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