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
1165/// The top-level name bindings of a module's source. `package` is the dotted package its
1166/// relative imports resolve against. `None` when the source does not parse.
1167fn module_scope(source: &str, package: &[String]) -> Option<ModuleScope> {
1168    let suite = ast::Suite::parse(source, "module.py").ok()?;
1169    let mut scope = ModuleScope {
1170        bindings: HashMap::new(),
1171        has_star_import: false,
1172    };
1173    for stmt in &suite {
1174        match stmt {
1175            ast::Stmt::Import(node) => {
1176                for alias in &node.names {
1177                    match &alias.asname {
1178                        Some(asname) => {
1179                            scope.bind(asname.to_string(), Binding::Module(alias.name.to_string()));
1180                        }
1181                        None => {
1182                            let head = import_head(alias.name.as_str());
1183                            scope.bind(head.to_string(), Binding::Module(head.to_string()));
1184                        }
1185                    }
1186                }
1187            }
1188            ast::Stmt::ImportFrom(node) => scope.bind_import_from(node, package),
1189            ast::Stmt::FunctionDef(node) => scope.bind(node.name.to_string(), Binding::Defined),
1190            ast::Stmt::AsyncFunctionDef(node) => {
1191                scope.bind(node.name.to_string(), Binding::Defined);
1192            }
1193            ast::Stmt::ClassDef(node) => scope.bind(node.name.to_string(), Binding::Defined),
1194            ast::Stmt::Assign(node) => {
1195                let literal = is_literal(&node.value);
1196                for target in &node.targets {
1197                    scope.bind_assign_target(target, literal);
1198                }
1199            }
1200            ast::Stmt::AnnAssign(node) => {
1201                if let Some(value) = &node.value {
1202                    scope.bind_assign_target(&node.target, is_literal(value));
1203                }
1204            }
1205            _ => {}
1206        }
1207    }
1208    Some(scope)
1209}
1210
1211/// `true` for an expression built of literals alone — data the module itself defines.
1212fn is_literal(expr: &Expr) -> bool {
1213    match expr {
1214        Expr::Constant(_) => true,
1215        Expr::UnaryOp(op) => is_literal(&op.operand),
1216        Expr::Dict(dict) => {
1217            dict.keys.iter().flatten().all(is_literal) && dict.values.iter().all(is_literal)
1218        }
1219        Expr::List(list) => list.elts.iter().all(is_literal),
1220        Expr::Tuple(tuple) => tuple.elts.iter().all(is_literal),
1221        Expr::Set(set) => set.elts.iter().all(is_literal),
1222        _ => false,
1223    }
1224}
1225
1226/// The dotted target a patch call names, resolved statically: the string literal for
1227/// `patch(...)` (and a string-target `patch.dict`), the resolved first argument for the
1228/// object forms. `None` when the target resists static resolution — nothing fires.
1229fn patch_target(call: &ExprCall, ctx: &ResolveCtx) -> Option<String> {
1230    match patch_form(call)? {
1231        PatchForm::Target => patch_string_target(call).map(str::to_owned),
1232        PatchForm::Dict => patch_string_target(call)
1233            .map(str::to_owned)
1234            .or_else(|| resolve_object_target(call.args.first()?, ctx)),
1235        PatchForm::Object => {
1236            let base = resolve_object_target(call.args.first()?, ctx)?;
1237            Some(match string_arg(call, 1) {
1238                Some(attr) => format!("{base}.{attr}"),
1239                None => base,
1240            })
1241        }
1242    }
1243}
1244
1245/// `true` when a patch target names an UPPER_CASE constant (`"pkg.cfg.CACHE_DIR"`).
1246fn patches_constant(target: &str) -> bool {
1247    target.rsplit('.').next().is_some_and(is_upper_constant)
1248}
1249
1250/// `true` when patch `target`'s head segment names the first-party package `pkg`.
1251fn patches_first_party(target: &str, pkg: &str) -> bool {
1252    target
1253        .split('.')
1254        .next()
1255        .is_some_and(|head| !head.is_empty() && head == pkg)
1256}
1257
1258/// `true` for an ALL-CAPS name: uppercase letters, digits, underscores, one letter minimum.
1259fn is_upper_constant(name: &str) -> bool {
1260    !name.is_empty()
1261        && name
1262            .chars()
1263            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
1264        && name.chars().any(|c| c.is_ascii_uppercase())
1265}
1266
1267const ENVIRON_MUTATION_MSG: &str =
1268    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
1269
1270/// `true` for the expression `os.environ`.
1271fn is_os_environ(expr: &Expr) -> bool {
1272    matches!(
1273        expr,
1274        Expr::Attribute(attr)
1275            if attr.attr.as_str() == "environ"
1276                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
1277    )
1278}
1279
1280/// `true` for `os.environ[...]`, the form used as an assignment or `del` target.
1281fn is_os_environ_subscript(expr: &Expr) -> bool {
1282    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
1283}
1284
1285/// `true` for a mutating method call on `os.environ`, like `os.environ.update(...)`.
1286fn is_environ_mutation_call(call: &ExprCall) -> bool {
1287    matches!(
1288        call.func.as_ref(),
1289        Expr::Attribute(attr)
1290            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
1291    )
1292}
1293
1294/// `true` for a `dict` method that mutates in place.
1295fn is_environ_mutator(method: &str) -> bool {
1296    matches!(
1297        method,
1298        "update" | "pop" | "setdefault" | "clear" | "popitem"
1299    )
1300}
1301
1302/// The 1-based line containing byte `offset` in `source`.
1303fn line_of(source: &str, offset: TextSize) -> usize {
1304    let offset = (u32::from(offset) as usize).min(source.len());
1305    source.as_bytes()[..offset]
1306        .iter()
1307        .filter(|&&byte| byte == b'\n')
1308        .count()
1309        + 1
1310}
1311
1312/// The dist's own top-level import package: the nearest `pyproject.toml`'s `[project].name`,
1313/// [normalized](normalize_dist_name). The walk up stops at a `.git` boundary so it can't
1314/// escape into an unrelated project, and `None` means nothing is flagged rather than guessed.
1315fn first_party_package(root: &Path) -> Option<String> {
1316    first_party_manifest(root).map(|(name, _)| name)
1317}
1318
1319/// [`first_party_package`] plus the directory holding the manifest, where module sources live.
1320fn first_party_manifest(root: &Path) -> Option<(String, PathBuf)> {
1321    for dir in root.ancestors() {
1322        let candidate = dir.join("pyproject.toml");
1323        if candidate.is_file() {
1324            return read_project_name(&candidate)
1325                .map(|name| (normalize_dist_name(&name), dir.to_path_buf()));
1326        }
1327        if dir.join(".git").exists() {
1328            break;
1329        }
1330    }
1331    None
1332}
1333
1334/// `[project].name` from a `pyproject.toml`, if present and a string.
1335fn read_project_name(path: &Path) -> Option<String> {
1336    let contents = std::fs::read_to_string(path).ok()?;
1337    let value: toml::Value = toml::from_str(&contents).ok()?;
1338    value
1339        .get("project")?
1340        .get("name")?
1341        .as_str()
1342        .map(str::to_owned)
1343}
1344
1345/// A distribution name as its import package name, PEP 503-flavoured: `My-Project` →
1346/// `my_project`.
1347fn normalize_dist_name(name: &str) -> String {
1348    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1349}
1350
1351fn collect_python_files(
1352    dir: &Path,
1353    out: &mut Vec<PathBuf>,
1354    is_match: fn(&Path) -> bool,
1355) -> Result<()> {
1356    let entries =
1357        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1358    for entry in entries {
1359        let path = crate::walk::dir_entry(entry, dir)?.path();
1360        if path.is_dir() {
1361            collect_python_files(&path, out, is_match)?;
1362        } else if is_match(&path) {
1363            out.push(path);
1364        }
1365    }
1366    Ok(())
1367}
1368
1369/// `true` for a file the integration lints scan: `*_test.py` or `conftest.py`. A legacy
1370/// `test_*.py` is ordinary source.
1371fn is_python_test_file(path: &Path) -> bool {
1372    let name = path
1373        .file_name()
1374        .and_then(|n| n.to_str())
1375        .unwrap_or_default();
1376    name == "conftest.py" || name.ends_with("_test.py")
1377}
1378
1379/// `true` for a colocated unit test: `*_test.py`. A legacy `test_*.py` is ordinary source,
1380/// and `conftest.py` holds fixtures rather than a unit.
1381fn is_python_unit_test_file(path: &Path) -> bool {
1382    let name = path
1383        .file_name()
1384        .and_then(|n| n.to_str())
1385        .unwrap_or_default();
1386    name.ends_with("_test.py")
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391    use super::*;
1392    use std::sync::atomic::{AtomicU64, Ordering};
1393
1394    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
1395    struct TempDir(PathBuf);
1396
1397    impl TempDir {
1398        fn new() -> Self {
1399            static COUNTER: AtomicU64 = AtomicU64::new(0);
1400            let dir = std::env::temp_dir().join(format!(
1401                "tc-lint-{}-{}",
1402                std::process::id(),
1403                COUNTER.fetch_add(1, Ordering::Relaxed),
1404            ));
1405            std::fs::create_dir_all(&dir).unwrap();
1406            TempDir(dir)
1407        }
1408
1409        fn write(&self, name: &str, contents: &str) {
1410            let path = self.0.join(name);
1411            if let Some(parent) = path.parent() {
1412                std::fs::create_dir_all(parent).unwrap();
1413            }
1414            std::fs::write(path, contents).unwrap();
1415        }
1416    }
1417
1418    impl Drop for TempDir {
1419        fn drop(&mut self) {
1420            let _ = std::fs::remove_dir_all(&self.0);
1421        }
1422    }
1423
1424    #[test]
1425    fn normalize_dist_name_maps_to_import_name() {
1426        assert_eq!(normalize_dist_name("My-Project"), "my_project");
1427        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1428        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
1429        assert_eq!(normalize_dist_name("myproject"), "myproject");
1430    }
1431
1432    /// Parse `src` (a single expression statement) and return its call.
1433    fn parse_call(src: &str) -> ExprCall {
1434        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1435        let stmt = suite.into_iter().next().expect("one statement");
1436        (*stmt.expect_expr_stmt().value).expect_call_expr()
1437    }
1438
1439    /// A ctx with no manifest, where trailing segments append without classification.
1440    fn naive_ctx<'a>(
1441        imports: &'a HashMap<String, String>,
1442        declared: &'a HashSet<String>,
1443    ) -> ResolveCtx<'a> {
1444        ResolveCtx {
1445            imports,
1446            declared_modules: declared,
1447            first_party: None,
1448            source_root: None,
1449        }
1450    }
1451
1452    #[test]
1453    fn patch_target_only_reads_string_literals_for_the_string_form() {
1454        let imports = HashMap::new();
1455        let declared = HashSet::new();
1456        let ctx = naive_ctx(&imports, &declared);
1457        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1458        assert_eq!(
1459            patch_target(&str_call, &ctx).as_deref(),
1460            Some("pkg.mod.attr")
1461        );
1462        // A name in `patch(...)` holds a string, which static resolution cannot read.
1463        let name_call = parse_call("patch(target)\n");
1464        assert_eq!(patch_target(&name_call, &ctx), None);
1465        let int_call = parse_call("patch(42)\n");
1466        assert_eq!(patch_target(&int_call, &ctx), None);
1467        let empty_call = parse_call("patch()\n");
1468        assert_eq!(patch_target(&empty_call, &ctx), None);
1469    }
1470
1471    /// An import map binding the names the object-form snippets use.
1472    fn object_form_imports() -> HashMap<String, String> {
1473        HashMap::from([
1474            ("ledger".to_string(), "myproject.ledger".to_string()),
1475            ("myproject".to_string(), "myproject".to_string()),
1476            ("cfg".to_string(), "myproject.cfg".to_string()),
1477        ])
1478    }
1479
1480    #[test]
1481    fn patch_target_resolves_object_forms_through_imports() {
1482        let imports = object_form_imports();
1483        let declared = HashSet::new();
1484        let ctx = naive_ctx(&imports, &declared);
1485        let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1486        assert_eq!(
1487            patch_target(&imported_name, &ctx).as_deref(),
1488            Some("myproject.ledger.record")
1489        );
1490        let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1491        assert_eq!(
1492            patch_target(&dotted_module, &ctx).as_deref(),
1493            Some("myproject.ledger.record")
1494        );
1495        // A non-literal attribute still names the base module, enough for the first-party rule.
1496        let name_attr = parse_call("patch.object(ledger, attr)\n");
1497        assert_eq!(
1498            patch_target(&name_attr, &ctx).as_deref(),
1499            Some("myproject.ledger")
1500        );
1501        let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1502        assert_eq!(
1503            patch_target(&dict_object, &ctx).as_deref(),
1504            Some("myproject.cfg.SETTINGS")
1505        );
1506        let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1507        assert_eq!(
1508            patch_target(&dict_string, &ctx).as_deref(),
1509            Some("pkg.cfg.FLAGS")
1510        );
1511    }
1512
1513    #[test]
1514    fn patch_target_declines_a_base_bound_by_no_import() {
1515        let imports = object_form_imports();
1516        let declared = HashSet::new();
1517        let ctx = naive_ctx(&imports, &declared);
1518        let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1519        assert_eq!(patch_target(&call_base, &ctx), None);
1520        let unbound_name = parse_call("patch.object(client, \"send\")\n");
1521        assert_eq!(patch_target(&unbound_name, &ctx), None);
1522        let empty = parse_call("patch.object()\n");
1523        assert_eq!(patch_target(&empty, &ctx), None);
1524    }
1525
1526    /// A tree holding `pyproject.toml` (`name = "myproject"`) plus one integration test
1527    /// whose fixture patches `patch.object(<base>, "<attr>")`.
1528    fn object_patch_tree(base: &str, attr: &str) -> TempDir {
1529        let tree = TempDir::new();
1530        tree.write(
1531            "pyproject.toml",
1532            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1533        );
1534        tree.write(
1535            "tests/integration/spy_test.py",
1536            &format!(
1537                "from unittest.mock import patch\n\
1538                 import pytest\n\
1539                 from myproject import async_mod\n\
1540                 @pytest.fixture\n\
1541                 def spy():\n\
1542                 \x20   with patch.object({base}, \"{attr}\") as s:\n\
1543                 \x20       yield s\n"
1544            ),
1545        );
1546        tree
1547    }
1548
1549    fn first_party_patch_count(tree: &TempDir) -> usize {
1550        find_violations(&tree.0)
1551            .expect("walking a readable tree should succeed")
1552            .iter()
1553            .filter(|v| v.rule == "no-first-party-patch")
1554            .count()
1555    }
1556
1557    #[test]
1558    fn object_form_stdlib_reached_through_first_party_is_not_flagged() {
1559        let tree = object_patch_tree("async_mod.asyncio", "to_thread");
1560        tree.write("myproject/async_mod.py", "import asyncio\n");
1561        assert_eq!(first_party_patch_count(&tree), 0);
1562    }
1563
1564    #[test]
1565    fn object_form_unnamed_module_attribute_declines_to_fire() {
1566        let missing_source = object_patch_tree("async_mod.transport", "send");
1567        assert_eq!(first_party_patch_count(&missing_source), 0);
1568        let dynamic = object_patch_tree("async_mod.transport", "send");
1569        dynamic.write("myproject/async_mod.py", "transport = build()\n");
1570        assert_eq!(first_party_patch_count(&dynamic), 0);
1571    }
1572
1573    #[test]
1574    fn object_form_first_party_module_attribute_still_fires() {
1575        let tree = object_patch_tree("async_mod.helper", "run");
1576        tree.write("myproject/async_mod.py", "from . import helper\n");
1577        assert_eq!(first_party_patch_count(&tree), 1);
1578    }
1579
1580    #[test]
1581    fn object_form_resolves_in_a_src_layout() {
1582        let tree = object_patch_tree("async_mod.helper", "run");
1583        tree.write("src/myproject/async_mod.py", "from . import helper\n");
1584        assert_eq!(first_party_patch_count(&tree), 1);
1585    }
1586
1587    #[test]
1588    fn object_form_star_import_over_an_unbound_name_declines() {
1589        let tree = object_patch_tree("async_mod.walk", "call");
1590        tree.write("myproject/async_mod.py", "from os import *\n");
1591        assert_eq!(first_party_patch_count(&tree), 0);
1592    }
1593
1594    #[test]
1595    fn object_form_conflicting_binding_declines() {
1596        let tree = object_patch_tree("async_mod.helper", "run");
1597        tree.write(
1598            "myproject/async_mod.py",
1599            "from . import helper\nhelper = None\n",
1600        );
1601        assert_eq!(first_party_patch_count(&tree), 0);
1602    }
1603
1604    #[test]
1605    fn object_form_defined_name_mid_chain_declines() {
1606        let tree = object_patch_tree("async_mod.Client.send", "retry");
1607        tree.write("myproject/async_mod.py", "class Client:\n    pass\n");
1608        assert_eq!(first_party_patch_count(&tree), 0);
1609    }
1610
1611    #[test]
1612    fn object_form_package_attribute_resolves_through_init() {
1613        let tree = TempDir::new();
1614        tree.write(
1615            "pyproject.toml",
1616            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1617        );
1618        tree.write("myproject/sub/__init__.py", "from . import leaf\n");
1619        tree.write("myproject/sub/leaf.py", "def run():\n    pass\n");
1620        tree.write(
1621            "tests/integration/spy_test.py",
1622            "from unittest.mock import patch\n\
1623             import pytest\n\
1624             from myproject import sub\n\
1625             @pytest.fixture\n\
1626             def spy():\n\
1627             \x20   with patch.object(sub.leaf, \"run\") as s:\n\
1628             \x20       yield s\n",
1629        );
1630        assert_eq!(first_party_patch_count(&tree), 1);
1631    }
1632
1633    #[test]
1634    fn object_form_unbound_name_falls_back_to_the_submodule_file() {
1635        let tree = TempDir::new();
1636        tree.write(
1637            "pyproject.toml",
1638            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1639        );
1640        tree.write("myproject/sub/__init__.py", "");
1641        tree.write("myproject/sub/leaf.py", "def run():\n    pass\n");
1642        tree.write(
1643            "tests/integration/spy_test.py",
1644            "from unittest.mock import patch\n\
1645             import pytest\n\
1646             from myproject import sub\n\
1647             @pytest.fixture\n\
1648             def spy():\n\
1649             \x20   with patch.object(sub.leaf, \"run\") as s:\n\
1650             \x20       yield s\n",
1651        );
1652        assert_eq!(first_party_patch_count(&tree), 1);
1653    }
1654
1655    #[test]
1656    fn resolve_appends_naively_past_a_third_party_head() {
1657        let tree = TempDir::new();
1658        let imports = HashMap::from([("requests".to_string(), "requests".to_string())]);
1659        let declared = HashSet::new();
1660        let ctx = ResolveCtx {
1661            imports: &imports,
1662            declared_modules: &declared,
1663            first_party: Some("myproject"),
1664            source_root: Some(&tree.0),
1665        };
1666        let call = parse_call("patch.object(requests.utils, \"default_headers\")\n");
1667        assert_eq!(
1668            patch_target(&call, &ctx).as_deref(),
1669            Some("requests.utils.default_headers")
1670        );
1671    }
1672
1673    #[test]
1674    fn locate_module_tries_flat_and_src_layouts() {
1675        let tree = TempDir::new();
1676        tree.write("myproject/flat.py", "");
1677        tree.write("myproject/pkg/__init__.py", "");
1678        tree.write("src/myproject/nested.py", "");
1679        let flat = locate_module(&tree.0, "myproject.flat").expect("flat module");
1680        assert_eq!(flat.path, tree.0.join("myproject/flat.py"));
1681        assert_eq!(flat.package, vec!["myproject".to_string()]);
1682        let pkg = locate_module(&tree.0, "myproject.pkg").expect("package module");
1683        assert_eq!(pkg.path, tree.0.join("myproject/pkg/__init__.py"));
1684        assert_eq!(
1685            pkg.package,
1686            vec!["myproject".to_string(), "pkg".to_string()]
1687        );
1688        let nested = locate_module(&tree.0, "myproject.nested").expect("src module");
1689        assert_eq!(nested.path, tree.0.join("src/myproject/nested.py"));
1690        assert!(locate_module(&tree.0, "myproject.absent").is_none());
1691    }
1692
1693    fn scope_of(source: &str) -> ModuleScope {
1694        module_scope(source, &["myproject".to_string()]).expect("source should parse")
1695    }
1696
1697    #[test]
1698    fn module_scope_classifies_import_bindings() {
1699        let scope = scope_of(
1700            "import asyncio\n\
1701             import myproject.util as util\n\
1702             from . import helper\n\
1703             from .sub import leaf\n\
1704             from myproject.vendor import client as vc\n",
1705        );
1706        assert_eq!(
1707            scope.bindings.get("asyncio"),
1708            Some(&Binding::Module("asyncio".to_string()))
1709        );
1710        assert_eq!(
1711            scope.bindings.get("util"),
1712            Some(&Binding::Module("myproject.util".to_string()))
1713        );
1714        assert_eq!(
1715            scope.bindings.get("helper"),
1716            Some(&Binding::Module("myproject.helper".to_string()))
1717        );
1718        assert_eq!(
1719            scope.bindings.get("leaf"),
1720            Some(&Binding::Module("myproject.sub.leaf".to_string()))
1721        );
1722        assert_eq!(
1723            scope.bindings.get("vc"),
1724            Some(&Binding::Module("myproject.vendor.client".to_string()))
1725        );
1726    }
1727
1728    #[test]
1729    fn module_scope_classifies_definitions_and_assignments() {
1730        let scope = scope_of(
1731            "def run():\n    pass\n\
1732             async def poll():\n    pass\n\
1733             class Client:\n    pass\n\
1734             LIMITS = [1, 2]\n\
1735             OFFSET = -1\n\
1736             registry = {\"on\": True}\n\
1737             PAIR: tuple = (1, 2)\n\
1738             transport = build()\n\
1739             alias = registry\n\
1740             a, b = make()\n",
1741        );
1742        for name in [
1743            "run", "poll", "Client", "LIMITS", "OFFSET", "registry", "PAIR",
1744        ] {
1745            assert_eq!(scope.bindings.get(name), Some(&Binding::Defined), "{name}");
1746        }
1747        for name in ["transport", "alias", "a", "b"] {
1748            assert_eq!(scope.bindings.get(name), Some(&Binding::Opaque), "{name}");
1749        }
1750    }
1751
1752    #[test]
1753    fn module_scope_marks_conflicts_opaque_and_dedupes_repeats() {
1754        let scope = scope_of("import asyncio\nimport asyncio\nfrom . import helper\nhelper = 1\n");
1755        assert_eq!(
1756            scope.bindings.get("asyncio"),
1757            Some(&Binding::Module("asyncio".to_string()))
1758        );
1759        assert_eq!(scope.bindings.get("helper"), Some(&Binding::Opaque));
1760    }
1761
1762    #[test]
1763    fn module_scope_flags_star_imports_and_rejects_deep_relatives() {
1764        let scope = scope_of("from os import *\nfrom .. import escape\n");
1765        assert!(scope.has_star_import);
1766        assert_eq!(scope.bindings.get("escape"), Some(&Binding::Opaque));
1767    }
1768
1769    #[test]
1770    fn module_scope_rejects_unparsable_source() {
1771        assert!(module_scope("def (\n", &[]).is_none());
1772    }
1773
1774    #[test]
1775    fn module_scope_binds_list_unpack_targets_as_opaque() {
1776        let scope = scope_of("[c, d] = make()\n");
1777        assert_eq!(scope.bindings.get("c"), Some(&Binding::Opaque));
1778        assert_eq!(scope.bindings.get("d"), Some(&Binding::Opaque));
1779    }
1780
1781    #[test]
1782    fn module_scope_skips_an_attribute_target() {
1783        let scope = scope_of("obj.attr = 1\n");
1784        assert!(scope.bindings.is_empty(), "{:?}", scope.bindings);
1785    }
1786
1787    #[test]
1788    fn module_scope_ignores_non_binding_statements() {
1789        let scope = scope_of("print(1)\n");
1790        assert!(scope.bindings.is_empty(), "{:?}", scope.bindings);
1791    }
1792
1793    #[test]
1794    fn module_scope_treats_a_set_literal_as_defined() {
1795        let scope = scope_of("NAMES = {1, 2}\n");
1796        assert_eq!(scope.bindings.get("NAMES"), Some(&Binding::Defined));
1797    }
1798
1799    /// The imports and declared modules a [`LintVisitor`] records for `src`.
1800    fn collect_bindings(src: &str) -> (HashMap<String, String>, HashSet<String>) {
1801        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1802        let mut visitor = LintVisitor {
1803            file: Path::new("t.py"),
1804            source: src,
1805            fixture_depth: 0,
1806            first_party: None,
1807            source_root: None,
1808            imports: HashMap::new(),
1809            declared_modules: HashSet::new(),
1810            violations: Vec::new(),
1811        };
1812        for stmt in suite {
1813            visitor.visit_stmt(stmt);
1814        }
1815        (visitor.imports, visitor.declared_modules)
1816    }
1817
1818    fn collect_imports(src: &str) -> HashMap<String, String> {
1819        collect_bindings(src).0
1820    }
1821
1822    #[test]
1823    fn lint_visitor_binds_imports_to_their_modules() {
1824        let imports = collect_imports(
1825            "import myproject.ledger\n\
1826             import myproject.config as cfg\n\
1827             from myproject import ledger\n\
1828             from myproject import charge as ch\n\
1829             from . import rel\n",
1830        );
1831        assert_eq!(
1832            imports.get("myproject").map(String::as_str),
1833            Some("myproject")
1834        );
1835        assert_eq!(
1836            imports.get("cfg").map(String::as_str),
1837            Some("myproject.config")
1838        );
1839        assert_eq!(
1840            imports.get("ledger").map(String::as_str),
1841            Some("myproject.ledger")
1842        );
1843        assert_eq!(
1844            imports.get("ch").map(String::as_str),
1845            Some("myproject.charge")
1846        );
1847        assert_eq!(imports.get("rel"), None);
1848    }
1849
1850    #[test]
1851    fn lint_visitor_declares_every_import_prefix() {
1852        let (_, declared) = collect_bindings(
1853            "import myproject.sub.ledger\n\
1854             from myproject.api import charge\n\
1855             from . import rel\n",
1856        );
1857        for module in [
1858            "myproject",
1859            "myproject.sub",
1860            "myproject.sub.ledger",
1861            "myproject.api",
1862        ] {
1863            assert!(declared.contains(module), "{module}");
1864        }
1865        assert!(!declared.contains("myproject.api.charge"));
1866        assert!(!declared.contains("rel"));
1867    }
1868
1869    /// Build a `from <source> import <symbols>` record (`source: None` → relative).
1870    fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1871        ImportRecord {
1872            display: source.unwrap_or(".rel").to_string(),
1873            line: 1,
1874            is_uut: false,
1875            symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1876            source: source.map(str::to_string),
1877            module: None,
1878        }
1879    }
1880
1881    fn targets(list: &[&str]) -> Vec<String> {
1882        list.iter().map(|s| (*s).to_string()).collect()
1883    }
1884
1885    #[test]
1886    fn is_mocked_requires_every_symbol_at_the_import_module() {
1887        let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1888        // Only `record` patched → the un-mocked `erase` leaves the import un-mocked.
1889        assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1890        assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1891    }
1892
1893    #[test]
1894    fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1895        let rec = from_import(Some("pkg.ledger"), &["record"]);
1896        // Same last segment, different module → not mocked.
1897        assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1898        let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1899        assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1900        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1901    }
1902
1903    #[test]
1904    fn is_mocked_relative_import_accepts_a_last_segment_match() {
1905        // A relative import has no module to compare, so a last-segment match is accepted.
1906        let rec = from_import(None, &["record"]);
1907        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1908        assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1909    }
1910
1911    #[test]
1912    fn is_mocked_module_import_matches_a_patch_reaching_in() {
1913        let rec = ImportRecord {
1914            display: "pkg.db".to_string(),
1915            line: 1,
1916            is_uut: false,
1917            symbols: Vec::new(),
1918            source: None,
1919            module: Some("pkg.db".to_string()),
1920        };
1921        assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1922        assert!(rec.is_mocked(&targets(&["pkg.db"])));
1923        assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1924        let empty = from_import(Some("pkg.mod"), &[]);
1925        assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1926    }
1927
1928    #[test]
1929    fn patches_first_party_matches_head_segment() {
1930        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1931        assert!(patches_first_party("myproject", "myproject"));
1932        assert!(!patches_first_party("requests.get", "myproject"));
1933        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1934        assert!(!patches_first_party("", "myproject"));
1935        assert!(!patches_first_party(".leading", "myproject"));
1936    }
1937
1938    #[test]
1939    fn first_party_package_reads_pyproject_name() {
1940        let tree = TempDir::new();
1941        tree.write(
1942            "pyproject.toml",
1943            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1944        );
1945        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1946    }
1947
1948    #[test]
1949    fn first_party_package_is_none_without_a_project_name() {
1950        let tree = TempDir::new();
1951        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1952        tree.write(".git", "");
1953        assert_eq!(first_party_package(&tree.0), None);
1954    }
1955
1956    #[test]
1957    fn first_party_package_is_none_when_absent() {
1958        let tree = TempDir::new();
1959        assert_eq!(first_party_package(&tree.0), None);
1960    }
1961
1962    /// The displays of the imports `source` leaves un-mocked.
1963    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1964        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1965        let mut visitor = UnitIsolationVisitor {
1966            source,
1967            first_party,
1968            base,
1969            type_checking_depth: 0,
1970            imports: Vec::new(),
1971            patch_targets: Vec::new(),
1972        };
1973        for stmt in suite {
1974            visitor.visit_stmt(stmt);
1975        }
1976        visitor
1977            .imports
1978            .iter()
1979            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1980            .map(|i| i.display.clone())
1981            .collect()
1982    }
1983
1984    #[test]
1985    fn import_head_and_last_segment() {
1986        assert_eq!(import_head("myproject.db.conn"), "myproject");
1987        assert_eq!(import_head("requests"), "requests");
1988        assert_eq!(last_segment("myproject.db.conn"), "conn");
1989        assert_eq!(last_segment("widget"), "widget");
1990    }
1991
1992    #[test]
1993    fn unit_under_test_base_strips_test_suffix() {
1994        assert_eq!(
1995            unit_under_test_base(Path::new("pkg/widget_test.py")),
1996            "widget"
1997        );
1998        // Only `*_test.py` reaches here, so a legacy `test_*.py` keeps its prefix.
1999        assert_eq!(
2000            unit_under_test_base(Path::new("test_widget.py")),
2001            "test_widget"
2002        );
2003        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
2004    }
2005
2006    #[test]
2007    fn recognizes_python_unit_test_files() {
2008        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
2009        assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
2010        assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
2011        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
2012        assert!(!is_python_unit_test_file(Path::new("widget.py")));
2013    }
2014
2015    #[test]
2016    fn visitor_flags_first_party_and_external_collaborators() {
2017        // The UUT is left alone; the first-party and third-party imports are flagged.
2018        let found = unmocked(
2019            "widget",
2020            "myproject",
2021            "from myproject.widget import build\n\
2022             from myproject.ledger import record\n\
2023             import requests\n",
2024        );
2025        assert_eq!(
2026            found,
2027            vec!["myproject.ledger".to_string(), "requests".to_string()]
2028        );
2029    }
2030
2031    #[test]
2032    fn visitor_clears_a_mocked_collaborator() {
2033        let found = unmocked(
2034            "widget",
2035            "myproject",
2036            "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
2037        );
2038        assert!(found.is_empty(), "got: {found:?}");
2039    }
2040
2041    #[test]
2042    fn visitor_flags_a_wrong_module_patch() {
2043        // A patch sharing only the last segment names a different module, so `record`
2044        // stays an un-mocked collaborator.
2045        let found = unmocked(
2046            "widget",
2047            "myproject",
2048            "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
2049        );
2050        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2051    }
2052
2053    #[test]
2054    fn visitor_flags_a_partly_mocked_multi_symbol_import() {
2055        // Patching only `record` leaves the sibling `erase` a real collaborator.
2056        let found = unmocked(
2057            "widget",
2058            "myproject",
2059            "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
2060        );
2061        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2062        let both = unmocked(
2063            "widget",
2064            "myproject",
2065            "from myproject.ledger import record, erase\n\
2066             patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
2067        );
2068        assert!(both.is_empty(), "got: {both:?}");
2069    }
2070
2071    #[test]
2072    fn visitor_handles_module_and_relative_imports() {
2073        assert_eq!(
2074            unmocked("widget", "myproject", "import myproject.db\n"),
2075            vec!["myproject.db".to_string()]
2076        );
2077        assert!(unmocked(
2078            "widget",
2079            "myproject",
2080            "import myproject.db\npatch(\"myproject.db.connect\")\n"
2081        )
2082        .is_empty());
2083        assert_eq!(
2084            unmocked("widget", "myproject", "from .ledger import record\n"),
2085            vec![".ledger".to_string()]
2086        );
2087        assert_eq!(
2088            unmocked(
2089                "widget",
2090                "myproject",
2091                "from . import ledger\nfrom . import widget\n"
2092            ),
2093            vec![".ledger".to_string()]
2094        );
2095    }
2096
2097    #[test]
2098    fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
2099        // A bare `from . import …` names the package's own re-export surface, the SUT.
2100        assert!(unmocked(
2101            "__init__",
2102            "myproject",
2103            "from . import Thing, __all__, __version__\n"
2104        )
2105        .is_empty());
2106        // Reaching around the barrel into a sibling module is still a collaborator.
2107        assert_eq!(
2108            unmocked("__init__", "myproject", "from .core import Thing\n"),
2109            vec![".core".to_string()]
2110        );
2111        // `from .. import x` resolves to the parent package, not the SUT file.
2112        assert_eq!(
2113            unmocked("__init__", "myproject", "from .. import sibling\n"),
2114            vec!["..sibling".to_string()]
2115        );
2116        // The barrel shortcut is scoped to the `__init__` base.
2117        assert_eq!(
2118            unmocked("widget", "myproject", "from . import ledger\n"),
2119            vec![".ledger".to_string()]
2120        );
2121    }
2122
2123    #[test]
2124    fn visitor_skips_type_checking_imports() {
2125        // A TYPE_CHECKING import is type-only; the runtime `else` import is still seen.
2126        let found = unmocked(
2127            "widget",
2128            "myproject",
2129            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
2130        );
2131        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2132    }
2133
2134    #[test]
2135    fn is_checked_import_classifies_origins() {
2136        assert!(is_checked_import("myproject", "myproject")); // first-party
2137        assert!(!is_checked_import("pytest", "myproject")); // test framework
2138        assert!(!is_checked_import("_pytest", "myproject"));
2139        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
2140        assert!(is_checked_import("socket", "myproject"));
2141        assert!(!is_checked_import("json", "myproject")); // pure stdlib
2142        assert!(!is_checked_import("dataclasses", "myproject"));
2143        assert!(is_checked_import("requests", "myproject")); // third-party
2144        assert!(is_checked_import("stripe", "myproject"));
2145        // A dual-nature head stays pure — the patch convention catches it, not the import.
2146        assert!(!is_checked_import("os", "myproject"));
2147        assert!(!is_checked_import("pathlib", "myproject"));
2148        assert!(!is_checked_import("datetime", "myproject"));
2149    }
2150
2151    #[test]
2152    fn is_checked_import_classifies_private_stdlib_as_stdlib() {
2153        assert!(!is_checked_import("__future__", "myproject"));
2154        assert!(!is_checked_import("_thread", "myproject"));
2155        assert!(!is_checked_import("_socket", "myproject"));
2156        assert!(!is_checked_import("_ast", "myproject"));
2157        assert!(!is_checked_import("_collections_abc", "myproject"));
2158        assert!(is_checked_import("_stripe", "myproject")); // third-party
2159    }
2160
2161    #[test]
2162    fn visitor_flags_external_collaborators() {
2163        let found = unmocked(
2164            "widget",
2165            "myproject",
2166            "import requests\nimport subprocess\nimport json\nimport pytest\n",
2167        );
2168        assert_eq!(found.len(), 2, "got: {found:?}");
2169        assert!(found.contains(&"requests".to_string()));
2170        assert!(found.contains(&"subprocess".to_string()));
2171    }
2172
2173    #[test]
2174    fn visitor_type_checking_variants_and_plain_if() {
2175        // The attribute form guards type-only imports too.
2176        assert!(unmocked(
2177            "widget",
2178            "myproject",
2179            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
2180        )
2181        .is_empty());
2182        // A plain `if` is walked normally; its import is still a collaborator.
2183        assert_eq!(
2184            unmocked(
2185                "widget",
2186                "myproject",
2187                "if ready == 1:\n    from myproject.ledger import record\n"
2188            ),
2189            vec!["myproject.ledger".to_string()]
2190        );
2191    }
2192
2193    #[test]
2194    fn find_unit_isolation_without_pyproject_reports_nothing() {
2195        let tree = TempDir::new();
2196        tree.write("widget_test.py", "from myproject.ledger import record\n");
2197        tree.write(".git", "");
2198        assert!(find_unit_isolation_violations(&tree.0)
2199            .expect("a readable tree should succeed")
2200            .is_empty());
2201    }
2202
2203    #[test]
2204    fn find_unit_isolation_walks_subdirs_and_flags() {
2205        let tree = TempDir::new();
2206        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2207        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
2208        let found =
2209            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
2210        assert_eq!(found.len(), 1, "got: {found:?}");
2211        assert_eq!(found[0].rule, "unmocked-collaborator");
2212        assert!(found[0].message.contains("myproject.ledger"));
2213    }
2214
2215    #[test]
2216    fn recognizes_python_test_files() {
2217        assert!(is_python_test_file(Path::new("widget_test.py")));
2218        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
2219        assert!(is_python_test_file(Path::new("conftest.py")));
2220        assert!(!is_python_test_file(Path::new("test_widget.py")));
2221    }
2222
2223    #[test]
2224    fn ignores_non_test_files() {
2225        assert!(!is_python_test_file(Path::new("widget.py")));
2226        assert!(!is_python_test_file(Path::new("conftest.pyi")));
2227        assert!(!is_python_test_file(Path::new("README.md")));
2228        assert!(!is_python_test_file(Path::new("testing.py")));
2229    }
2230
2231    #[test]
2232    fn line_of_counts_newlines() {
2233        let src = "a\nb\nc\n";
2234        assert_eq!(line_of(src, TextSize::from(0)), 1);
2235        assert_eq!(line_of(src, TextSize::from(2)), 2);
2236        assert_eq!(line_of(src, TextSize::from(4)), 3);
2237    }
2238
2239    #[test]
2240    fn recognizes_environ_mutators() {
2241        assert!(is_environ_mutator("update"));
2242        assert!(is_environ_mutator("pop"));
2243        assert!(is_environ_mutator("clear"));
2244        assert!(!is_environ_mutator("get"));
2245        assert!(!is_environ_mutator("keys"));
2246    }
2247
2248    /// The rules the suite lint reports for `source`, in report order.
2249    fn lint_rules(source: &str) -> Vec<&'static str> {
2250        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
2251        let mut visitor = LintVisitor {
2252            file: Path::new("t.py"),
2253            source,
2254            fixture_depth: 0,
2255            first_party: Some("myproject"),
2256            source_root: None,
2257            imports: HashMap::new(),
2258            declared_modules: HashSet::new(),
2259            violations: Vec::new(),
2260        };
2261        for stmt in suite {
2262            visitor.visit_stmt(stmt);
2263        }
2264        visitor.violations.iter().map(|v| v.rule).collect()
2265    }
2266
2267    #[test]
2268    fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
2269        assert!(
2270            lint_rules("@pytest.fixture\nasync def client():\n    patch(\"pkg.mod.attr\")\n")
2271                .is_empty()
2272        );
2273        assert_eq!(
2274            lint_rules("async def widget_test():\n    patch(\"pkg.mod.attr\")\n"),
2275            vec!["no-inline-patch"]
2276        );
2277        assert_eq!(
2278            lint_rules("async def widget_test(monkeypatch):\n    pass\n"),
2279            vec!["no-monkeypatch"]
2280        );
2281    }
2282
2283    #[test]
2284    fn an_augmented_assignment_to_environ_is_a_mutation() {
2285        assert_eq!(
2286            lint_rules("def widget_test():\n    os.environ[\"PATH\"] += \":/x\"\n"),
2287            vec!["no-environ-mutation"]
2288        );
2289        assert!(lint_rules("def widget_test():\n    total += 1\n").is_empty());
2290    }
2291
2292    #[test]
2293    fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
2294        assert!(
2295            lint_rules("@fixture\ndef client():\n    patch(\"pkg.mod.attr\")\n").is_empty(),
2296            "a bare `@fixture` shelters the patch"
2297        );
2298        assert_eq!(
2299            lint_rules("@registry[\"fixture\"]\ndef client():\n    patch(\"pkg.mod.attr\")\n"),
2300            vec!["no-inline-patch"],
2301            "a subscripted decorator is not a fixture"
2302        );
2303    }
2304
2305    #[test]
2306    fn patch_object_is_recognized_only_through_a_patch_receiver() {
2307        assert_eq!(
2308            lint_rules("def widget_test():\n    mock.patch.object(svc, \"send\")\n"),
2309            vec!["no-inline-patch"]
2310        );
2311        assert!(
2312            lint_rules("def widget_test():\n    helpers[0].object(svc, \"send\")\n").is_empty(),
2313            "a subscripted receiver is not `patch`"
2314        );
2315        assert!(
2316            lint_rules("def widget_test():\n    helpers[0](\"pkg.mod.attr\")\n").is_empty(),
2317            "a subscripted callee is not a patch call"
2318        );
2319    }
2320
2321    #[test]
2322    fn find_suite_without_a_tests_directory_reports_nothing() {
2323        let tree = TempDir::new();
2324        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2325        assert!(find_suite_violations(&tree.0)
2326            .expect("a readable tree should succeed")
2327            .is_empty());
2328    }
2329
2330    #[test]
2331    fn find_unit_isolation_without_a_project_name_reports_nothing() {
2332        let tree = TempDir::new();
2333        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
2334        tree.write("widget_test.py", "from myproject.ledger import record\n");
2335        assert!(find_unit_isolation_violations(&tree.0)
2336            .expect("a readable tree should succeed")
2337            .is_empty());
2338    }
2339
2340    #[test]
2341    fn recognizes_upper_constants() {
2342        assert!(is_upper_constant("CACHE_DIR"));
2343        assert!(is_upper_constant("DEBUG"));
2344        assert!(is_upper_constant("MAX_2"));
2345        assert!(!is_upper_constant("cache_dir"));
2346        assert!(!is_upper_constant("CacheDir"));
2347        assert!(!is_upper_constant("fetch"));
2348        assert!(!is_upper_constant(""));
2349        assert!(!is_upper_constant("_"));
2350        assert!(!is_upper_constant("123"));
2351    }
2352
2353    #[test]
2354    fn an_unreadable_test_file_names_the_file() {
2355        let tree = TempDir::new();
2356        std::fs::write(tree.0.join("widget_test.py"), [0xFF, 0xFE]).unwrap();
2357        let err = find_violations(&tree.0).unwrap_err();
2358        assert!(
2359            format!("{err:#}").contains("reading test file"),
2360            "got: {err:#}"
2361        );
2362    }
2363
2364    #[test]
2365    fn an_unparsable_test_file_names_the_file() {
2366        let tree = TempDir::new();
2367        tree.write("widget_test.py", "def broken(:\n");
2368        let err = find_violations(&tree.0).unwrap_err();
2369        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
2370    }
2371
2372    #[test]
2373    fn a_missing_root_is_an_error() {
2374        let err = find_violations(Path::new("/nonexistent-tc-lint")).unwrap_err();
2375        assert!(
2376            format!("{err:#}").contains("reading directory"),
2377            "got: {err:#}"
2378        );
2379    }
2380
2381    fn unit_isolation_tree() -> TempDir {
2382        let tree = TempDir::new();
2383        tree.write(
2384            "pyproject.toml",
2385            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
2386        );
2387        tree
2388    }
2389
2390    #[test]
2391    fn an_unreadable_unit_test_file_names_the_file() {
2392        let tree = unit_isolation_tree();
2393        std::fs::write(tree.0.join("widget_test.py"), [0xFF, 0xFE]).unwrap();
2394        let err = find_unit_isolation_violations(&tree.0).unwrap_err();
2395        assert!(
2396            format!("{err:#}").contains("reading test file"),
2397            "got: {err:#}"
2398        );
2399    }
2400
2401    #[test]
2402    fn an_unparsable_unit_test_file_names_the_file() {
2403        let tree = unit_isolation_tree();
2404        tree.write("widget_test.py", "def broken(:\n");
2405        let err = find_unit_isolation_violations(&tree.0).unwrap_err();
2406        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
2407    }
2408
2409    #[test]
2410    fn the_unit_under_test_import_is_never_a_collaborator() {
2411        let tree = unit_isolation_tree();
2412        tree.write("widget_test.py", "from myproject.widget import build\n");
2413        let violations = find_unit_isolation_violations(&tree.0).unwrap();
2414        assert!(violations.is_empty(), "got {violations:?}");
2415    }
2416
2417    #[test]
2418    fn a_starred_monkeypatch_parameter_is_flagged() {
2419        let tree = TempDir::new();
2420        tree.write(
2421            "widget_test.py",
2422            "def test_widget(*monkeypatch):\n    pass\n",
2423        );
2424        let violations = find_violations(&tree.0).unwrap();
2425        assert_eq!(violations.len(), 1, "got {violations:?}");
2426        assert_eq!(violations[0].rule, "no-monkeypatch");
2427    }
2428
2429    #[test]
2430    fn a_double_starred_monkeypatch_parameter_is_flagged() {
2431        let tree = TempDir::new();
2432        tree.write(
2433            "widget_test.py",
2434            "def test_widget(**monkeypatch):\n    pass\n",
2435        );
2436        let violations = find_violations(&tree.0).unwrap();
2437        assert_eq!(violations.len(), 1, "got {violations:?}");
2438        assert_eq!(violations[0].rule, "no-monkeypatch");
2439    }
2440
2441    /// Parse `src` (a single expression statement) and return the expression itself.
2442    fn parse_expr(src: &str) -> Expr {
2443        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
2444        let stmt = suite.into_iter().next().expect("one statement");
2445        *stmt.expect_expr_stmt().value
2446    }
2447
2448    #[test]
2449    fn a_called_fixture_decorator_is_recognized() {
2450        assert!(is_fixture_decorator(&parse_expr("pytest.fixture()\n")));
2451        assert!(is_fixture_decorator(&parse_expr("fixture()\n")));
2452        assert!(!is_fixture_decorator(&parse_expr("staticmethod()\n")));
2453    }
2454
2455    #[test]
2456    fn object_target_walks_declared_submodules() {
2457        let imports = HashMap::from([("myproject".to_string(), "myproject".to_string())]);
2458        let declared = HashSet::from(["myproject.sub".to_string()]);
2459        let ctx = naive_ctx(&imports, &declared);
2460        let call = parse_call("patch.object(myproject.sub.helper, \"run\")\n");
2461        assert_eq!(
2462            patch_target(&call, &ctx).as_deref(),
2463            Some("myproject.sub.helper.run")
2464        );
2465    }
2466
2467    #[test]
2468    fn a_stdlib_from_import_is_not_a_collaborator() {
2469        let found = unmocked("widget", "myproject", "from os import path\n");
2470        assert!(found.is_empty(), "got {found:?}");
2471    }
2472}