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 relative_level(&node) == 0 {
848            if let Some(module) = &node.module {
849                self.declare_module(module.as_str());
850                for alias in &node.names {
851                    let bound = alias.asname.as_ref().unwrap_or(&alias.name);
852                    self.imports
853                        .insert(bound.to_string(), format!("{module}.{}", alias.name));
854                }
855            }
856        }
857        self.generic_visit_stmt_import_from(node);
858    }
859
860    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
861    // context expression is never walked unless we descend into it here.
862    fn visit_withitem(&mut self, node: WithItem) {
863        self.visit_expr(node.context_expr);
864        if let Some(optional_vars) = node.optional_vars {
865            self.visit_expr(*optional_vars);
866        }
867    }
868
869    fn visit_stmt_assign(&mut self, node: StmtAssign) {
870        if node.targets.iter().any(is_os_environ_subscript) {
871            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
872        }
873        self.generic_visit_stmt_assign(node);
874    }
875
876    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
877        if is_os_environ_subscript(node.target.as_ref()) {
878            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
879        }
880        self.generic_visit_stmt_aug_assign(node);
881    }
882
883    fn visit_stmt_delete(&mut self, node: StmtDelete) {
884        if node.targets.iter().any(is_os_environ_subscript) {
885            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
886        }
887        self.generic_visit_stmt_delete(node);
888    }
889}
890
891/// `true` when a `*args` / `**kwargs` arg is named `name`.
892fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
893    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
894}
895
896/// `true` for an `@pytest.fixture` / `@fixture` decorator, called or bare.
897fn is_fixture_decorator(decorator: &Expr) -> bool {
898    let target = match decorator {
899        Expr::Call(call) => call.func.as_ref(),
900        other => other,
901    };
902    match target {
903        Expr::Name(name) => name.id.as_str() == "fixture",
904        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
905        _ => false,
906    }
907}
908
909/// The three call shapes of `unittest.mock.patch`, which name their target differently.
910enum PatchForm {
911    /// `patch("pkg.mod.attr")` — the target is the string-literal first argument.
912    Target,
913    /// `patch.object(base, "attr")` — the target is `base`'s module plus the attribute.
914    Object,
915    /// `patch.dict(base_or_string, ...)` — the target is the dict itself.
916    Dict,
917}
918
919/// The form of a `patch(...)` / `patch.object(...)` / `patch.dict(...)` call, plain or
920/// reached through a module (`mock.patch(...)`, `unittest.mock.patch`). `None` otherwise.
921fn patch_form(call: &ExprCall) -> Option<PatchForm> {
922    match call.func.as_ref() {
923        Expr::Name(name) if name.id.as_str() == "patch" => Some(PatchForm::Target),
924        Expr::Attribute(attr) => match attr.attr.as_str() {
925            "patch" => Some(PatchForm::Target),
926            "object" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Object),
927            "dict" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Dict),
928            _ => None,
929        },
930        _ => None,
931    }
932}
933
934/// `true` for any [`PatchForm`] call.
935fn is_patch_call(call: &ExprCall) -> bool {
936    patch_form(call).is_some()
937}
938
939/// `true` when an attribute's base resolves to `patch` — a `patch.object` receiver.
940fn attr_base_is_patch(expr: &Expr) -> bool {
941    match expr {
942        Expr::Name(name) => name.id.as_str() == "patch",
943        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
944        _ => false,
945    }
946}
947
948const 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)";
949
950const 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";
951
952/// The string-literal first argument of a `patch(...)` call, the dotted target. `None` for
953/// a non-literal argument, which can't be classified deterministically.
954fn patch_string_target(call: &ExprCall) -> Option<&str> {
955    string_arg(call, 0)
956}
957
958/// The string literal at argument position `index` of a call, if that is what sits there.
959fn string_arg(call: &ExprCall, index: usize) -> Option<&str> {
960    if let Some(Expr::Constant(constant)) = call.args.get(index) {
961        if let Constant::Str(value) = &constant.value {
962            return Some(value.as_str());
963        }
964    }
965    None
966}
967
968/// The dotted segments of a plain attribute chain (`myproject.ledger` → `["myproject",
969/// "ledger"]`). `None` for a chain rooted in anything but a name.
970fn attr_chain_segments(expr: &Expr) -> Option<Vec<&str>> {
971    match expr {
972        Expr::Name(name) => Some(vec![name.id.as_str()]),
973        Expr::Attribute(attr) => {
974            let mut segments = attr_chain_segments(attr.value.as_ref())?;
975            segments.push(attr.attr.as_str());
976            Some(segments)
977        }
978        _ => None,
979    }
980}
981
982/// What object-target resolution reads: the test file's bindings plus the tree's manifest.
983struct ResolveCtx<'a> {
984    /// Local name → the dotted module path its import binds.
985    imports: &'a HashMap<String, String>,
986    /// Every dotted prefix of an imported module path, each a module by construction.
987    declared_modules: &'a HashSet<String>,
988    first_party: Option<&'a str>,
989    source_root: Option<&'a Path>,
990}
991
992/// The dotted path an object target names, classified by what the chain ends at: the head is
993/// the module its import binds, and each trailing segment on a first-party path is read from
994/// that module's own top-level source. `None` when any step resists static resolution.
995fn resolve_object_target(expr: &Expr, ctx: &ResolveCtx) -> Option<String> {
996    let segments = attr_chain_segments(expr)?;
997    let (head, rest) = segments.split_first()?;
998    let mut target = ctx.imports.get(*head)?.clone();
999    for (index, segment) in rest.iter().enumerate() {
1000        let candidate = format!("{target}.{segment}");
1001        if ctx.declared_modules.contains(&candidate) {
1002            target = candidate;
1003            continue;
1004        }
1005        let first_party_root = ctx
1006            .first_party
1007            .zip(ctx.source_root)
1008            .filter(|(pkg, _)| target.split('.').next() == Some(*pkg));
1009        let Some((_, root)) = first_party_root else {
1010            return Some(append_segments(target, &rest[index..]));
1011        };
1012        target = match module_attribute(root, &target, segment)? {
1013            Attr::Module(absolute) => absolute,
1014            Attr::Defined if index == rest.len() - 1 => candidate,
1015            Attr::Defined => return None,
1016        };
1017    }
1018    Some(target)
1019}
1020
1021fn append_segments(mut target: String, segments: &[&str]) -> String {
1022    for segment in segments {
1023        target.push('.');
1024        target.push_str(segment);
1025    }
1026    target
1027}
1028
1029/// What a module attribute resolves to, read from the module's own top-level source.
1030enum Attr {
1031    /// The attribute names another module, by import binding or by submodule file.
1032    Module(String),
1033    /// The module defines the attribute itself: a `def`, `class`, or literal assignment.
1034    Defined,
1035}
1036
1037/// Classify `module`'s attribute `name`. `None` when the source leaves it unnamed: a missing
1038/// or unparsable file, a dynamic or conflicting binding, or a star import over an unbound name.
1039fn module_attribute(root: &Path, module: &str, name: &str) -> Option<Attr> {
1040    let file = locate_module(root, module)?;
1041    let source = std::fs::read_to_string(&file.path).ok()?;
1042    let scope = module_scope(&source, &file.package)?;
1043    match scope.bindings.get(name) {
1044        Some(Binding::Module(absolute)) => Some(Attr::Module(absolute.clone())),
1045        Some(Binding::Defined) => Some(Attr::Defined),
1046        Some(Binding::Opaque) => None,
1047        None if scope.has_star_import => None,
1048        None => {
1049            let submodule = format!("{module}.{name}");
1050            locate_module(root, &submodule).map(|_| Attr::Module(submodule))
1051        }
1052    }
1053}
1054
1055/// A module's source file plus the package its relative imports resolve against.
1056struct ModuleFile {
1057    path: PathBuf,
1058    package: Vec<String>,
1059}
1060
1061/// The file for dotted `module` under `root`, tried in flat and `src/` layouts.
1062fn locate_module(root: &Path, module: &str) -> Option<ModuleFile> {
1063    let segments: Vec<String> = module.split('.').map(str::to_owned).collect();
1064    let rel: PathBuf = segments.iter().collect();
1065    for base in [root.to_path_buf(), root.join("src")] {
1066        let file = base.join(&rel).with_extension("py");
1067        if file.is_file() {
1068            let package = segments[..segments.len() - 1].to_vec();
1069            return Some(ModuleFile {
1070                path: file,
1071                package,
1072            });
1073        }
1074        let init = base.join(&rel).join("__init__.py");
1075        if init.is_file() {
1076            return Some(ModuleFile {
1077                path: init,
1078                package: segments,
1079            });
1080        }
1081    }
1082    None
1083}
1084
1085/// What a module's own top-level source binds a name to.
1086#[derive(Debug, PartialEq)]
1087enum Binding {
1088    /// An import binds the name to this absolute module path.
1089    Module(String),
1090    /// A `def`, `class`, or literal assignment defines the name in this module.
1091    Defined,
1092    /// A dynamic or conflicting binding, which static resolution declines to classify.
1093    Opaque,
1094}
1095
1096struct ModuleScope {
1097    bindings: HashMap<String, Binding>,
1098    has_star_import: bool,
1099}
1100
1101impl ModuleScope {
1102    fn bind(&mut self, name: String, binding: Binding) {
1103        match self.bindings.entry(name) {
1104            Entry::Occupied(mut entry) => {
1105                if *entry.get() != binding {
1106                    entry.insert(Binding::Opaque);
1107                }
1108            }
1109            Entry::Vacant(entry) => {
1110                entry.insert(binding);
1111            }
1112        }
1113    }
1114
1115    fn bind_import_from(&mut self, node: &StmtImportFrom, package: &[String]) {
1116        let base = match relative_level(node) {
1117            0 => node.module.as_ref().map(|module| module.to_string()),
1118            // A level beyond the package walks above the top-level package, which Python rejects.
1119            level if level <= package.len() => {
1120                let parent = package[..package.len() + 1 - level].join(".");
1121                Some(match &node.module {
1122                    Some(module) => format!("{parent}.{module}"),
1123                    None => parent,
1124                })
1125            }
1126            _ => None,
1127        };
1128        for alias in &node.names {
1129            if alias.name.as_str() == "*" {
1130                self.has_star_import = true;
1131                continue;
1132            }
1133            let bound = alias.asname.as_ref().unwrap_or(&alias.name).to_string();
1134            let binding = match &base {
1135                Some(base) => Binding::Module(format!("{base}.{}", alias.name)),
1136                None => Binding::Opaque,
1137            };
1138            self.bind(bound, binding);
1139        }
1140    }
1141
1142    fn bind_assign_target(&mut self, target: &Expr, literal: bool) {
1143        match target {
1144            Expr::Name(name) => {
1145                let binding = if literal {
1146                    Binding::Defined
1147                } else {
1148                    Binding::Opaque
1149                };
1150                self.bind(name.id.to_string(), binding);
1151            }
1152            Expr::Tuple(tuple) => {
1153                for elt in &tuple.elts {
1154                    self.bind_assign_target(elt, false);
1155                }
1156            }
1157            Expr::List(list) => {
1158                for elt in &list.elts {
1159                    self.bind_assign_target(elt, false);
1160                }
1161            }
1162            _ => {}
1163        }
1164    }
1165}
1166
1167/// The top-level name bindings of a module's source. `package` is the dotted package its
1168/// relative imports resolve against. `None` when the source does not parse.
1169fn module_scope(source: &str, package: &[String]) -> Option<ModuleScope> {
1170    let suite = ast::Suite::parse(source, "module.py").ok()?;
1171    let mut scope = ModuleScope {
1172        bindings: HashMap::new(),
1173        has_star_import: false,
1174    };
1175    for stmt in &suite {
1176        match stmt {
1177            ast::Stmt::Import(node) => {
1178                for alias in &node.names {
1179                    match &alias.asname {
1180                        Some(asname) => {
1181                            scope.bind(asname.to_string(), Binding::Module(alias.name.to_string()));
1182                        }
1183                        None => {
1184                            let head = import_head(alias.name.as_str());
1185                            scope.bind(head.to_string(), Binding::Module(head.to_string()));
1186                        }
1187                    }
1188                }
1189            }
1190            ast::Stmt::ImportFrom(node) => scope.bind_import_from(node, package),
1191            ast::Stmt::FunctionDef(node) => scope.bind(node.name.to_string(), Binding::Defined),
1192            ast::Stmt::AsyncFunctionDef(node) => {
1193                scope.bind(node.name.to_string(), Binding::Defined);
1194            }
1195            ast::Stmt::ClassDef(node) => scope.bind(node.name.to_string(), Binding::Defined),
1196            ast::Stmt::Assign(node) => {
1197                let literal = is_literal(&node.value);
1198                for target in &node.targets {
1199                    scope.bind_assign_target(target, literal);
1200                }
1201            }
1202            ast::Stmt::AnnAssign(node) => {
1203                if let Some(value) = &node.value {
1204                    scope.bind_assign_target(&node.target, is_literal(value));
1205                }
1206            }
1207            _ => {}
1208        }
1209    }
1210    Some(scope)
1211}
1212
1213/// `true` for an expression built of literals alone — data the module itself defines.
1214fn is_literal(expr: &Expr) -> bool {
1215    match expr {
1216        Expr::Constant(_) => true,
1217        Expr::UnaryOp(op) => is_literal(&op.operand),
1218        Expr::Dict(dict) => {
1219            dict.keys.iter().flatten().all(is_literal) && dict.values.iter().all(is_literal)
1220        }
1221        Expr::List(list) => list.elts.iter().all(is_literal),
1222        Expr::Tuple(tuple) => tuple.elts.iter().all(is_literal),
1223        Expr::Set(set) => set.elts.iter().all(is_literal),
1224        _ => false,
1225    }
1226}
1227
1228/// The dotted target a patch call names, resolved statically: the string literal for
1229/// `patch(...)` (and a string-target `patch.dict`), the resolved first argument for the
1230/// object forms. `None` when the target resists static resolution — nothing fires.
1231fn patch_target(call: &ExprCall, ctx: &ResolveCtx) -> Option<String> {
1232    match patch_form(call)? {
1233        PatchForm::Target => patch_string_target(call).map(str::to_owned),
1234        PatchForm::Dict => patch_string_target(call)
1235            .map(str::to_owned)
1236            .or_else(|| resolve_object_target(call.args.first()?, ctx)),
1237        PatchForm::Object => {
1238            let base = resolve_object_target(call.args.first()?, ctx)?;
1239            Some(match string_arg(call, 1) {
1240                Some(attr) => format!("{base}.{attr}"),
1241                None => base,
1242            })
1243        }
1244    }
1245}
1246
1247/// `true` when a patch target names an UPPER_CASE constant (`"pkg.cfg.CACHE_DIR"`).
1248fn patches_constant(target: &str) -> bool {
1249    target.rsplit('.').next().is_some_and(is_upper_constant)
1250}
1251
1252/// `true` when patch `target`'s head segment names the first-party package `pkg`.
1253fn patches_first_party(target: &str, pkg: &str) -> bool {
1254    target
1255        .split('.')
1256        .next()
1257        .is_some_and(|head| !head.is_empty() && head == pkg)
1258}
1259
1260/// `true` for an ALL-CAPS name: uppercase letters, digits, underscores, one letter minimum.
1261fn is_upper_constant(name: &str) -> bool {
1262    !name.is_empty()
1263        && name
1264            .chars()
1265            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
1266        && name.chars().any(|c| c.is_ascii_uppercase())
1267}
1268
1269const ENVIRON_MUTATION_MSG: &str =
1270    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
1271
1272/// `true` for the expression `os.environ`.
1273fn is_os_environ(expr: &Expr) -> bool {
1274    matches!(
1275        expr,
1276        Expr::Attribute(attr)
1277            if attr.attr.as_str() == "environ"
1278                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
1279    )
1280}
1281
1282/// `true` for `os.environ[...]`, the form used as an assignment or `del` target.
1283fn is_os_environ_subscript(expr: &Expr) -> bool {
1284    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
1285}
1286
1287/// `true` for a mutating method call on `os.environ`, like `os.environ.update(...)`.
1288fn is_environ_mutation_call(call: &ExprCall) -> bool {
1289    matches!(
1290        call.func.as_ref(),
1291        Expr::Attribute(attr)
1292            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
1293    )
1294}
1295
1296/// `true` for a `dict` method that mutates in place.
1297fn is_environ_mutator(method: &str) -> bool {
1298    matches!(
1299        method,
1300        "update" | "pop" | "setdefault" | "clear" | "popitem"
1301    )
1302}
1303
1304/// The 1-based line containing byte `offset` in `source`.
1305fn line_of(source: &str, offset: TextSize) -> usize {
1306    let offset = (u32::from(offset) as usize).min(source.len());
1307    source.as_bytes()[..offset]
1308        .iter()
1309        .filter(|&&byte| byte == b'\n')
1310        .count()
1311        + 1
1312}
1313
1314/// The dist's own top-level import package: the nearest `pyproject.toml`'s `[project].name`,
1315/// [normalized](normalize_dist_name). The walk up stops at a `.git` boundary so it can't
1316/// escape into an unrelated project, and `None` means nothing is flagged rather than guessed.
1317fn first_party_package(root: &Path) -> Option<String> {
1318    first_party_manifest(root).map(|(name, _)| name)
1319}
1320
1321/// [`first_party_package`] plus the directory holding the manifest, where module sources live.
1322fn first_party_manifest(root: &Path) -> Option<(String, PathBuf)> {
1323    for dir in root.ancestors() {
1324        let candidate = dir.join("pyproject.toml");
1325        if candidate.is_file() {
1326            return read_project_name(&candidate)
1327                .map(|name| (normalize_dist_name(&name), dir.to_path_buf()));
1328        }
1329        if dir.join(".git").exists() {
1330            break;
1331        }
1332    }
1333    None
1334}
1335
1336/// `[project].name` from a `pyproject.toml`, if present and a string.
1337fn read_project_name(path: &Path) -> Option<String> {
1338    let contents = std::fs::read_to_string(path).ok()?;
1339    let value: toml::Value = toml::from_str(&contents).ok()?;
1340    value
1341        .get("project")?
1342        .get("name")?
1343        .as_str()
1344        .map(str::to_owned)
1345}
1346
1347/// A distribution name as its import package name, PEP 503-flavoured: `My-Project` →
1348/// `my_project`.
1349fn normalize_dist_name(name: &str) -> String {
1350    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1351}
1352
1353fn collect_python_files(
1354    dir: &Path,
1355    out: &mut Vec<PathBuf>,
1356    is_match: fn(&Path) -> bool,
1357) -> Result<()> {
1358    let entries =
1359        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1360    for entry in entries {
1361        let path = entry
1362            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
1363            .path();
1364        if path.is_dir() {
1365            collect_python_files(&path, out, is_match)?;
1366        } else if is_match(&path) {
1367            out.push(path);
1368        }
1369    }
1370    Ok(())
1371}
1372
1373/// `true` for a file the integration lints scan: `*_test.py` or `conftest.py`. A legacy
1374/// `test_*.py` is ordinary source.
1375fn is_python_test_file(path: &Path) -> bool {
1376    let name = path
1377        .file_name()
1378        .and_then(|n| n.to_str())
1379        .unwrap_or_default();
1380    name == "conftest.py" || name.ends_with("_test.py")
1381}
1382
1383/// `true` for a colocated unit test: `*_test.py`. A legacy `test_*.py` is ordinary source,
1384/// and `conftest.py` holds fixtures rather than a unit.
1385fn is_python_unit_test_file(path: &Path) -> bool {
1386    let name = path
1387        .file_name()
1388        .and_then(|n| n.to_str())
1389        .unwrap_or_default();
1390    name.ends_with("_test.py")
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395    use super::*;
1396    use std::sync::atomic::{AtomicU64, Ordering};
1397
1398    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
1399    struct TempDir(PathBuf);
1400
1401    impl TempDir {
1402        fn new() -> Self {
1403            static COUNTER: AtomicU64 = AtomicU64::new(0);
1404            let dir = std::env::temp_dir().join(format!(
1405                "tc-lint-{}-{}",
1406                std::process::id(),
1407                COUNTER.fetch_add(1, Ordering::Relaxed),
1408            ));
1409            std::fs::create_dir_all(&dir).unwrap();
1410            TempDir(dir)
1411        }
1412
1413        fn write(&self, name: &str, contents: &str) {
1414            let path = self.0.join(name);
1415            if let Some(parent) = path.parent() {
1416                std::fs::create_dir_all(parent).unwrap();
1417            }
1418            std::fs::write(path, contents).unwrap();
1419        }
1420    }
1421
1422    impl Drop for TempDir {
1423        fn drop(&mut self) {
1424            let _ = std::fs::remove_dir_all(&self.0);
1425        }
1426    }
1427
1428    #[test]
1429    fn normalize_dist_name_maps_to_import_name() {
1430        assert_eq!(normalize_dist_name("My-Project"), "my_project");
1431        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1432        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
1433        assert_eq!(normalize_dist_name("myproject"), "myproject");
1434    }
1435
1436    /// Parse `src` (a single expression statement) and return its call.
1437    fn parse_call(src: &str) -> ExprCall {
1438        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1439        let stmt = suite.into_iter().next().expect("one statement");
1440        (*stmt.expect_expr_stmt().value).expect_call_expr()
1441    }
1442
1443    /// A ctx with no manifest, where trailing segments append without classification.
1444    fn naive_ctx<'a>(
1445        imports: &'a HashMap<String, String>,
1446        declared: &'a HashSet<String>,
1447    ) -> ResolveCtx<'a> {
1448        ResolveCtx {
1449            imports,
1450            declared_modules: declared,
1451            first_party: None,
1452            source_root: None,
1453        }
1454    }
1455
1456    #[test]
1457    fn patch_target_only_reads_string_literals_for_the_string_form() {
1458        let imports = HashMap::new();
1459        let declared = HashSet::new();
1460        let ctx = naive_ctx(&imports, &declared);
1461        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1462        assert_eq!(
1463            patch_target(&str_call, &ctx).as_deref(),
1464            Some("pkg.mod.attr")
1465        );
1466        // A name in `patch(...)` holds a string, which static resolution cannot read.
1467        let name_call = parse_call("patch(target)\n");
1468        assert_eq!(patch_target(&name_call, &ctx), None);
1469        let int_call = parse_call("patch(42)\n");
1470        assert_eq!(patch_target(&int_call, &ctx), None);
1471        let empty_call = parse_call("patch()\n");
1472        assert_eq!(patch_target(&empty_call, &ctx), None);
1473    }
1474
1475    /// An import map binding the names the object-form snippets use.
1476    fn object_form_imports() -> HashMap<String, String> {
1477        HashMap::from([
1478            ("ledger".to_string(), "myproject.ledger".to_string()),
1479            ("myproject".to_string(), "myproject".to_string()),
1480            ("cfg".to_string(), "myproject.cfg".to_string()),
1481        ])
1482    }
1483
1484    #[test]
1485    fn patch_target_resolves_object_forms_through_imports() {
1486        let imports = object_form_imports();
1487        let declared = HashSet::new();
1488        let ctx = naive_ctx(&imports, &declared);
1489        let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1490        assert_eq!(
1491            patch_target(&imported_name, &ctx).as_deref(),
1492            Some("myproject.ledger.record")
1493        );
1494        let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1495        assert_eq!(
1496            patch_target(&dotted_module, &ctx).as_deref(),
1497            Some("myproject.ledger.record")
1498        );
1499        // A non-literal attribute still names the base module, enough for the first-party rule.
1500        let name_attr = parse_call("patch.object(ledger, attr)\n");
1501        assert_eq!(
1502            patch_target(&name_attr, &ctx).as_deref(),
1503            Some("myproject.ledger")
1504        );
1505        let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1506        assert_eq!(
1507            patch_target(&dict_object, &ctx).as_deref(),
1508            Some("myproject.cfg.SETTINGS")
1509        );
1510        let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1511        assert_eq!(
1512            patch_target(&dict_string, &ctx).as_deref(),
1513            Some("pkg.cfg.FLAGS")
1514        );
1515    }
1516
1517    #[test]
1518    fn patch_target_declines_a_base_bound_by_no_import() {
1519        let imports = object_form_imports();
1520        let declared = HashSet::new();
1521        let ctx = naive_ctx(&imports, &declared);
1522        let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1523        assert_eq!(patch_target(&call_base, &ctx), None);
1524        let unbound_name = parse_call("patch.object(client, \"send\")\n");
1525        assert_eq!(patch_target(&unbound_name, &ctx), None);
1526        let empty = parse_call("patch.object()\n");
1527        assert_eq!(patch_target(&empty, &ctx), None);
1528    }
1529
1530    /// A tree holding `pyproject.toml` (`name = "myproject"`) plus one integration test
1531    /// whose fixture patches `patch.object(<base>, "<attr>")`.
1532    fn object_patch_tree(base: &str, attr: &str) -> TempDir {
1533        let tree = TempDir::new();
1534        tree.write(
1535            "pyproject.toml",
1536            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1537        );
1538        tree.write(
1539            "tests/integration/spy_test.py",
1540            &format!(
1541                "from unittest.mock import patch\n\
1542                 import pytest\n\
1543                 from myproject import async_mod\n\
1544                 @pytest.fixture\n\
1545                 def spy():\n\
1546                 \x20   with patch.object({base}, \"{attr}\") as s:\n\
1547                 \x20       yield s\n"
1548            ),
1549        );
1550        tree
1551    }
1552
1553    fn first_party_patch_count(tree: &TempDir) -> usize {
1554        find_violations(&tree.0)
1555            .expect("walking a readable tree should succeed")
1556            .iter()
1557            .filter(|v| v.rule == "no-first-party-patch")
1558            .count()
1559    }
1560
1561    #[test]
1562    fn object_form_stdlib_reached_through_first_party_is_not_flagged() {
1563        let tree = object_patch_tree("async_mod.asyncio", "to_thread");
1564        tree.write("myproject/async_mod.py", "import asyncio\n");
1565        assert_eq!(first_party_patch_count(&tree), 0);
1566    }
1567
1568    #[test]
1569    fn object_form_unnamed_module_attribute_declines_to_fire() {
1570        let missing_source = object_patch_tree("async_mod.transport", "send");
1571        assert_eq!(first_party_patch_count(&missing_source), 0);
1572        let dynamic = object_patch_tree("async_mod.transport", "send");
1573        dynamic.write("myproject/async_mod.py", "transport = build()\n");
1574        assert_eq!(first_party_patch_count(&dynamic), 0);
1575    }
1576
1577    #[test]
1578    fn object_form_first_party_module_attribute_still_fires() {
1579        let tree = object_patch_tree("async_mod.helper", "run");
1580        tree.write("myproject/async_mod.py", "from . import helper\n");
1581        assert_eq!(first_party_patch_count(&tree), 1);
1582    }
1583
1584    #[test]
1585    fn object_form_resolves_in_a_src_layout() {
1586        let tree = object_patch_tree("async_mod.helper", "run");
1587        tree.write("src/myproject/async_mod.py", "from . import helper\n");
1588        assert_eq!(first_party_patch_count(&tree), 1);
1589    }
1590
1591    #[test]
1592    fn object_form_star_import_over_an_unbound_name_declines() {
1593        let tree = object_patch_tree("async_mod.walk", "call");
1594        tree.write("myproject/async_mod.py", "from os import *\n");
1595        assert_eq!(first_party_patch_count(&tree), 0);
1596    }
1597
1598    #[test]
1599    fn object_form_conflicting_binding_declines() {
1600        let tree = object_patch_tree("async_mod.helper", "run");
1601        tree.write(
1602            "myproject/async_mod.py",
1603            "from . import helper\nhelper = None\n",
1604        );
1605        assert_eq!(first_party_patch_count(&tree), 0);
1606    }
1607
1608    #[test]
1609    fn object_form_defined_name_mid_chain_declines() {
1610        let tree = object_patch_tree("async_mod.Client.send", "retry");
1611        tree.write("myproject/async_mod.py", "class Client:\n    pass\n");
1612        assert_eq!(first_party_patch_count(&tree), 0);
1613    }
1614
1615    #[test]
1616    fn object_form_package_attribute_resolves_through_init() {
1617        let tree = TempDir::new();
1618        tree.write(
1619            "pyproject.toml",
1620            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1621        );
1622        tree.write("myproject/sub/__init__.py", "from . import leaf\n");
1623        tree.write("myproject/sub/leaf.py", "def run():\n    pass\n");
1624        tree.write(
1625            "tests/integration/spy_test.py",
1626            "from unittest.mock import patch\n\
1627             import pytest\n\
1628             from myproject import sub\n\
1629             @pytest.fixture\n\
1630             def spy():\n\
1631             \x20   with patch.object(sub.leaf, \"run\") as s:\n\
1632             \x20       yield s\n",
1633        );
1634        assert_eq!(first_party_patch_count(&tree), 1);
1635    }
1636
1637    #[test]
1638    fn object_form_unbound_name_falls_back_to_the_submodule_file() {
1639        let tree = TempDir::new();
1640        tree.write(
1641            "pyproject.toml",
1642            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1643        );
1644        tree.write("myproject/sub/__init__.py", "");
1645        tree.write("myproject/sub/leaf.py", "def run():\n    pass\n");
1646        tree.write(
1647            "tests/integration/spy_test.py",
1648            "from unittest.mock import patch\n\
1649             import pytest\n\
1650             from myproject import sub\n\
1651             @pytest.fixture\n\
1652             def spy():\n\
1653             \x20   with patch.object(sub.leaf, \"run\") as s:\n\
1654             \x20       yield s\n",
1655        );
1656        assert_eq!(first_party_patch_count(&tree), 1);
1657    }
1658
1659    #[test]
1660    fn resolve_appends_naively_past_a_third_party_head() {
1661        let tree = TempDir::new();
1662        let imports = HashMap::from([("requests".to_string(), "requests".to_string())]);
1663        let declared = HashSet::new();
1664        let ctx = ResolveCtx {
1665            imports: &imports,
1666            declared_modules: &declared,
1667            first_party: Some("myproject"),
1668            source_root: Some(&tree.0),
1669        };
1670        let call = parse_call("patch.object(requests.utils, \"default_headers\")\n");
1671        assert_eq!(
1672            patch_target(&call, &ctx).as_deref(),
1673            Some("requests.utils.default_headers")
1674        );
1675    }
1676
1677    #[test]
1678    fn locate_module_tries_flat_and_src_layouts() {
1679        let tree = TempDir::new();
1680        tree.write("myproject/flat.py", "");
1681        tree.write("myproject/pkg/__init__.py", "");
1682        tree.write("src/myproject/nested.py", "");
1683        let flat = locate_module(&tree.0, "myproject.flat").expect("flat module");
1684        assert_eq!(flat.path, tree.0.join("myproject/flat.py"));
1685        assert_eq!(flat.package, vec!["myproject".to_string()]);
1686        let pkg = locate_module(&tree.0, "myproject.pkg").expect("package module");
1687        assert_eq!(pkg.path, tree.0.join("myproject/pkg/__init__.py"));
1688        assert_eq!(
1689            pkg.package,
1690            vec!["myproject".to_string(), "pkg".to_string()]
1691        );
1692        let nested = locate_module(&tree.0, "myproject.nested").expect("src module");
1693        assert_eq!(nested.path, tree.0.join("src/myproject/nested.py"));
1694        assert!(locate_module(&tree.0, "myproject.absent").is_none());
1695    }
1696
1697    fn scope_of(source: &str) -> ModuleScope {
1698        module_scope(source, &["myproject".to_string()]).expect("source should parse")
1699    }
1700
1701    #[test]
1702    fn module_scope_classifies_import_bindings() {
1703        let scope = scope_of(
1704            "import asyncio\n\
1705             import myproject.util as util\n\
1706             from . import helper\n\
1707             from .sub import leaf\n\
1708             from myproject.vendor import client as vc\n",
1709        );
1710        assert_eq!(
1711            scope.bindings.get("asyncio"),
1712            Some(&Binding::Module("asyncio".to_string()))
1713        );
1714        assert_eq!(
1715            scope.bindings.get("util"),
1716            Some(&Binding::Module("myproject.util".to_string()))
1717        );
1718        assert_eq!(
1719            scope.bindings.get("helper"),
1720            Some(&Binding::Module("myproject.helper".to_string()))
1721        );
1722        assert_eq!(
1723            scope.bindings.get("leaf"),
1724            Some(&Binding::Module("myproject.sub.leaf".to_string()))
1725        );
1726        assert_eq!(
1727            scope.bindings.get("vc"),
1728            Some(&Binding::Module("myproject.vendor.client".to_string()))
1729        );
1730    }
1731
1732    #[test]
1733    fn module_scope_classifies_definitions_and_assignments() {
1734        let scope = scope_of(
1735            "def run():\n    pass\n\
1736             async def poll():\n    pass\n\
1737             class Client:\n    pass\n\
1738             LIMITS = [1, 2]\n\
1739             OFFSET = -1\n\
1740             registry = {\"on\": True}\n\
1741             PAIR: tuple = (1, 2)\n\
1742             transport = build()\n\
1743             alias = registry\n\
1744             a, b = make()\n",
1745        );
1746        for name in [
1747            "run", "poll", "Client", "LIMITS", "OFFSET", "registry", "PAIR",
1748        ] {
1749            assert_eq!(scope.bindings.get(name), Some(&Binding::Defined), "{name}");
1750        }
1751        for name in ["transport", "alias", "a", "b"] {
1752            assert_eq!(scope.bindings.get(name), Some(&Binding::Opaque), "{name}");
1753        }
1754    }
1755
1756    #[test]
1757    fn module_scope_marks_conflicts_opaque_and_dedupes_repeats() {
1758        let scope = scope_of("import asyncio\nimport asyncio\nfrom . import helper\nhelper = 1\n");
1759        assert_eq!(
1760            scope.bindings.get("asyncio"),
1761            Some(&Binding::Module("asyncio".to_string()))
1762        );
1763        assert_eq!(scope.bindings.get("helper"), Some(&Binding::Opaque));
1764    }
1765
1766    #[test]
1767    fn module_scope_flags_star_imports_and_rejects_deep_relatives() {
1768        let scope = scope_of("from os import *\nfrom .. import escape\n");
1769        assert!(scope.has_star_import);
1770        assert_eq!(scope.bindings.get("escape"), Some(&Binding::Opaque));
1771    }
1772
1773    #[test]
1774    fn module_scope_rejects_unparsable_source() {
1775        assert!(module_scope("def (\n", &[]).is_none());
1776    }
1777
1778    /// The imports and declared modules a [`LintVisitor`] records for `src`.
1779    fn collect_bindings(src: &str) -> (HashMap<String, String>, HashSet<String>) {
1780        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1781        let mut visitor = LintVisitor {
1782            file: Path::new("t.py"),
1783            source: src,
1784            fixture_depth: 0,
1785            first_party: None,
1786            source_root: None,
1787            imports: HashMap::new(),
1788            declared_modules: HashSet::new(),
1789            violations: Vec::new(),
1790        };
1791        for stmt in suite {
1792            visitor.visit_stmt(stmt);
1793        }
1794        (visitor.imports, visitor.declared_modules)
1795    }
1796
1797    fn collect_imports(src: &str) -> HashMap<String, String> {
1798        collect_bindings(src).0
1799    }
1800
1801    #[test]
1802    fn lint_visitor_binds_imports_to_their_modules() {
1803        let imports = collect_imports(
1804            "import myproject.ledger\n\
1805             import myproject.config as cfg\n\
1806             from myproject import ledger\n\
1807             from myproject import charge as ch\n\
1808             from . import rel\n",
1809        );
1810        assert_eq!(
1811            imports.get("myproject").map(String::as_str),
1812            Some("myproject")
1813        );
1814        assert_eq!(
1815            imports.get("cfg").map(String::as_str),
1816            Some("myproject.config")
1817        );
1818        assert_eq!(
1819            imports.get("ledger").map(String::as_str),
1820            Some("myproject.ledger")
1821        );
1822        assert_eq!(
1823            imports.get("ch").map(String::as_str),
1824            Some("myproject.charge")
1825        );
1826        assert_eq!(imports.get("rel"), None);
1827    }
1828
1829    #[test]
1830    fn lint_visitor_declares_every_import_prefix() {
1831        let (_, declared) = collect_bindings(
1832            "import myproject.sub.ledger\n\
1833             from myproject.api import charge\n\
1834             from . import rel\n",
1835        );
1836        for module in [
1837            "myproject",
1838            "myproject.sub",
1839            "myproject.sub.ledger",
1840            "myproject.api",
1841        ] {
1842            assert!(declared.contains(module), "{module}");
1843        }
1844        assert!(!declared.contains("myproject.api.charge"));
1845        assert!(!declared.contains("rel"));
1846    }
1847
1848    /// Build a `from <source> import <symbols>` record (`source: None` → relative).
1849    fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1850        ImportRecord {
1851            display: source.unwrap_or(".rel").to_string(),
1852            line: 1,
1853            is_uut: false,
1854            symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1855            source: source.map(str::to_string),
1856            module: None,
1857        }
1858    }
1859
1860    fn targets(list: &[&str]) -> Vec<String> {
1861        list.iter().map(|s| (*s).to_string()).collect()
1862    }
1863
1864    #[test]
1865    fn is_mocked_requires_every_symbol_at_the_import_module() {
1866        let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1867        // Only `record` patched → the un-mocked `erase` leaves the import un-mocked.
1868        assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1869        assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1870    }
1871
1872    #[test]
1873    fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1874        let rec = from_import(Some("pkg.ledger"), &["record"]);
1875        // Same last segment, different module → not mocked.
1876        assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1877        let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1878        assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1879        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1880    }
1881
1882    #[test]
1883    fn is_mocked_relative_import_accepts_a_last_segment_match() {
1884        // A relative import has no module to compare, so a last-segment match is accepted.
1885        let rec = from_import(None, &["record"]);
1886        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1887        assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1888    }
1889
1890    #[test]
1891    fn is_mocked_module_import_matches_a_patch_reaching_in() {
1892        let rec = ImportRecord {
1893            display: "pkg.db".to_string(),
1894            line: 1,
1895            is_uut: false,
1896            symbols: Vec::new(),
1897            source: None,
1898            module: Some("pkg.db".to_string()),
1899        };
1900        assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1901        assert!(rec.is_mocked(&targets(&["pkg.db"])));
1902        assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1903        let empty = from_import(Some("pkg.mod"), &[]);
1904        assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1905    }
1906
1907    #[test]
1908    fn patches_first_party_matches_head_segment() {
1909        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1910        assert!(patches_first_party("myproject", "myproject"));
1911        assert!(!patches_first_party("requests.get", "myproject"));
1912        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1913        assert!(!patches_first_party("", "myproject"));
1914        assert!(!patches_first_party(".leading", "myproject"));
1915    }
1916
1917    #[test]
1918    fn first_party_package_reads_pyproject_name() {
1919        let tree = TempDir::new();
1920        tree.write(
1921            "pyproject.toml",
1922            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1923        );
1924        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1925    }
1926
1927    #[test]
1928    fn first_party_package_is_none_without_a_project_name() {
1929        let tree = TempDir::new();
1930        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1931        tree.write(".git", "");
1932        assert_eq!(first_party_package(&tree.0), None);
1933    }
1934
1935    #[test]
1936    fn first_party_package_is_none_when_absent() {
1937        let tree = TempDir::new();
1938        assert_eq!(first_party_package(&tree.0), None);
1939    }
1940
1941    /// The displays of the imports `source` leaves un-mocked.
1942    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1943        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1944        let mut visitor = UnitIsolationVisitor {
1945            source,
1946            first_party,
1947            base,
1948            type_checking_depth: 0,
1949            imports: Vec::new(),
1950            patch_targets: Vec::new(),
1951        };
1952        for stmt in suite {
1953            visitor.visit_stmt(stmt);
1954        }
1955        visitor
1956            .imports
1957            .iter()
1958            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1959            .map(|i| i.display.clone())
1960            .collect()
1961    }
1962
1963    #[test]
1964    fn import_head_and_last_segment() {
1965        assert_eq!(import_head("myproject.db.conn"), "myproject");
1966        assert_eq!(import_head("requests"), "requests");
1967        assert_eq!(last_segment("myproject.db.conn"), "conn");
1968        assert_eq!(last_segment("widget"), "widget");
1969    }
1970
1971    #[test]
1972    fn unit_under_test_base_strips_test_suffix() {
1973        assert_eq!(
1974            unit_under_test_base(Path::new("pkg/widget_test.py")),
1975            "widget"
1976        );
1977        // Only `*_test.py` reaches here, so a legacy `test_*.py` keeps its prefix.
1978        assert_eq!(
1979            unit_under_test_base(Path::new("test_widget.py")),
1980            "test_widget"
1981        );
1982        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1983    }
1984
1985    #[test]
1986    fn recognizes_python_unit_test_files() {
1987        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1988        assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1989        assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1990        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1991        assert!(!is_python_unit_test_file(Path::new("widget.py")));
1992    }
1993
1994    #[test]
1995    fn visitor_flags_first_party_and_external_collaborators() {
1996        // The UUT is left alone; the first-party and third-party imports are flagged.
1997        let found = unmocked(
1998            "widget",
1999            "myproject",
2000            "from myproject.widget import build\n\
2001             from myproject.ledger import record\n\
2002             import requests\n",
2003        );
2004        assert_eq!(
2005            found,
2006            vec!["myproject.ledger".to_string(), "requests".to_string()]
2007        );
2008    }
2009
2010    #[test]
2011    fn visitor_clears_a_mocked_collaborator() {
2012        let found = unmocked(
2013            "widget",
2014            "myproject",
2015            "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
2016        );
2017        assert!(found.is_empty(), "got: {found:?}");
2018    }
2019
2020    #[test]
2021    fn visitor_flags_a_wrong_module_patch() {
2022        // A patch sharing only the last segment names a different module, so `record`
2023        // stays an un-mocked collaborator.
2024        let found = unmocked(
2025            "widget",
2026            "myproject",
2027            "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
2028        );
2029        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2030    }
2031
2032    #[test]
2033    fn visitor_flags_a_partly_mocked_multi_symbol_import() {
2034        // Patching only `record` leaves the sibling `erase` a real collaborator.
2035        let found = unmocked(
2036            "widget",
2037            "myproject",
2038            "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
2039        );
2040        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2041        let both = unmocked(
2042            "widget",
2043            "myproject",
2044            "from myproject.ledger import record, erase\n\
2045             patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
2046        );
2047        assert!(both.is_empty(), "got: {both:?}");
2048    }
2049
2050    #[test]
2051    fn visitor_handles_module_and_relative_imports() {
2052        assert_eq!(
2053            unmocked("widget", "myproject", "import myproject.db\n"),
2054            vec!["myproject.db".to_string()]
2055        );
2056        assert!(unmocked(
2057            "widget",
2058            "myproject",
2059            "import myproject.db\npatch(\"myproject.db.connect\")\n"
2060        )
2061        .is_empty());
2062        assert_eq!(
2063            unmocked("widget", "myproject", "from .ledger import record\n"),
2064            vec![".ledger".to_string()]
2065        );
2066        assert_eq!(
2067            unmocked(
2068                "widget",
2069                "myproject",
2070                "from . import ledger\nfrom . import widget\n"
2071            ),
2072            vec![".ledger".to_string()]
2073        );
2074    }
2075
2076    #[test]
2077    fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
2078        // A bare `from . import …` names the package's own re-export surface, the SUT.
2079        assert!(unmocked(
2080            "__init__",
2081            "myproject",
2082            "from . import Thing, __all__, __version__\n"
2083        )
2084        .is_empty());
2085        // Reaching around the barrel into a sibling module is still a collaborator.
2086        assert_eq!(
2087            unmocked("__init__", "myproject", "from .core import Thing\n"),
2088            vec![".core".to_string()]
2089        );
2090        // `from .. import x` resolves to the parent package, not the SUT file.
2091        assert_eq!(
2092            unmocked("__init__", "myproject", "from .. import sibling\n"),
2093            vec!["..sibling".to_string()]
2094        );
2095        // The barrel shortcut is scoped to the `__init__` base.
2096        assert_eq!(
2097            unmocked("widget", "myproject", "from . import ledger\n"),
2098            vec![".ledger".to_string()]
2099        );
2100    }
2101
2102    #[test]
2103    fn visitor_skips_type_checking_imports() {
2104        // A TYPE_CHECKING import is type-only; the runtime `else` import is still seen.
2105        let found = unmocked(
2106            "widget",
2107            "myproject",
2108            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
2109        );
2110        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2111    }
2112
2113    #[test]
2114    fn is_checked_import_classifies_origins() {
2115        assert!(is_checked_import("myproject", "myproject")); // first-party
2116        assert!(!is_checked_import("pytest", "myproject")); // test framework
2117        assert!(!is_checked_import("_pytest", "myproject"));
2118        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
2119        assert!(is_checked_import("socket", "myproject"));
2120        assert!(!is_checked_import("json", "myproject")); // pure stdlib
2121        assert!(!is_checked_import("dataclasses", "myproject"));
2122        assert!(is_checked_import("requests", "myproject")); // third-party
2123        assert!(is_checked_import("stripe", "myproject"));
2124        // A dual-nature head stays pure — the patch convention catches it, not the import.
2125        assert!(!is_checked_import("os", "myproject"));
2126        assert!(!is_checked_import("pathlib", "myproject"));
2127        assert!(!is_checked_import("datetime", "myproject"));
2128    }
2129
2130    #[test]
2131    fn is_checked_import_classifies_private_stdlib_as_stdlib() {
2132        assert!(!is_checked_import("__future__", "myproject"));
2133        assert!(!is_checked_import("_thread", "myproject"));
2134        assert!(!is_checked_import("_socket", "myproject"));
2135        assert!(!is_checked_import("_ast", "myproject"));
2136        assert!(!is_checked_import("_collections_abc", "myproject"));
2137        assert!(is_checked_import("_stripe", "myproject")); // third-party
2138    }
2139
2140    #[test]
2141    fn visitor_flags_external_collaborators() {
2142        let found = unmocked(
2143            "widget",
2144            "myproject",
2145            "import requests\nimport subprocess\nimport json\nimport pytest\n",
2146        );
2147        assert_eq!(found.len(), 2, "got: {found:?}");
2148        assert!(found.contains(&"requests".to_string()));
2149        assert!(found.contains(&"subprocess".to_string()));
2150    }
2151
2152    #[test]
2153    fn visitor_type_checking_variants_and_plain_if() {
2154        // The attribute form guards type-only imports too.
2155        assert!(unmocked(
2156            "widget",
2157            "myproject",
2158            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
2159        )
2160        .is_empty());
2161        // A plain `if` is walked normally; its import is still a collaborator.
2162        assert_eq!(
2163            unmocked(
2164                "widget",
2165                "myproject",
2166                "if ready == 1:\n    from myproject.ledger import record\n"
2167            ),
2168            vec!["myproject.ledger".to_string()]
2169        );
2170    }
2171
2172    #[test]
2173    fn find_unit_isolation_without_pyproject_reports_nothing() {
2174        let tree = TempDir::new();
2175        tree.write("widget_test.py", "from myproject.ledger import record\n");
2176        tree.write(".git", "");
2177        assert!(find_unit_isolation_violations(&tree.0)
2178            .expect("a readable tree should succeed")
2179            .is_empty());
2180    }
2181
2182    #[test]
2183    fn find_unit_isolation_walks_subdirs_and_flags() {
2184        let tree = TempDir::new();
2185        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2186        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
2187        let found =
2188            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
2189        assert_eq!(found.len(), 1, "got: {found:?}");
2190        assert_eq!(found[0].rule, "unmocked-collaborator");
2191        assert!(found[0].message.contains("myproject.ledger"));
2192    }
2193
2194    #[test]
2195    fn recognizes_python_test_files() {
2196        assert!(is_python_test_file(Path::new("widget_test.py")));
2197        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
2198        assert!(is_python_test_file(Path::new("conftest.py")));
2199        assert!(!is_python_test_file(Path::new("test_widget.py")));
2200    }
2201
2202    #[test]
2203    fn ignores_non_test_files() {
2204        assert!(!is_python_test_file(Path::new("widget.py")));
2205        assert!(!is_python_test_file(Path::new("conftest.pyi")));
2206        assert!(!is_python_test_file(Path::new("README.md")));
2207        assert!(!is_python_test_file(Path::new("testing.py")));
2208    }
2209
2210    #[test]
2211    fn line_of_counts_newlines() {
2212        let src = "a\nb\nc\n";
2213        assert_eq!(line_of(src, TextSize::from(0)), 1);
2214        assert_eq!(line_of(src, TextSize::from(2)), 2);
2215        assert_eq!(line_of(src, TextSize::from(4)), 3);
2216    }
2217
2218    #[test]
2219    fn recognizes_environ_mutators() {
2220        assert!(is_environ_mutator("update"));
2221        assert!(is_environ_mutator("pop"));
2222        assert!(is_environ_mutator("clear"));
2223        assert!(!is_environ_mutator("get"));
2224        assert!(!is_environ_mutator("keys"));
2225    }
2226
2227    /// The rules the suite lint reports for `source`, in report order.
2228    fn lint_rules(source: &str) -> Vec<&'static str> {
2229        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
2230        let mut visitor = LintVisitor {
2231            file: Path::new("t.py"),
2232            source,
2233            fixture_depth: 0,
2234            first_party: Some("myproject"),
2235            source_root: None,
2236            imports: HashMap::new(),
2237            declared_modules: HashSet::new(),
2238            violations: Vec::new(),
2239        };
2240        for stmt in suite {
2241            visitor.visit_stmt(stmt);
2242        }
2243        visitor.violations.iter().map(|v| v.rule).collect()
2244    }
2245
2246    #[test]
2247    fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
2248        assert!(
2249            lint_rules("@pytest.fixture\nasync def client():\n    patch(\"pkg.mod.attr\")\n")
2250                .is_empty()
2251        );
2252        assert_eq!(
2253            lint_rules("async def widget_test():\n    patch(\"pkg.mod.attr\")\n"),
2254            vec!["no-inline-patch"]
2255        );
2256        assert_eq!(
2257            lint_rules("async def widget_test(monkeypatch):\n    pass\n"),
2258            vec!["no-monkeypatch"]
2259        );
2260    }
2261
2262    #[test]
2263    fn an_augmented_assignment_to_environ_is_a_mutation() {
2264        assert_eq!(
2265            lint_rules("def widget_test():\n    os.environ[\"PATH\"] += \":/x\"\n"),
2266            vec!["no-environ-mutation"]
2267        );
2268        assert!(lint_rules("def widget_test():\n    total += 1\n").is_empty());
2269    }
2270
2271    #[test]
2272    fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
2273        assert!(
2274            lint_rules("@fixture\ndef client():\n    patch(\"pkg.mod.attr\")\n").is_empty(),
2275            "a bare `@fixture` shelters the patch"
2276        );
2277        assert_eq!(
2278            lint_rules("@registry[\"fixture\"]\ndef client():\n    patch(\"pkg.mod.attr\")\n"),
2279            vec!["no-inline-patch"],
2280            "a subscripted decorator is not a fixture"
2281        );
2282    }
2283
2284    #[test]
2285    fn patch_object_is_recognized_only_through_a_patch_receiver() {
2286        assert_eq!(
2287            lint_rules("def widget_test():\n    mock.patch.object(svc, \"send\")\n"),
2288            vec!["no-inline-patch"]
2289        );
2290        assert!(
2291            lint_rules("def widget_test():\n    helpers[0].object(svc, \"send\")\n").is_empty(),
2292            "a subscripted receiver is not `patch`"
2293        );
2294        assert!(
2295            lint_rules("def widget_test():\n    helpers[0](\"pkg.mod.attr\")\n").is_empty(),
2296            "a subscripted callee is not a patch call"
2297        );
2298    }
2299
2300    #[test]
2301    fn find_suite_without_a_tests_directory_reports_nothing() {
2302        let tree = TempDir::new();
2303        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2304        assert!(find_suite_violations(&tree.0)
2305            .expect("a readable tree should succeed")
2306            .is_empty());
2307    }
2308
2309    #[test]
2310    fn find_unit_isolation_without_a_project_name_reports_nothing() {
2311        let tree = TempDir::new();
2312        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
2313        tree.write("widget_test.py", "from myproject.ledger import record\n");
2314        assert!(find_unit_isolation_violations(&tree.0)
2315            .expect("a readable tree should succeed")
2316            .is_empty());
2317    }
2318
2319    #[test]
2320    fn recognizes_upper_constants() {
2321        assert!(is_upper_constant("CACHE_DIR"));
2322        assert!(is_upper_constant("DEBUG"));
2323        assert!(is_upper_constant("MAX_2"));
2324        assert!(!is_upper_constant("cache_dir"));
2325        assert!(!is_upper_constant("CacheDir"));
2326        assert!(!is_upper_constant("fetch"));
2327        assert!(!is_upper_constant(""));
2328        assert!(!is_upper_constant("_"));
2329        assert!(!is_upper_constant("123"));
2330    }
2331}