Skip to main content

testing_conventions/
lint.rs

1//! The Python mocking mechanism and style lints behind `integration lint`, plus the Python
2//! arm of `unit lint`. Each test file is parsed with `rustpython_parser` and walked with a
3//! [`Visitor`]; the rules themselves are documented under `docs/reference/checks/`.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, Context, Result};
9use rustpython_ast::Visitor;
10use rustpython_parser::ast::{
11    self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
12    StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
13};
14use rustpython_parser::text_size::{TextRange, TextSize};
15use rustpython_parser::Parse;
16
17// Re-exported so `testing_conventions::lint::Violation` still resolves.
18pub use crate::violation::Violation;
19
20/// Every lint violation in the Python test files under `root`, sorted by `(file, line)`. A
21/// *Python test file* is `*_test.py` or `conftest.py`, where fixtures live; a legacy
22/// `test_*.py` is ordinary source. A file that cannot be read or parsed is an error.
23pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
24    let root = root.as_ref();
25    // Resolved once for the whole tree; `None` means `no-first-party-patch` flags nothing.
26    let first_party = first_party_package(root);
27    let mut files = Vec::new();
28    collect_python_files(root, &mut files, is_python_test_file)?;
29    files.sort();
30
31    let mut violations = Vec::new();
32    for file in &files {
33        let source = std::fs::read_to_string(file)
34            .with_context(|| format!("reading test file `{}`", file.display()))?;
35        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
36            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
37        let mut visitor = LintVisitor {
38            file,
39            source: &source,
40            fixture_depth: 0,
41            first_party: first_party.as_deref(),
42            imports: HashMap::new(),
43            violations: Vec::new(),
44        };
45        for stmt in suite {
46            visitor.visit_stmt(stmt);
47        }
48        violations.append(&mut visitor.violations);
49    }
50
51    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
52    Ok(violations)
53}
54
55const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
56     a suite lives in `tests/integration/` or `tests/e2e/`";
57
58/// Every lint violation in `package_root`'s suite tiers, sorted by `(file, line)`.
59/// `tests/integration/` and `tests/e2e/` both run first-party code for real; a `*_test.py`
60/// under `tests/` outside them is `unknown-tier` rather than silently unscanned.
61pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
62    let tests = package_root.join("tests");
63    let mut violations = Vec::new();
64    let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
65    for tier in &tiers {
66        if tier.is_dir() {
67            violations.extend(find_violations(tier)?);
68        }
69    }
70    if tests.is_dir() {
71        let mut strays = Vec::new();
72        collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
73        strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
74        for file in strays {
75            violations.push(Violation {
76                file,
77                line: 1,
78                rule: "unknown-tier",
79                message: UNKNOWN_TIER_MSG.to_string(),
80            });
81        }
82    }
83    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
84    Ok(violations)
85}
86
87/// Every `unmocked-collaborator` violation under `root` — a collaborator a `*_test.py`
88/// imports without mocking it — sorted by `(file, line)`. First-party is the dist's own
89/// package ([`first_party_package`]); a tree that declares none reports nothing.
90pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
91    let root = root.as_ref();
92    let Some(first_party) = first_party_package(root) else {
93        return Ok(Vec::new());
94    };
95    let mut files = Vec::new();
96    collect_python_files(root, &mut files, is_python_unit_test_file)?;
97    // The suite tiers run first-party code for real, so their files are never unit subjects.
98    if let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") {
99        files.retain(|file| !file.starts_with(&tests));
100    }
101    files.sort();
102
103    let mut violations = Vec::new();
104    for file in &files {
105        let source = std::fs::read_to_string(file)
106            .with_context(|| format!("reading test file `{}`", file.display()))?;
107        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
108            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
109        let base = unit_under_test_base(file);
110        let mut visitor = UnitIsolationVisitor {
111            source: &source,
112            first_party: &first_party,
113            base: &base,
114            type_checking_depth: 0,
115            imports: Vec::new(),
116            patch_targets: Vec::new(),
117        };
118        for stmt in suite {
119            visitor.visit_stmt(stmt);
120        }
121        for import in &visitor.imports {
122            if import.is_uut || import.is_mocked(&visitor.patch_targets) {
123                continue;
124            }
125            violations.push(Violation {
126                file: file.to_path_buf(),
127                line: import.line,
128                rule: "unmocked-collaborator",
129                message: format!(
130                    "unit test imports `{}` without mocking it — a unit test isolates the \
131                     unit under test, so mock every collaborator (patch it by string in a \
132                     fixture)",
133                    import.display
134                ),
135            });
136        }
137    }
138
139    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
140    Ok(violations)
141}
142
143/// One import seen in a unit test, with what it takes to decide whether it is mocked.
144struct ImportRecord {
145    /// The module path to name in the message (`myproject.ledger`, `.ledger`).
146    display: String,
147    line: usize,
148    is_uut: bool,
149    /// For `from X import a, b` — the bound symbols, each of which must be mocked.
150    symbols: Vec<String>,
151    /// For an **absolute** `from X import a, b` — the source module `X`, which a mocking
152    /// patch must name. `None` for a relative `from`-import, which has no module to compare.
153    source: Option<String>,
154    /// For `import X.Y` — the module path (a patch reaching into it counts as a mock).
155    module: Option<String>,
156}
157
158impl ImportRecord {
159    /// `true` when some `patch("…")` target mocks this import: a plain `import X.Y` by any
160    /// patch reaching into `X.Y`, a `from X import a, b` only when **every** bound symbol
161    /// is patched at `X` itself.
162    fn is_mocked(&self, patch_targets: &[String]) -> bool {
163        if let Some(module) = &self.module {
164            let prefix = format!("{module}.");
165            return patch_targets
166                .iter()
167                .any(|target| target == module || target.starts_with(&prefix));
168        }
169        if self.symbols.is_empty() {
170            return false;
171        }
172        self.symbols.iter().all(|symbol| {
173            patch_targets
174                .iter()
175                .any(|target| self.symbol_is_mocked(target, symbol))
176        })
177    }
178
179    /// `true` when `target`'s last dotted segment is `symbol` and — for an absolute import
180    /// — its module path is the import's own [`source`](Self::source).
181    fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
182        let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
183            return false;
184        };
185        match &self.source {
186            Some(source) => module == source,
187            None => true,
188        }
189    }
190}
191
192/// Walks one unit test, collecting its imports and every `patch("…")` string target so
193/// [`find_unit_isolation_violations`] can pair them. An `if TYPE_CHECKING:` import is erased
194/// at runtime and skipped.
195struct UnitIsolationVisitor<'a> {
196    source: &'a str,
197    first_party: &'a str,
198    base: &'a str,
199    type_checking_depth: usize,
200    imports: Vec<ImportRecord>,
201    patch_targets: Vec<String>,
202}
203
204impl Visitor for UnitIsolationVisitor<'_> {
205    fn visit_stmt_import(&mut self, node: StmtImport) {
206        if self.type_checking_depth == 0 {
207            let line = line_of(self.source, node.range.start());
208            for alias in &node.names {
209                let module = alias.name.as_str();
210                if is_checked_import(import_head(module), self.first_party) {
211                    self.imports.push(ImportRecord {
212                        display: module.to_string(),
213                        line,
214                        is_uut: last_segment(module) == self.base,
215                        symbols: Vec::new(),
216                        source: None,
217                        module: Some(module.to_string()),
218                    });
219                }
220            }
221        }
222        self.generic_visit_stmt_import(node);
223    }
224
225    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
226        if self.type_checking_depth == 0 {
227            let level = relative_level(&node);
228            let module = node.module.as_ref().map(|m| m.as_str());
229            // A relative import is first-party; an absolute one is judged by its head.
230            let should_check = level > 0
231                || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
232            if should_check {
233                let line = line_of(self.source, node.range.start());
234                let dots = ".".repeat(level);
235                match module {
236                    // `from <module> import a, b` — the bound symbols are the collaborators.
237                    Some(module) => self.imports.push(ImportRecord {
238                        display: format!("{dots}{module}"),
239                        line,
240                        is_uut: last_segment(module) == self.base,
241                        symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
242                        source: (level == 0).then(|| module.to_string()),
243                        module: None,
244                    }),
245                    // `from . import sub` — each name is a submodule.
246                    None => {
247                        // In `__init___test.py` a bare `from . import …` names the
248                        // package's own re-export surface — the unit under test itself.
249                        let barrel_sut = self.base == "__init__" && level == 1;
250                        for alias in &node.names {
251                            let name = alias.name.as_str();
252                            self.imports.push(ImportRecord {
253                                display: format!("{dots}{name}"),
254                                line,
255                                is_uut: barrel_sut || name == self.base,
256                                symbols: vec![name.to_string()],
257                                source: None,
258                                module: None,
259                            });
260                        }
261                    }
262                }
263            }
264        }
265        self.generic_visit_stmt_import_from(node);
266    }
267
268    fn visit_expr_call(&mut self, node: ExprCall) {
269        if is_patch_call(&node) {
270            if let Some(target) = patch_string_target(&node) {
271                self.patch_targets.push(target.to_string());
272            }
273        }
274        self.generic_visit_expr_call(node);
275    }
276
277    fn visit_stmt_if(&mut self, node: StmtIf) {
278        // An `if TYPE_CHECKING:` body is type-only; its runtime `else` is still walked.
279        if is_type_checking(node.test.as_ref()) {
280            self.type_checking_depth += 1;
281            for stmt in node.body {
282                self.visit_stmt(stmt);
283            }
284            self.type_checking_depth -= 1;
285            for stmt in node.orelse {
286                self.visit_stmt(stmt);
287            }
288        } else {
289            self.generic_visit_stmt_if(node);
290        }
291    }
292}
293
294/// The leading dotted segment of a module path (`myproject.db` → `myproject`).
295fn import_head(module: &str) -> &str {
296    module.split('.').next().unwrap_or(module)
297}
298
299/// `true` when an import head names a checked collaborator — the dist package, a third-party
300/// package, or effectful stdlib. The test framework and pure stdlib are not collaborators.
301fn is_checked_import(head: &str, first_party: &str) -> bool {
302    if head == first_party {
303        return true;
304    }
305    if TEST_FRAMEWORK.contains(&head) {
306        return false;
307    }
308    if EFFECTFUL_STDLIB.contains(&head) {
309        return true;
310    }
311    if STDLIB_MODULES.contains(&head) {
312        return false;
313    }
314    true // an unrecognized head is a third-party package
315}
316
317/// The test harness, never a collaborator. `unittest` is stdlib; these are the rest.
318const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
319
320/// Standard-library modules that are **effectful at the head**. Dual-nature heads (`os`,
321/// `pathlib`, `datetime`, `time`, `io`, `logging`, `threading`) are excluded: a pure use
322/// can't be told from an effectful one at the import, so the patch convention catches those.
323const EFFECTFUL_STDLIB: &[&str] = &[
324    "asynchat",
325    "asyncore",
326    "ctypes",
327    "curses",
328    "dbm",
329    "fcntl",
330    "ftplib",
331    "imaplib",
332    "mmap",
333    "msvcrt",
334    "multiprocessing",
335    "nis",
336    "nntplib",
337    "ossaudiodev",
338    "poplib",
339    "pty",
340    "random",
341    "secrets",
342    "select",
343    "selectors",
344    "signal",
345    "smtpd",
346    "smtplib",
347    "socket",
348    "socketserver",
349    "spwd",
350    "sqlite3",
351    "ssl",
352    "subprocess",
353    "syslog",
354    "telnetlib",
355    "termios",
356    "tty",
357    "webbrowser",
358    "winreg",
359    "winsound",
360];
361
362/// Python's `sys.stdlib_module_names`, which tells pure stdlib from a third-party package.
363/// The [`EFFECTFUL_STDLIB`] subset is what is actually flagged.
364const STDLIB_MODULES: &[&str] = &[
365    "abc",
366    "aifc",
367    "antigravity",
368    "argparse",
369    "array",
370    "ast",
371    "asynchat",
372    "asyncio",
373    "asyncore",
374    "atexit",
375    "audioop",
376    "base64",
377    "bdb",
378    "binascii",
379    "bisect",
380    "builtins",
381    "bz2",
382    "cProfile",
383    "calendar",
384    "cgi",
385    "cgitb",
386    "chunk",
387    "cmath",
388    "cmd",
389    "code",
390    "codecs",
391    "codeop",
392    "collections",
393    "colorsys",
394    "compileall",
395    "concurrent",
396    "configparser",
397    "contextlib",
398    "contextvars",
399    "copy",
400    "copyreg",
401    "crypt",
402    "csv",
403    "ctypes",
404    "curses",
405    "dataclasses",
406    "datetime",
407    "dbm",
408    "decimal",
409    "difflib",
410    "dis",
411    "distutils",
412    "doctest",
413    "email",
414    "encodings",
415    "ensurepip",
416    "enum",
417    "errno",
418    "faulthandler",
419    "fcntl",
420    "filecmp",
421    "fileinput",
422    "fnmatch",
423    "fractions",
424    "ftplib",
425    "functools",
426    "gc",
427    "genericpath",
428    "getopt",
429    "getpass",
430    "gettext",
431    "glob",
432    "graphlib",
433    "grp",
434    "gzip",
435    "hashlib",
436    "heapq",
437    "hmac",
438    "html",
439    "http",
440    "idlelib",
441    "imaplib",
442    "imghdr",
443    "imp",
444    "importlib",
445    "inspect",
446    "io",
447    "ipaddress",
448    "itertools",
449    "json",
450    "keyword",
451    "lib2to3",
452    "linecache",
453    "locale",
454    "logging",
455    "lzma",
456    "mailbox",
457    "mailcap",
458    "marshal",
459    "math",
460    "mimetypes",
461    "mmap",
462    "modulefinder",
463    "msilib",
464    "msvcrt",
465    "multiprocessing",
466    "netrc",
467    "nis",
468    "nntplib",
469    "nt",
470    "ntpath",
471    "nturl2path",
472    "numbers",
473    "opcode",
474    "operator",
475    "optparse",
476    "os",
477    "ossaudiodev",
478    "pathlib",
479    "pdb",
480    "pickle",
481    "pickletools",
482    "pipes",
483    "pkgutil",
484    "platform",
485    "plistlib",
486    "poplib",
487    "posix",
488    "posixpath",
489    "pprint",
490    "profile",
491    "pstats",
492    "pty",
493    "pwd",
494    "py_compile",
495    "pyclbr",
496    "pydoc",
497    "pydoc_data",
498    "pyexpat",
499    "queue",
500    "quopri",
501    "random",
502    "re",
503    "readline",
504    "reprlib",
505    "resource",
506    "rlcompleter",
507    "runpy",
508    "sched",
509    "secrets",
510    "select",
511    "selectors",
512    "shelve",
513    "shlex",
514    "shutil",
515    "signal",
516    "site",
517    "smtpd",
518    "smtplib",
519    "sndhdr",
520    "socket",
521    "socketserver",
522    "spwd",
523    "sqlite3",
524    "sre_compile",
525    "sre_constants",
526    "sre_parse",
527    "ssl",
528    "stat",
529    "statistics",
530    "string",
531    "stringprep",
532    "struct",
533    "subprocess",
534    "sunau",
535    "symtable",
536    "sys",
537    "sysconfig",
538    "syslog",
539    "tabnanny",
540    "tarfile",
541    "telnetlib",
542    "tempfile",
543    "termios",
544    "textwrap",
545    "this",
546    "threading",
547    "time",
548    "timeit",
549    "tkinter",
550    "token",
551    "tokenize",
552    "tomllib",
553    "trace",
554    "traceback",
555    "tracemalloc",
556    "tty",
557    "turtle",
558    "turtledemo",
559    "types",
560    "typing",
561    "unicodedata",
562    "unittest",
563    "urllib",
564    "uu",
565    "uuid",
566    "venv",
567    "warnings",
568    "wave",
569    "weakref",
570    "webbrowser",
571    "winreg",
572    "winsound",
573    "wsgiref",
574    "xdrlib",
575    "xml",
576    "xmlrpc",
577    "zipapp",
578    "zipfile",
579    "zipimport",
580    "zlib",
581    "zoneinfo",
582];
583
584/// The trailing dotted segment of a module path (`myproject.db` → `db`).
585fn last_segment(module: &str) -> &str {
586    module.rsplit('.').next().unwrap_or(module)
587}
588
589/// The number of leading dots on a `from`-import: `from ..pkg import x` → 2, absolute → 0.
590fn relative_level(node: &StmtImportFrom) -> usize {
591    node.level.map_or(0, |level| level.to_usize())
592}
593
594/// `true` for `TYPE_CHECKING` / `typing.TYPE_CHECKING`, the guard over type-only imports.
595fn is_type_checking(test: &Expr) -> bool {
596    match test {
597        Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
598        Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
599        _ => false,
600    }
601}
602
603/// The unit-under-test base name for a test file: `widget_test.py` → `widget`. Only
604/// `*_test.py` reaches here, so stripping the `_test` suffix is all it takes.
605fn unit_under_test_base(file: &Path) -> String {
606    let name = file
607        .file_name()
608        .and_then(|n| n.to_str())
609        .unwrap_or_default();
610    let stem = name.strip_suffix(".py").unwrap_or(name);
611    stem.strip_suffix("_test").unwrap_or(stem).to_string()
612}
613
614/// Walks one test file, collecting lint violations. `fixture_depth` tracks `@pytest.fixture`
615/// nesting, so `no-inline-patch` allows a patch there and flags one in a test body.
616struct LintVisitor<'a> {
617    file: &'a Path,
618    source: &'a str,
619    fixture_depth: usize,
620    /// The dist's own top-level package, or `None` when undiscoverable.
621    first_party: Option<&'a str>,
622    /// Local name → the dotted module path its import binds, for object patch targets.
623    imports: HashMap<String, String>,
624    violations: Vec<Violation>,
625}
626
627impl LintVisitor<'_> {
628    fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
629        self.violations.push(Violation {
630            file: self.file.to_path_buf(),
631            line: line_of(self.source, range.start()),
632            rule,
633            message: message.to_string(),
634        });
635    }
636
637    /// Run the parameter lint, and return whether this function is a fixture.
638    fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
639        let takes_monkeypatch = args
640            .posonlyargs
641            .iter()
642            .chain(&args.args)
643            .chain(&args.kwonlyargs)
644            .any(|arg| arg.def.arg.as_str() == "monkeypatch")
645            || arg_named(&args.vararg, "monkeypatch")
646            || arg_named(&args.kwarg, "monkeypatch");
647        if takes_monkeypatch {
648            self.report(
649                range,
650                "no-monkeypatch",
651                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
652            );
653        }
654
655        decorators.iter().any(is_fixture_decorator)
656    }
657}
658
659impl Visitor for LintVisitor<'_> {
660    fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
661        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
662        if is_fixture {
663            self.fixture_depth += 1;
664        }
665        self.generic_visit_stmt_function_def(node);
666        if is_fixture {
667            self.fixture_depth -= 1;
668        }
669    }
670
671    fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
672        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
673        if is_fixture {
674            self.fixture_depth += 1;
675        }
676        self.generic_visit_stmt_async_function_def(node);
677        if is_fixture {
678            self.fixture_depth -= 1;
679        }
680    }
681
682    fn visit_expr_call(&mut self, node: ExprCall) {
683        // A fixture is the right place for a patch; a test body is not.
684        if is_patch_call(&node) && self.fixture_depth == 0 {
685            self.report(
686                node.range,
687                "no-inline-patch",
688                "patch is called inline in a test body; move it into a `pytest.fixture`",
689            );
690        }
691        // Both target rules fire regardless of fixture depth — a config constant is usually
692        // patched in one — and only on a statically resolved target.
693        if let Some(target) = patch_target(&node, &self.imports) {
694            if patches_constant(&target) {
695                self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
696            }
697            if let Some(pkg) = self.first_party {
698                if patches_first_party(&target, pkg) {
699                    self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
700                }
701            }
702        }
703        if is_environ_mutation_call(&node) {
704            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
705        }
706        self.generic_visit_expr_call(node);
707    }
708
709    fn visit_stmt_import(&mut self, node: StmtImport) {
710        for alias in &node.names {
711            match &alias.asname {
712                // `import X.Y as A` binds `A`; a plain `import X.Y` binds only the head `X`.
713                Some(asname) => {
714                    self.imports
715                        .insert(asname.to_string(), alias.name.to_string());
716                }
717                None => {
718                    let head = import_head(alias.name.as_str());
719                    self.imports.insert(head.to_string(), head.to_string());
720                }
721            }
722        }
723        self.generic_visit_stmt_import(node);
724    }
725
726    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
727        // A relative import names no absolute module, so its bindings resolve nothing.
728        if relative_level(&node) == 0 {
729            if let Some(module) = &node.module {
730                for alias in &node.names {
731                    let bound = alias.asname.as_ref().unwrap_or(&alias.name);
732                    self.imports
733                        .insert(bound.to_string(), format!("{module}.{}", alias.name));
734                }
735            }
736        }
737        self.generic_visit_stmt_import_from(node);
738    }
739
740    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
741    // context expression is never walked unless we descend into it here.
742    fn visit_withitem(&mut self, node: WithItem) {
743        self.visit_expr(node.context_expr);
744        if let Some(optional_vars) = node.optional_vars {
745            self.visit_expr(*optional_vars);
746        }
747    }
748
749    fn visit_stmt_assign(&mut self, node: StmtAssign) {
750        if node.targets.iter().any(is_os_environ_subscript) {
751            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
752        }
753        self.generic_visit_stmt_assign(node);
754    }
755
756    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
757        if is_os_environ_subscript(node.target.as_ref()) {
758            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
759        }
760        self.generic_visit_stmt_aug_assign(node);
761    }
762
763    fn visit_stmt_delete(&mut self, node: StmtDelete) {
764        if node.targets.iter().any(is_os_environ_subscript) {
765            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
766        }
767        self.generic_visit_stmt_delete(node);
768    }
769}
770
771/// `true` when a `*args` / `**kwargs` arg is named `name`.
772fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
773    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
774}
775
776/// `true` for an `@pytest.fixture` / `@fixture` decorator, called or bare.
777fn is_fixture_decorator(decorator: &Expr) -> bool {
778    let target = match decorator {
779        Expr::Call(call) => call.func.as_ref(),
780        other => other,
781    };
782    match target {
783        Expr::Name(name) => name.id.as_str() == "fixture",
784        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
785        _ => false,
786    }
787}
788
789/// The three call shapes of `unittest.mock.patch`, which name their target differently.
790enum PatchForm {
791    /// `patch("pkg.mod.attr")` — the target is the string-literal first argument.
792    Target,
793    /// `patch.object(base, "attr")` — the target is `base`'s module plus the attribute.
794    Object,
795    /// `patch.dict(base_or_string, ...)` — the target is the dict itself.
796    Dict,
797}
798
799/// The form of a `patch(...)` / `patch.object(...)` / `patch.dict(...)` call, plain or
800/// reached through a module (`mock.patch(...)`, `unittest.mock.patch`). `None` otherwise.
801fn patch_form(call: &ExprCall) -> Option<PatchForm> {
802    match call.func.as_ref() {
803        Expr::Name(name) if name.id.as_str() == "patch" => Some(PatchForm::Target),
804        Expr::Attribute(attr) => match attr.attr.as_str() {
805            "patch" => Some(PatchForm::Target),
806            "object" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Object),
807            "dict" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Dict),
808            _ => None,
809        },
810        _ => None,
811    }
812}
813
814/// `true` for any [`PatchForm`] call.
815fn is_patch_call(call: &ExprCall) -> bool {
816    patch_form(call).is_some()
817}
818
819/// `true` when an attribute's base resolves to `patch` — a `patch.object` receiver.
820fn attr_base_is_patch(expr: &Expr) -> bool {
821    match expr {
822        Expr::Name(name) => name.id.as_str() == "patch",
823        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
824        _ => false,
825    }
826}
827
828const 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)";
829
830const 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";
831
832/// The string-literal first argument of a `patch(...)` call, the dotted target. `None` for
833/// a non-literal argument, which can't be classified deterministically.
834fn patch_string_target(call: &ExprCall) -> Option<&str> {
835    string_arg(call, 0)
836}
837
838/// The string literal at argument position `index` of a call, if that is what sits there.
839fn string_arg(call: &ExprCall, index: usize) -> Option<&str> {
840    if let Some(Expr::Constant(constant)) = call.args.get(index) {
841        if let Constant::Str(value) = &constant.value {
842            return Some(value.as_str());
843        }
844    }
845    None
846}
847
848/// The dotted segments of a plain attribute chain (`myproject.ledger` → `["myproject",
849/// "ledger"]`). `None` for a chain rooted in anything but a name.
850fn attr_chain_segments(expr: &Expr) -> Option<Vec<&str>> {
851    match expr {
852        Expr::Name(name) => Some(vec![name.id.as_str()]),
853        Expr::Attribute(attr) => {
854            let mut segments = attr_chain_segments(attr.value.as_ref())?;
855            segments.push(attr.attr.as_str());
856            Some(segments)
857        }
858        _ => None,
859    }
860}
861
862/// The dotted module path an object target names, its head replaced by the module its import
863/// binds (`ledger` → `myproject.ledger` after `from myproject import ledger`). `None` when no
864/// import binds the head — a local name has no statically known module.
865fn resolve_object_target(expr: &Expr, imports: &HashMap<String, String>) -> Option<String> {
866    let segments = attr_chain_segments(expr)?;
867    let (head, rest) = segments.split_first()?;
868    let mut target = imports.get(*head)?.clone();
869    for segment in rest {
870        target.push('.');
871        target.push_str(segment);
872    }
873    Some(target)
874}
875
876/// The dotted target a patch call names, resolved statically: the string literal for
877/// `patch(...)` (and a string-target `patch.dict`), the import-resolved first argument for
878/// the object forms. `None` when the target resists static resolution — nothing fires.
879fn patch_target(call: &ExprCall, imports: &HashMap<String, String>) -> Option<String> {
880    match patch_form(call)? {
881        PatchForm::Target => patch_string_target(call).map(str::to_owned),
882        PatchForm::Dict => patch_string_target(call)
883            .map(str::to_owned)
884            .or_else(|| resolve_object_target(call.args.first()?, imports)),
885        PatchForm::Object => {
886            let base = resolve_object_target(call.args.first()?, imports)?;
887            Some(match string_arg(call, 1) {
888                Some(attr) => format!("{base}.{attr}"),
889                None => base,
890            })
891        }
892    }
893}
894
895/// `true` when a patch target names an UPPER_CASE constant (`"pkg.cfg.CACHE_DIR"`).
896fn patches_constant(target: &str) -> bool {
897    target.rsplit('.').next().is_some_and(is_upper_constant)
898}
899
900/// `true` when patch `target`'s head segment names the first-party package `pkg`.
901fn patches_first_party(target: &str, pkg: &str) -> bool {
902    target
903        .split('.')
904        .next()
905        .is_some_and(|head| !head.is_empty() && head == pkg)
906}
907
908/// `true` for an ALL-CAPS name: uppercase letters, digits, underscores, one letter minimum.
909fn is_upper_constant(name: &str) -> bool {
910    !name.is_empty()
911        && name
912            .chars()
913            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
914        && name.chars().any(|c| c.is_ascii_uppercase())
915}
916
917const ENVIRON_MUTATION_MSG: &str =
918    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
919
920/// `true` for the expression `os.environ`.
921fn is_os_environ(expr: &Expr) -> bool {
922    matches!(
923        expr,
924        Expr::Attribute(attr)
925            if attr.attr.as_str() == "environ"
926                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
927    )
928}
929
930/// `true` for `os.environ[...]`, the form used as an assignment or `del` target.
931fn is_os_environ_subscript(expr: &Expr) -> bool {
932    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
933}
934
935/// `true` for a mutating method call on `os.environ`, like `os.environ.update(...)`.
936fn is_environ_mutation_call(call: &ExprCall) -> bool {
937    matches!(
938        call.func.as_ref(),
939        Expr::Attribute(attr)
940            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
941    )
942}
943
944/// `true` for a `dict` method that mutates in place.
945fn is_environ_mutator(method: &str) -> bool {
946    matches!(
947        method,
948        "update" | "pop" | "setdefault" | "clear" | "popitem"
949    )
950}
951
952/// The 1-based line containing byte `offset` in `source`.
953fn line_of(source: &str, offset: TextSize) -> usize {
954    let offset = (u32::from(offset) as usize).min(source.len());
955    source.as_bytes()[..offset]
956        .iter()
957        .filter(|&&byte| byte == b'\n')
958        .count()
959        + 1
960}
961
962/// The dist's own top-level import package: the nearest `pyproject.toml`'s `[project].name`,
963/// [normalized](normalize_dist_name). The walk up stops at a `.git` boundary so it can't
964/// escape into an unrelated project, and `None` means nothing is flagged rather than guessed.
965fn first_party_package(root: &Path) -> Option<String> {
966    for dir in root.ancestors() {
967        let candidate = dir.join("pyproject.toml");
968        if candidate.is_file() {
969            return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
970        }
971        if dir.join(".git").exists() {
972            break;
973        }
974    }
975    None
976}
977
978/// `[project].name` from a `pyproject.toml`, if present and a string.
979fn read_project_name(path: &Path) -> Option<String> {
980    let contents = std::fs::read_to_string(path).ok()?;
981    let value: toml::Value = toml::from_str(&contents).ok()?;
982    value
983        .get("project")?
984        .get("name")?
985        .as_str()
986        .map(str::to_owned)
987}
988
989/// A distribution name as its import package name, PEP 503-flavoured: `My-Project` →
990/// `my_project`.
991fn normalize_dist_name(name: &str) -> String {
992    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
993}
994
995fn collect_python_files(
996    dir: &Path,
997    out: &mut Vec<PathBuf>,
998    is_match: fn(&Path) -> bool,
999) -> Result<()> {
1000    let entries =
1001        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1002    for entry in entries {
1003        let path = entry
1004            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
1005            .path();
1006        if path.is_dir() {
1007            collect_python_files(&path, out, is_match)?;
1008        } else if is_match(&path) {
1009            out.push(path);
1010        }
1011    }
1012    Ok(())
1013}
1014
1015/// `true` for a file the integration lints scan: `*_test.py` or `conftest.py`. A legacy
1016/// `test_*.py` is ordinary source.
1017fn is_python_test_file(path: &Path) -> bool {
1018    let name = path
1019        .file_name()
1020        .and_then(|n| n.to_str())
1021        .unwrap_or_default();
1022    name == "conftest.py" || name.ends_with("_test.py")
1023}
1024
1025/// `true` for a colocated unit test: `*_test.py`. A legacy `test_*.py` is ordinary source,
1026/// and `conftest.py` holds fixtures rather than a unit.
1027fn is_python_unit_test_file(path: &Path) -> bool {
1028    let name = path
1029        .file_name()
1030        .and_then(|n| n.to_str())
1031        .unwrap_or_default();
1032    name.ends_with("_test.py")
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038    use std::sync::atomic::{AtomicU64, Ordering};
1039
1040    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
1041    struct TempDir(PathBuf);
1042
1043    impl TempDir {
1044        fn new() -> Self {
1045            static COUNTER: AtomicU64 = AtomicU64::new(0);
1046            let dir = std::env::temp_dir().join(format!(
1047                "tc-lint-{}-{}",
1048                std::process::id(),
1049                COUNTER.fetch_add(1, Ordering::Relaxed),
1050            ));
1051            std::fs::create_dir_all(&dir).unwrap();
1052            TempDir(dir)
1053        }
1054
1055        fn write(&self, name: &str, contents: &str) {
1056            let path = self.0.join(name);
1057            if let Some(parent) = path.parent() {
1058                std::fs::create_dir_all(parent).unwrap();
1059            }
1060            std::fs::write(path, contents).unwrap();
1061        }
1062    }
1063
1064    impl Drop for TempDir {
1065        fn drop(&mut self) {
1066            let _ = std::fs::remove_dir_all(&self.0);
1067        }
1068    }
1069
1070    #[test]
1071    fn normalize_dist_name_maps_to_import_name() {
1072        assert_eq!(normalize_dist_name("My-Project"), "my_project");
1073        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1074        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
1075        assert_eq!(normalize_dist_name("myproject"), "myproject");
1076    }
1077
1078    /// Parse `src` (a single expression statement) and return its call.
1079    fn parse_call(src: &str) -> ExprCall {
1080        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1081        match suite.into_iter().next().expect("one statement") {
1082            ast::Stmt::Expr(stmt) => match *stmt.value {
1083                Expr::Call(call) => call,
1084                other => panic!("expected a call, got {other:?}"),
1085            },
1086            other => panic!("expected an expression statement, got {other:?}"),
1087        }
1088    }
1089
1090    #[test]
1091    fn patch_target_only_reads_string_literals_for_the_string_form() {
1092        let imports = HashMap::new();
1093        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1094        assert_eq!(
1095            patch_target(&str_call, &imports).as_deref(),
1096            Some("pkg.mod.attr")
1097        );
1098        // A name in `patch(...)` holds a string, which static resolution cannot read.
1099        let name_call = parse_call("patch(target)\n");
1100        assert_eq!(patch_target(&name_call, &imports), None);
1101        let int_call = parse_call("patch(42)\n");
1102        assert_eq!(patch_target(&int_call, &imports), None);
1103        let empty_call = parse_call("patch()\n");
1104        assert_eq!(patch_target(&empty_call, &imports), None);
1105    }
1106
1107    /// An import map binding the names the object-form snippets use.
1108    fn object_form_imports() -> HashMap<String, String> {
1109        HashMap::from([
1110            ("ledger".to_string(), "myproject.ledger".to_string()),
1111            ("myproject".to_string(), "myproject".to_string()),
1112            ("cfg".to_string(), "myproject.cfg".to_string()),
1113        ])
1114    }
1115
1116    #[test]
1117    fn patch_target_resolves_object_forms_through_imports() {
1118        let imports = object_form_imports();
1119        let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1120        assert_eq!(
1121            patch_target(&imported_name, &imports).as_deref(),
1122            Some("myproject.ledger.record")
1123        );
1124        let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1125        assert_eq!(
1126            patch_target(&dotted_module, &imports).as_deref(),
1127            Some("myproject.ledger.record")
1128        );
1129        // A non-literal attribute still names the base module, enough for the first-party rule.
1130        let name_attr = parse_call("patch.object(ledger, attr)\n");
1131        assert_eq!(
1132            patch_target(&name_attr, &imports).as_deref(),
1133            Some("myproject.ledger")
1134        );
1135        let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1136        assert_eq!(
1137            patch_target(&dict_object, &imports).as_deref(),
1138            Some("myproject.cfg.SETTINGS")
1139        );
1140        let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1141        assert_eq!(
1142            patch_target(&dict_string, &imports).as_deref(),
1143            Some("pkg.cfg.FLAGS")
1144        );
1145    }
1146
1147    #[test]
1148    fn patch_target_declines_a_base_bound_by_no_import() {
1149        let imports = object_form_imports();
1150        let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1151        assert_eq!(patch_target(&call_base, &imports), None);
1152        let unbound_name = parse_call("patch.object(client, \"send\")\n");
1153        assert_eq!(patch_target(&unbound_name, &imports), None);
1154        let empty = parse_call("patch.object()\n");
1155        assert_eq!(patch_target(&empty, &imports), None);
1156    }
1157
1158    /// The imports a [`LintVisitor`] records for `src`.
1159    fn collect_imports(src: &str) -> HashMap<String, String> {
1160        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1161        let mut visitor = LintVisitor {
1162            file: Path::new("t.py"),
1163            source: src,
1164            fixture_depth: 0,
1165            first_party: None,
1166            imports: HashMap::new(),
1167            violations: Vec::new(),
1168        };
1169        for stmt in suite {
1170            visitor.visit_stmt(stmt);
1171        }
1172        visitor.imports
1173    }
1174
1175    #[test]
1176    fn lint_visitor_binds_imports_to_their_modules() {
1177        let imports = collect_imports(
1178            "import myproject.ledger\n\
1179             import myproject.config as cfg\n\
1180             from myproject import ledger\n\
1181             from myproject import charge as ch\n\
1182             from . import rel\n",
1183        );
1184        assert_eq!(
1185            imports.get("myproject").map(String::as_str),
1186            Some("myproject")
1187        );
1188        assert_eq!(
1189            imports.get("cfg").map(String::as_str),
1190            Some("myproject.config")
1191        );
1192        assert_eq!(
1193            imports.get("ledger").map(String::as_str),
1194            Some("myproject.ledger")
1195        );
1196        assert_eq!(
1197            imports.get("ch").map(String::as_str),
1198            Some("myproject.charge")
1199        );
1200        assert_eq!(imports.get("rel"), None);
1201    }
1202
1203    /// Build a `from <source> import <symbols>` record (`source: None` → relative).
1204    fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1205        ImportRecord {
1206            display: source.unwrap_or(".rel").to_string(),
1207            line: 1,
1208            is_uut: false,
1209            symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1210            source: source.map(str::to_string),
1211            module: None,
1212        }
1213    }
1214
1215    fn targets(list: &[&str]) -> Vec<String> {
1216        list.iter().map(|s| (*s).to_string()).collect()
1217    }
1218
1219    #[test]
1220    fn is_mocked_requires_every_symbol_at_the_import_module() {
1221        let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1222        // Only `record` patched → the un-mocked `erase` leaves the import un-mocked.
1223        assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1224        assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1225    }
1226
1227    #[test]
1228    fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1229        let rec = from_import(Some("pkg.ledger"), &["record"]);
1230        // Same last segment, different module → not mocked.
1231        assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1232        let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1233        assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1234        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1235    }
1236
1237    #[test]
1238    fn is_mocked_relative_import_accepts_a_last_segment_match() {
1239        // A relative import has no module to compare, so a last-segment match is accepted.
1240        let rec = from_import(None, &["record"]);
1241        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1242        assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1243    }
1244
1245    #[test]
1246    fn is_mocked_module_import_matches_a_patch_reaching_in() {
1247        let rec = ImportRecord {
1248            display: "pkg.db".to_string(),
1249            line: 1,
1250            is_uut: false,
1251            symbols: Vec::new(),
1252            source: None,
1253            module: Some("pkg.db".to_string()),
1254        };
1255        assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1256        assert!(rec.is_mocked(&targets(&["pkg.db"])));
1257        assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1258        let empty = from_import(Some("pkg.mod"), &[]);
1259        assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1260    }
1261
1262    #[test]
1263    fn patches_first_party_matches_head_segment() {
1264        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1265        assert!(patches_first_party("myproject", "myproject"));
1266        assert!(!patches_first_party("requests.get", "myproject"));
1267        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1268        assert!(!patches_first_party("", "myproject"));
1269        assert!(!patches_first_party(".leading", "myproject"));
1270    }
1271
1272    #[test]
1273    fn first_party_package_reads_pyproject_name() {
1274        let tree = TempDir::new();
1275        tree.write(
1276            "pyproject.toml",
1277            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1278        );
1279        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1280    }
1281
1282    #[test]
1283    fn first_party_package_is_none_without_a_project_name() {
1284        let tree = TempDir::new();
1285        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1286        tree.write(".git", "");
1287        assert_eq!(first_party_package(&tree.0), None);
1288    }
1289
1290    #[test]
1291    fn first_party_package_is_none_when_absent() {
1292        let tree = TempDir::new();
1293        assert_eq!(first_party_package(&tree.0), None);
1294    }
1295
1296    /// The displays of the imports `source` leaves un-mocked.
1297    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1298        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1299        let mut visitor = UnitIsolationVisitor {
1300            source,
1301            first_party,
1302            base,
1303            type_checking_depth: 0,
1304            imports: Vec::new(),
1305            patch_targets: Vec::new(),
1306        };
1307        for stmt in suite {
1308            visitor.visit_stmt(stmt);
1309        }
1310        visitor
1311            .imports
1312            .iter()
1313            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1314            .map(|i| i.display.clone())
1315            .collect()
1316    }
1317
1318    #[test]
1319    fn import_head_and_last_segment() {
1320        assert_eq!(import_head("myproject.db.conn"), "myproject");
1321        assert_eq!(import_head("requests"), "requests");
1322        assert_eq!(last_segment("myproject.db.conn"), "conn");
1323        assert_eq!(last_segment("widget"), "widget");
1324    }
1325
1326    #[test]
1327    fn unit_under_test_base_strips_test_suffix() {
1328        assert_eq!(
1329            unit_under_test_base(Path::new("pkg/widget_test.py")),
1330            "widget"
1331        );
1332        // Only `*_test.py` reaches here, so a legacy `test_*.py` keeps its prefix.
1333        assert_eq!(
1334            unit_under_test_base(Path::new("test_widget.py")),
1335            "test_widget"
1336        );
1337        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1338    }
1339
1340    #[test]
1341    fn recognizes_python_unit_test_files() {
1342        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1343        assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1344        assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1345        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1346        assert!(!is_python_unit_test_file(Path::new("widget.py")));
1347    }
1348
1349    #[test]
1350    fn visitor_flags_first_party_and_external_collaborators() {
1351        // The UUT is left alone; the first-party and third-party imports are flagged.
1352        let found = unmocked(
1353            "widget",
1354            "myproject",
1355            "from myproject.widget import build\n\
1356             from myproject.ledger import record\n\
1357             import requests\n",
1358        );
1359        assert_eq!(
1360            found,
1361            vec!["myproject.ledger".to_string(), "requests".to_string()]
1362        );
1363    }
1364
1365    #[test]
1366    fn visitor_clears_a_mocked_collaborator() {
1367        let found = unmocked(
1368            "widget",
1369            "myproject",
1370            "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1371        );
1372        assert!(found.is_empty(), "got: {found:?}");
1373    }
1374
1375    #[test]
1376    fn visitor_flags_a_wrong_module_patch() {
1377        // A patch sharing only the last segment names a different module, so `record`
1378        // stays an un-mocked collaborator.
1379        let found = unmocked(
1380            "widget",
1381            "myproject",
1382            "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1383        );
1384        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1385    }
1386
1387    #[test]
1388    fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1389        // Patching only `record` leaves the sibling `erase` a real collaborator.
1390        let found = unmocked(
1391            "widget",
1392            "myproject",
1393            "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1394        );
1395        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1396        let both = unmocked(
1397            "widget",
1398            "myproject",
1399            "from myproject.ledger import record, erase\n\
1400             patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1401        );
1402        assert!(both.is_empty(), "got: {both:?}");
1403    }
1404
1405    #[test]
1406    fn visitor_handles_module_and_relative_imports() {
1407        assert_eq!(
1408            unmocked("widget", "myproject", "import myproject.db\n"),
1409            vec!["myproject.db".to_string()]
1410        );
1411        assert!(unmocked(
1412            "widget",
1413            "myproject",
1414            "import myproject.db\npatch(\"myproject.db.connect\")\n"
1415        )
1416        .is_empty());
1417        assert_eq!(
1418            unmocked("widget", "myproject", "from .ledger import record\n"),
1419            vec![".ledger".to_string()]
1420        );
1421        assert_eq!(
1422            unmocked(
1423                "widget",
1424                "myproject",
1425                "from . import ledger\nfrom . import widget\n"
1426            ),
1427            vec![".ledger".to_string()]
1428        );
1429    }
1430
1431    #[test]
1432    fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1433        // A bare `from . import …` names the package's own re-export surface, the SUT.
1434        assert!(unmocked(
1435            "__init__",
1436            "myproject",
1437            "from . import Thing, __all__, __version__\n"
1438        )
1439        .is_empty());
1440        // Reaching around the barrel into a sibling module is still a collaborator.
1441        assert_eq!(
1442            unmocked("__init__", "myproject", "from .core import Thing\n"),
1443            vec![".core".to_string()]
1444        );
1445        // `from .. import x` resolves to the parent package, not the SUT file.
1446        assert_eq!(
1447            unmocked("__init__", "myproject", "from .. import sibling\n"),
1448            vec!["..sibling".to_string()]
1449        );
1450        // The barrel shortcut is scoped to the `__init__` base.
1451        assert_eq!(
1452            unmocked("widget", "myproject", "from . import ledger\n"),
1453            vec![".ledger".to_string()]
1454        );
1455    }
1456
1457    #[test]
1458    fn visitor_skips_type_checking_imports() {
1459        // A TYPE_CHECKING import is type-only; the runtime `else` import is still seen.
1460        let found = unmocked(
1461            "widget",
1462            "myproject",
1463            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
1464        );
1465        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1466    }
1467
1468    #[test]
1469    fn is_checked_import_classifies_origins() {
1470        assert!(is_checked_import("myproject", "myproject")); // first-party
1471        assert!(!is_checked_import("pytest", "myproject")); // test framework
1472        assert!(!is_checked_import("_pytest", "myproject"));
1473        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
1474        assert!(is_checked_import("socket", "myproject"));
1475        assert!(!is_checked_import("json", "myproject")); // pure stdlib
1476        assert!(!is_checked_import("dataclasses", "myproject"));
1477        assert!(is_checked_import("requests", "myproject")); // third-party
1478        assert!(is_checked_import("stripe", "myproject"));
1479        // A dual-nature head stays pure — the patch convention catches it, not the import.
1480        assert!(!is_checked_import("os", "myproject"));
1481        assert!(!is_checked_import("pathlib", "myproject"));
1482        assert!(!is_checked_import("datetime", "myproject"));
1483    }
1484
1485    #[test]
1486    fn visitor_flags_external_collaborators() {
1487        let found = unmocked(
1488            "widget",
1489            "myproject",
1490            "import requests\nimport subprocess\nimport json\nimport pytest\n",
1491        );
1492        assert_eq!(found.len(), 2, "got: {found:?}");
1493        assert!(found.contains(&"requests".to_string()));
1494        assert!(found.contains(&"subprocess".to_string()));
1495    }
1496
1497    #[test]
1498    fn visitor_type_checking_variants_and_plain_if() {
1499        // The attribute form guards type-only imports too.
1500        assert!(unmocked(
1501            "widget",
1502            "myproject",
1503            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
1504        )
1505        .is_empty());
1506        // A plain `if` is walked normally; its import is still a collaborator.
1507        assert_eq!(
1508            unmocked(
1509                "widget",
1510                "myproject",
1511                "if ready == 1:\n    from myproject.ledger import record\n"
1512            ),
1513            vec!["myproject.ledger".to_string()]
1514        );
1515    }
1516
1517    #[test]
1518    fn find_unit_isolation_without_pyproject_reports_nothing() {
1519        let tree = TempDir::new();
1520        tree.write("widget_test.py", "from myproject.ledger import record\n");
1521        tree.write(".git", "");
1522        assert!(find_unit_isolation_violations(&tree.0)
1523            .expect("a readable tree should succeed")
1524            .is_empty());
1525    }
1526
1527    #[test]
1528    fn find_unit_isolation_walks_subdirs_and_flags() {
1529        let tree = TempDir::new();
1530        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1531        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1532        let found =
1533            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1534        assert_eq!(found.len(), 1, "got: {found:?}");
1535        assert_eq!(found[0].rule, "unmocked-collaborator");
1536        assert!(found[0].message.contains("myproject.ledger"));
1537    }
1538
1539    #[test]
1540    fn recognizes_python_test_files() {
1541        assert!(is_python_test_file(Path::new("widget_test.py")));
1542        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1543        assert!(is_python_test_file(Path::new("conftest.py")));
1544        assert!(!is_python_test_file(Path::new("test_widget.py")));
1545    }
1546
1547    #[test]
1548    fn ignores_non_test_files() {
1549        assert!(!is_python_test_file(Path::new("widget.py")));
1550        assert!(!is_python_test_file(Path::new("conftest.pyi")));
1551        assert!(!is_python_test_file(Path::new("README.md")));
1552        assert!(!is_python_test_file(Path::new("testing.py")));
1553    }
1554
1555    #[test]
1556    fn line_of_counts_newlines() {
1557        let src = "a\nb\nc\n";
1558        assert_eq!(line_of(src, TextSize::from(0)), 1);
1559        assert_eq!(line_of(src, TextSize::from(2)), 2);
1560        assert_eq!(line_of(src, TextSize::from(4)), 3);
1561    }
1562
1563    #[test]
1564    fn recognizes_environ_mutators() {
1565        assert!(is_environ_mutator("update"));
1566        assert!(is_environ_mutator("pop"));
1567        assert!(is_environ_mutator("clear"));
1568        assert!(!is_environ_mutator("get"));
1569        assert!(!is_environ_mutator("keys"));
1570    }
1571
1572    #[test]
1573    fn recognizes_upper_constants() {
1574        assert!(is_upper_constant("CACHE_DIR"));
1575        assert!(is_upper_constant("DEBUG"));
1576        assert!(is_upper_constant("MAX_2"));
1577        assert!(!is_upper_constant("cache_dir"));
1578        assert!(!is_upper_constant("CacheDir"));
1579        assert!(!is_upper_constant("fetch"));
1580        assert!(!is_upper_constant(""));
1581        assert!(!is_upper_constant("_"));
1582        assert!(!is_upper_constant("123"));
1583    }
1584}