Skip to main content

testing_conventions/
lint.rs

1//! Integration-test lints (issue #19; rules #48–#52) — the `integration lint`
2//! command.
3//!
4//! A *lint* here is a deterministic style/mechanism check on test code, as
5//! opposed to the structural `colocated-test` / `coverage` rules. This module hosts
6//! the mocking mechanism & style lints; more lints will join them under the
7//! same command.
8//!
9//! Detection is AST-based: each Python test file is parsed with
10//! `rustpython_parser` and the tree is walked with a [`Visitor`].
11//!
12//! Implemented lints:
13//! - **`no-monkeypatch`** (#49): a test/fixture function that declares the
14//!   `monkeypatch` parameter (pytest's fixture). Patch with `unittest.mock`
15//!   wrapped in a `pytest.fixture` instead.
16//! - **`no-inline-patch`** (#50): a `patch(...)` / `patch.object(...)` /
17//!   `patch.dict(...)` call inside a test body — the `with patch(...)` form or a
18//!   bare call. Patches belong in a `pytest.fixture`; a patch *inside* a fixture
19//!   is allowed.
20//! - **`no-environ-mutation`** (#51): direct mutation of `os.environ` —
21//!   `os.environ[...] = …`, `del os.environ[...]`, or a mutating method
22//!   (`update` / `pop` / `setdefault` / `clear` / `popitem`). Set env via
23//!   `patch.dict(os.environ, {...})` instead.
24//! - **`no-constant-patch`** (#52): patching a module-global UPPER_CASE constant,
25//!   e.g. `patch("pkg.config.CACHE_DIR", …)`. Inject config explicitly. Waivable
26//!   per file via the config `exempt` list.
27
28use std::path::{Path, PathBuf};
29
30use anyhow::{anyhow, Context, Result};
31use rustpython_ast::Visitor;
32use rustpython_parser::ast::{
33    self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
34    StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
35};
36use rustpython_parser::text_size::{TextRange, TextSize};
37use rustpython_parser::Parse;
38
39// `Violation` is shared with the Rust `isolation` lint; it lives in `violation`
40// and is re-exported here so `testing_conventions::lint::Violation` still resolves.
41pub use crate::violation::Violation;
42
43/// Scan the Python test files under `root` and return every lint violation,
44/// sorted by `(file, line)` for deterministic output.
45///
46/// A *Python test file* is `*_test.py`, the legacy `test_*.py`, or
47/// `conftest.py` (where fixtures live). Each is parsed and walked. A file that
48/// cannot be read or parsed is an error.
49pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
50    let root = root.as_ref();
51    // The dist's own top-level package, for `no-first-party-patch` (#42). Resolved
52    // once for the whole tree; `None` (no declared package) means that rule flags
53    // nothing.
54    let first_party = first_party_package(root);
55    let mut files = Vec::new();
56    collect_python_files(root, &mut files, is_python_test_file)?;
57    files.sort();
58
59    let mut violations = Vec::new();
60    for file in &files {
61        let source = std::fs::read_to_string(file)
62            .with_context(|| format!("reading test file `{}`", file.display()))?;
63        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
64            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
65        let mut visitor = LintVisitor {
66            file,
67            source: &source,
68            fixture_depth: 0,
69            first_party: first_party.as_deref(),
70            violations: Vec::new(),
71        };
72        for stmt in suite {
73            visitor.visit_stmt(stmt);
74        }
75        violations.append(&mut visitor.violations);
76    }
77
78    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
79    Ok(violations)
80}
81
82/// Scan the colocated Python unit tests under `root` and return every
83/// `unmocked-collaborator` violation (#42 slice 2): a first-party collaborator a
84/// unit test imports without mocking it. The Python arm of `unit isolation`
85/// ([`crate::isolation::Language::Python`]).
86///
87/// A *unit test* here is `*_test.py` / `test_*.py` (not `conftest.py`). First-party
88/// is the dist's own package ([`first_party_package`]); a tree with no declared
89/// package has no first-party collaborators and so reports nothing.
90pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
91    let root = root.as_ref();
92    // First-party is the dist's own package; with none declared there are no
93    // first-party collaborators to flag.
94    let Some(first_party) = first_party_package(root) else {
95        return Ok(Vec::new());
96    };
97    let mut files = Vec::new();
98    collect_python_files(root, &mut files, is_python_unit_test_file)?;
99    files.sort();
100
101    let mut violations = Vec::new();
102    for file in &files {
103        let source = std::fs::read_to_string(file)
104            .with_context(|| format!("reading test file `{}`", file.display()))?;
105        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
106            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
107        let base = unit_under_test_base(file);
108        let mut visitor = UnitIsolationVisitor {
109            source: &source,
110            first_party: &first_party,
111            base: &base,
112            type_checking_depth: 0,
113            imports: Vec::new(),
114            patch_targets: Vec::new(),
115        };
116        for stmt in suite {
117            visitor.visit_stmt(stmt);
118        }
119        // A first-party import that is neither the unit under test nor mocked by
120        // some `patch(...)` in the file is an un-mocked collaborator.
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 first-party import seen in a unit test, with what it takes to decide
144/// whether it's the unit under test or mocked.
145struct ImportRecord {
146    /// The module path to name in the message (`myproject.ledger`, `.ledger`).
147    display: String,
148    line: usize,
149    /// `true` when this import *is* the unit under test (never a collaborator).
150    is_uut: bool,
151    /// For `from X import a, b` — the bound symbols (matched against a patch's last
152    /// dotted segment). Empty for a plain module import.
153    symbols: Vec<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 matching last
160    /// segment for a `from`-import symbol, or a patch reaching into a module import.
161    fn is_mocked(&self, patch_targets: &[String]) -> bool {
162        let symbol_mocked = patch_targets.iter().any(|target| {
163            let last = target.rsplit('.').next().unwrap_or(target);
164            self.symbols.iter().any(|symbol| symbol == last)
165        });
166        if symbol_mocked {
167            return true;
168        }
169        match &self.module {
170            Some(module) => {
171                let prefix = format!("{module}.");
172                patch_targets
173                    .iter()
174                    .any(|target| target == module || target.starts_with(&prefix))
175            }
176            None => false,
177        }
178    }
179}
180
181/// Walks one parsed unit test, collecting its first-party imports and every
182/// `patch("…")` string target so [`find_unit_isolation_violations`] can pair them.
183/// Imports guarded by `if TYPE_CHECKING:` are type-only (erased at runtime) and
184/// skipped.
185struct UnitIsolationVisitor<'a> {
186    source: &'a str,
187    first_party: &'a str,
188    base: &'a str,
189    type_checking_depth: usize,
190    imports: Vec<ImportRecord>,
191    patch_targets: Vec<String>,
192}
193
194impl Visitor for UnitIsolationVisitor<'_> {
195    fn visit_stmt_import(&mut self, node: StmtImport) {
196        if self.type_checking_depth == 0 {
197            let line = line_of(self.source, node.range.start());
198            for alias in &node.names {
199                let module = alias.name.as_str();
200                if is_checked_import(import_head(module), self.first_party) {
201                    self.imports.push(ImportRecord {
202                        display: module.to_string(),
203                        line,
204                        is_uut: last_segment(module) == self.base,
205                        symbols: Vec::new(),
206                        module: Some(module.to_string()),
207                    });
208                }
209            }
210        }
211        self.generic_visit_stmt_import(node);
212    }
213
214    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
215        if self.type_checking_depth == 0 {
216            let level = relative_level(&node);
217            let module = node.module.as_ref().map(|m| m.as_str());
218            // Relative imports are first-party; an absolute import is checked when
219            // its head is first-party or external (third-party / effectful stdlib).
220            let should_check = level > 0
221                || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
222            if should_check {
223                let line = line_of(self.source, node.range.start());
224                let dots = ".".repeat(level);
225                match module {
226                    // `from <module> import a, b` — the bound symbols are collaborators.
227                    Some(module) => self.imports.push(ImportRecord {
228                        display: format!("{dots}{module}"),
229                        line,
230                        is_uut: last_segment(module) == self.base,
231                        symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
232                        module: None,
233                    }),
234                    // `from . import sub` — each name is a submodule.
235                    None => {
236                        for alias in &node.names {
237                            let name = alias.name.as_str();
238                            self.imports.push(ImportRecord {
239                                display: format!("{dots}{name}"),
240                                line,
241                                is_uut: name == self.base,
242                                symbols: vec![name.to_string()],
243                                module: None,
244                            });
245                        }
246                    }
247                }
248            }
249        }
250        self.generic_visit_stmt_import_from(node);
251    }
252
253    fn visit_expr_call(&mut self, node: ExprCall) {
254        if is_patch_call(&node) {
255            if let Some(target) = patch_string_target(&node) {
256                self.patch_targets.push(target.to_string());
257            }
258        }
259        self.generic_visit_expr_call(node);
260    }
261
262    fn visit_stmt_if(&mut self, node: StmtIf) {
263        // Imports under `if TYPE_CHECKING:` are type-only — skip the body (the
264        // runtime `else` is still walked). Other `if`s recurse normally.
265        if is_type_checking(node.test.as_ref()) {
266            self.type_checking_depth += 1;
267            for stmt in node.body {
268                self.visit_stmt(stmt);
269            }
270            self.type_checking_depth -= 1;
271            for stmt in node.orelse {
272                self.visit_stmt(stmt);
273            }
274        } else {
275            self.generic_visit_stmt_if(node);
276        }
277    }
278}
279
280/// The leading dotted segment of a module path (`myproject.db` → `myproject`).
281fn import_head(module: &str) -> &str {
282    module.split('.').next().unwrap_or(module)
283}
284
285/// `true` when an import head names a collaborator the unit-isolation rule checks:
286/// **first-party** (the dist package) or **external** — a third-party package, or an
287/// effectful-stdlib module. The test framework and **pure** stdlib are not
288/// collaborators (#121).
289fn is_checked_import(head: &str, first_party: &str) -> bool {
290    if head == first_party {
291        return true; // first-party
292    }
293    if TEST_FRAMEWORK.contains(&head) {
294        return false; // pytest et al. — the harness, never a collaborator
295    }
296    if EFFECTFUL_STDLIB.contains(&head) {
297        return true; // external — effectful stdlib
298    }
299    if STDLIB_MODULES.contains(&head) {
300        return false; // pure stdlib
301    }
302    true // external — a third-party package
303}
304
305/// The test harness — never a collaborator to mock. `unittest` / `unittest.mock`
306/// are stdlib (handled by [`STDLIB_MODULES`]); these are the rest.
307const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
308
309/// Standard-library modules that are **effectful at the head** — the README's
310/// External Dependencies (network / subprocess / process & IPC / randomness /
311/// database / low-level OS). **Dual-nature** heads (`os`, `pathlib`, `datetime`,
312/// `time`, `io`, `logging`, `threading`) are deliberately excluded: a pure use
313/// (`os.path.join`, `datetime(2020, 1, 1)`) can't be told from an effectful one at
314/// the import, so the clock / filesystem stay caught by the patch convention, not
315/// here (a documented non-goal). A tunable heuristic, not an exhaustive map.
316const EFFECTFUL_STDLIB: &[&str] = &[
317    "asynchat",
318    "asyncore",
319    "ctypes",
320    "curses",
321    "dbm",
322    "fcntl",
323    "ftplib",
324    "imaplib",
325    "mmap",
326    "msvcrt",
327    "multiprocessing",
328    "nis",
329    "nntplib",
330    "ossaudiodev",
331    "poplib",
332    "pty",
333    "random",
334    "secrets",
335    "select",
336    "selectors",
337    "signal",
338    "smtpd",
339    "smtplib",
340    "socket",
341    "socketserver",
342    "spwd",
343    "sqlite3",
344    "ssl",
345    "subprocess",
346    "syslog",
347    "telnetlib",
348    "termios",
349    "tty",
350    "webbrowser",
351    "winreg",
352    "winsound",
353];
354
355/// Top-level standard-library module names (Python's `sys.stdlib_module_names`).
356/// Used to tell **pure** stdlib (allowed) from a **third-party** package (checked);
357/// the [`EFFECTFUL_STDLIB`] subset is what's actually flagged.
358const STDLIB_MODULES: &[&str] = &[
359    "abc",
360    "aifc",
361    "antigravity",
362    "argparse",
363    "array",
364    "ast",
365    "asynchat",
366    "asyncio",
367    "asyncore",
368    "atexit",
369    "audioop",
370    "base64",
371    "bdb",
372    "binascii",
373    "bisect",
374    "builtins",
375    "bz2",
376    "cProfile",
377    "calendar",
378    "cgi",
379    "cgitb",
380    "chunk",
381    "cmath",
382    "cmd",
383    "code",
384    "codecs",
385    "codeop",
386    "collections",
387    "colorsys",
388    "compileall",
389    "concurrent",
390    "configparser",
391    "contextlib",
392    "contextvars",
393    "copy",
394    "copyreg",
395    "crypt",
396    "csv",
397    "ctypes",
398    "curses",
399    "dataclasses",
400    "datetime",
401    "dbm",
402    "decimal",
403    "difflib",
404    "dis",
405    "distutils",
406    "doctest",
407    "email",
408    "encodings",
409    "ensurepip",
410    "enum",
411    "errno",
412    "faulthandler",
413    "fcntl",
414    "filecmp",
415    "fileinput",
416    "fnmatch",
417    "fractions",
418    "ftplib",
419    "functools",
420    "gc",
421    "genericpath",
422    "getopt",
423    "getpass",
424    "gettext",
425    "glob",
426    "graphlib",
427    "grp",
428    "gzip",
429    "hashlib",
430    "heapq",
431    "hmac",
432    "html",
433    "http",
434    "idlelib",
435    "imaplib",
436    "imghdr",
437    "imp",
438    "importlib",
439    "inspect",
440    "io",
441    "ipaddress",
442    "itertools",
443    "json",
444    "keyword",
445    "lib2to3",
446    "linecache",
447    "locale",
448    "logging",
449    "lzma",
450    "mailbox",
451    "mailcap",
452    "marshal",
453    "math",
454    "mimetypes",
455    "mmap",
456    "modulefinder",
457    "msilib",
458    "msvcrt",
459    "multiprocessing",
460    "netrc",
461    "nis",
462    "nntplib",
463    "nt",
464    "ntpath",
465    "nturl2path",
466    "numbers",
467    "opcode",
468    "operator",
469    "optparse",
470    "os",
471    "ossaudiodev",
472    "pathlib",
473    "pdb",
474    "pickle",
475    "pickletools",
476    "pipes",
477    "pkgutil",
478    "platform",
479    "plistlib",
480    "poplib",
481    "posix",
482    "posixpath",
483    "pprint",
484    "profile",
485    "pstats",
486    "pty",
487    "pwd",
488    "py_compile",
489    "pyclbr",
490    "pydoc",
491    "pydoc_data",
492    "pyexpat",
493    "queue",
494    "quopri",
495    "random",
496    "re",
497    "readline",
498    "reprlib",
499    "resource",
500    "rlcompleter",
501    "runpy",
502    "sched",
503    "secrets",
504    "select",
505    "selectors",
506    "shelve",
507    "shlex",
508    "shutil",
509    "signal",
510    "site",
511    "smtpd",
512    "smtplib",
513    "sndhdr",
514    "socket",
515    "socketserver",
516    "spwd",
517    "sqlite3",
518    "sre_compile",
519    "sre_constants",
520    "sre_parse",
521    "ssl",
522    "stat",
523    "statistics",
524    "string",
525    "stringprep",
526    "struct",
527    "subprocess",
528    "sunau",
529    "symtable",
530    "sys",
531    "sysconfig",
532    "syslog",
533    "tabnanny",
534    "tarfile",
535    "telnetlib",
536    "tempfile",
537    "termios",
538    "textwrap",
539    "this",
540    "threading",
541    "time",
542    "timeit",
543    "tkinter",
544    "token",
545    "tokenize",
546    "tomllib",
547    "trace",
548    "traceback",
549    "tracemalloc",
550    "tty",
551    "turtle",
552    "turtledemo",
553    "types",
554    "typing",
555    "unicodedata",
556    "unittest",
557    "urllib",
558    "uu",
559    "uuid",
560    "venv",
561    "warnings",
562    "wave",
563    "weakref",
564    "webbrowser",
565    "winreg",
566    "winsound",
567    "wsgiref",
568    "xdrlib",
569    "xml",
570    "xmlrpc",
571    "zipapp",
572    "zipfile",
573    "zipimport",
574    "zlib",
575    "zoneinfo",
576];
577
578/// The trailing dotted segment of a module path (`myproject.db` → `db`).
579fn last_segment(module: &str) -> &str {
580    module.rsplit('.').next().unwrap_or(module)
581}
582
583/// The number of leading dots on a `from`-import (`from ..pkg import x` → 2; an
584/// absolute import → 0).
585fn relative_level(node: &StmtImportFrom) -> usize {
586    node.level.map_or(0, |level| level.to_usize())
587}
588
589/// `true` for `TYPE_CHECKING` / `typing.TYPE_CHECKING` — the guard whose body holds
590/// type-only imports.
591fn is_type_checking(test: &Expr) -> bool {
592    match test {
593        Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
594        Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
595        _ => false,
596    }
597}
598
599/// The unit-under-test base name for a test file: `widget_test.py` → `widget`,
600/// legacy `test_widget.py` → `widget`.
601fn unit_under_test_base(file: &Path) -> String {
602    let name = file
603        .file_name()
604        .and_then(|n| n.to_str())
605        .unwrap_or_default();
606    let stem = name.strip_suffix(".py").unwrap_or(name);
607    if let Some(base) = stem.strip_suffix("_test") {
608        base.to_string()
609    } else if let Some(base) = stem.strip_prefix("test_") {
610        base.to_string()
611    } else {
612        stem.to_string()
613    }
614}
615
616/// Walks one parsed test file, collecting lint violations. Tracks how deep we
617/// are inside `@pytest.fixture` functions so `no-inline-patch` can allow patches
618/// there while flagging them in test bodies.
619struct LintVisitor<'a> {
620    file: &'a Path,
621    source: &'a str,
622    fixture_depth: usize,
623    /// The dist's own top-level package (#42), or `None` when undiscoverable.
624    first_party: Option<&'a str>,
625    violations: Vec<Violation>,
626}
627
628impl LintVisitor<'_> {
629    fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
630        self.violations.push(Violation {
631            file: self.file.to_path_buf(),
632            line: line_of(self.source, range.start()),
633            rule,
634            message: message.to_string(),
635        });
636    }
637
638    /// Shared entry for both function kinds: run the parameter lint, then return
639    /// whether this function is a fixture (so the caller bumps `fixture_depth`).
640    fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
641        // `no-monkeypatch` (#49): the `monkeypatch` parameter is the signal.
642        let takes_monkeypatch = args
643            .posonlyargs
644            .iter()
645            .chain(&args.args)
646            .chain(&args.kwonlyargs)
647            .any(|arg| arg.def.arg.as_str() == "monkeypatch")
648            || arg_named(&args.vararg, "monkeypatch")
649            || arg_named(&args.kwarg, "monkeypatch");
650        if takes_monkeypatch {
651            self.report(
652                range,
653                "no-monkeypatch",
654                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
655            );
656        }
657
658        decorators.iter().any(is_fixture_decorator)
659    }
660}
661
662impl Visitor for LintVisitor<'_> {
663    fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
664        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
665        if is_fixture {
666            self.fixture_depth += 1;
667        }
668        self.generic_visit_stmt_function_def(node);
669        if is_fixture {
670            self.fixture_depth -= 1;
671        }
672    }
673
674    fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
675        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
676        if is_fixture {
677            self.fixture_depth += 1;
678        }
679        self.generic_visit_stmt_async_function_def(node);
680        if is_fixture {
681            self.fixture_depth -= 1;
682        }
683    }
684
685    fn visit_expr_call(&mut self, node: ExprCall) {
686        let is_patch = is_patch_call(&node);
687        // `no-inline-patch` (#50): a patch(...) call outside any fixture is a
688        // patch in a test body. Inside a fixture it is the right place.
689        if is_patch && self.fixture_depth == 0 {
690            self.report(
691                node.range,
692                "no-inline-patch",
693                "patch is called inline in a test body; move it into a `pytest.fixture`",
694            );
695        }
696        // `no-constant-patch` (#52): patching a module-global UPPER_CASE constant.
697        // Fires regardless of fixture — config constants are usually patched in one.
698        if is_patch && patches_constant(&node) {
699            self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
700        }
701        // `no-first-party-patch` (#42): in an integration test, patching a
702        // first-party target — `patch("ourpkg.mod.fn")` — is forbidden; an
703        // integration test runs first-party code for real. Fires regardless of
704        // fixture (the patch belongs in one); only when the dist's own package is
705        // known (`first_party`) and the target's head segment names it.
706        if is_patch {
707            if let Some(pkg) = self.first_party {
708                if patch_string_target(&node).is_some_and(|target| patches_first_party(target, pkg))
709                {
710                    self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
711                }
712            }
713        }
714        // `no-environ-mutation` (#51): `os.environ.update(...)` and friends.
715        if is_environ_mutation_call(&node) {
716            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
717        }
718        self.generic_visit_expr_call(node);
719    }
720
721    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
722    // context expression is never walked unless we descend into it here.
723    fn visit_withitem(&mut self, node: WithItem) {
724        self.visit_expr(node.context_expr);
725        if let Some(optional_vars) = node.optional_vars {
726            self.visit_expr(*optional_vars);
727        }
728    }
729
730    // `no-environ-mutation` (#51): `os.environ[...] = …`, augmented assignment,
731    // and `del os.environ[...]`.
732    fn visit_stmt_assign(&mut self, node: StmtAssign) {
733        if node.targets.iter().any(is_os_environ_subscript) {
734            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
735        }
736        self.generic_visit_stmt_assign(node);
737    }
738
739    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
740        if is_os_environ_subscript(node.target.as_ref()) {
741            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
742        }
743        self.generic_visit_stmt_aug_assign(node);
744    }
745
746    fn visit_stmt_delete(&mut self, node: StmtDelete) {
747        if node.targets.iter().any(is_os_environ_subscript) {
748            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
749        }
750        self.generic_visit_stmt_delete(node);
751    }
752}
753
754/// `true` when a `*args` / `**kwargs` arg is named `name`.
755fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
756    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
757}
758
759/// `true` for an `@pytest.fixture` / `@fixture` decorator, with or without a
760/// call (`@pytest.fixture(autouse=True)`).
761fn is_fixture_decorator(decorator: &Expr) -> bool {
762    let target = match decorator {
763        Expr::Call(call) => call.func.as_ref(),
764        other => other,
765    };
766    match target {
767        Expr::Name(name) => name.id.as_str() == "fixture",
768        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
769        _ => false,
770    }
771}
772
773/// `true` when a call is `patch(...)`, `patch.object(...)`, `patch.dict(...)`, or
774/// the same reached through a module (`mock.patch(...)`, `unittest.mock.patch`).
775fn is_patch_call(call: &ExprCall) -> bool {
776    match call.func.as_ref() {
777        Expr::Name(name) => name.id.as_str() == "patch",
778        Expr::Attribute(attr) => {
779            let name = attr.attr.as_str();
780            name == "patch"
781                || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
782        }
783        _ => false,
784    }
785}
786
787/// `true` when an attribute's base resolves to `patch` — the receiver of
788/// `patch.object` / `patch.dict`.
789fn attr_base_is_patch(expr: &Expr) -> bool {
790    match expr {
791        Expr::Name(name) => name.id.as_str() == "patch",
792        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
793        _ => false,
794    }
795}
796
797/// Message for the `no-constant-patch` lint.
798const 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)";
799
800/// Message for the `no-first-party-patch` lint (#42).
801const 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";
802
803/// The string-literal first argument of a `patch(...)` call — the dotted target
804/// like `"pkg.mod.attr"`. `None` when the first argument isn't a string literal
805/// (a non-literal target can't be classified deterministically).
806fn patch_string_target(call: &ExprCall) -> Option<&str> {
807    if let Some(Expr::Constant(constant)) = call.args.first() {
808        if let Constant::Str(target) = &constant.value {
809            return Some(target.as_str());
810        }
811    }
812    None
813}
814
815/// `true` when a `patch(...)` call's first string argument names a module-global
816/// UPPER_CASE constant, e.g. `patch("pkg.config.CACHE_DIR", …)`.
817fn patches_constant(call: &ExprCall) -> bool {
818    patch_string_target(call)
819        .and_then(|target| target.rsplit('.').next())
820        .is_some_and(is_upper_constant)
821}
822
823/// `true` when a patch `target`'s head dotted segment names the first-party
824/// package `pkg`, e.g. `target = "ourpkg.mod.fn"`, `pkg = "ourpkg"` (#42).
825fn patches_first_party(target: &str, pkg: &str) -> bool {
826    target
827        .split('.')
828        .next()
829        .is_some_and(|head| !head.is_empty() && head == pkg)
830}
831
832/// `true` for an ALL-CAPS constant name — letters uppercase, digits and
833/// underscores allowed, at least one letter (`CACHE_DIR`, `DEBUG`, `MAX_SIZE`).
834fn is_upper_constant(name: &str) -> bool {
835    !name.is_empty()
836        && name
837            .chars()
838            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
839        && name.chars().any(|c| c.is_ascii_uppercase())
840}
841
842/// Message for the `no-environ-mutation` lint.
843const ENVIRON_MUTATION_MSG: &str =
844    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
845
846/// `true` for the expression `os.environ`.
847fn is_os_environ(expr: &Expr) -> bool {
848    matches!(
849        expr,
850        Expr::Attribute(attr)
851            if attr.attr.as_str() == "environ"
852                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
853    )
854}
855
856/// `true` for `os.environ[...]` — a subscript of `os.environ`, the form used as
857/// an assignment or `del` target.
858fn is_os_environ_subscript(expr: &Expr) -> bool {
859    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
860}
861
862/// `true` for a mutating method call on `os.environ` (`os.environ.update(...)`
863/// and friends).
864fn is_environ_mutation_call(call: &ExprCall) -> bool {
865    matches!(
866        call.func.as_ref(),
867        Expr::Attribute(attr)
868            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
869    )
870}
871
872/// `true` for a `dict` method that mutates in place.
873fn is_environ_mutator(method: &str) -> bool {
874    matches!(
875        method,
876        "update" | "pop" | "setdefault" | "clear" | "popitem"
877    )
878}
879
880/// The 1-based line containing byte `offset` in `source`.
881fn line_of(source: &str, offset: TextSize) -> usize {
882    let offset = (u32::from(offset) as usize).min(source.len());
883    source.as_bytes()[..offset]
884        .iter()
885        .filter(|&&byte| byte == b'\n')
886        .count()
887        + 1
888}
889
890/// The dist's own top-level import package — the first-party root for
891/// `no-first-party-patch` (#42).
892///
893/// Walk up from `root` to the nearest `pyproject.toml`, read its `[project].name`,
894/// and [normalize](normalize_dist_name) it to an import name. Returns `None` when
895/// no `pyproject.toml` (with a `[project].name`) is found, so a tree with no
896/// declared package flags nothing rather than guess. The walk stops at a `.git`
897/// boundary so it can't escape the project into an unrelated `pyproject.toml`.
898fn first_party_package(root: &Path) -> Option<String> {
899    for dir in root.ancestors() {
900        let candidate = dir.join("pyproject.toml");
901        if candidate.is_file() {
902            return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
903        }
904        if dir.join(".git").exists() {
905            break;
906        }
907    }
908    None
909}
910
911/// `[project].name` from a `pyproject.toml`, if present and a string.
912fn read_project_name(path: &Path) -> Option<String> {
913    let contents = std::fs::read_to_string(path).ok()?;
914    let value: toml::Value = toml::from_str(&contents).ok()?;
915    value
916        .get("project")?
917        .get("name")?
918        .as_str()
919        .map(str::to_owned)
920}
921
922/// Normalize a distribution name to its import package name: lower-cased, with
923/// `-` and `.` mapped to `_` (PEP 503-flavoured — `My-Project` → `my_project`).
924fn normalize_dist_name(name: &str) -> String {
925    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
926}
927
928/// Recursively collect every Python file under `dir` matching `is_match` into `out`.
929fn collect_python_files(
930    dir: &Path,
931    out: &mut Vec<PathBuf>,
932    is_match: fn(&Path) -> bool,
933) -> Result<()> {
934    let entries =
935        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
936    for entry in entries {
937        let path = entry
938            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
939            .path();
940        if path.is_dir() {
941            collect_python_files(&path, out, is_match)?;
942        } else if is_match(&path) {
943            out.push(path);
944        }
945    }
946    Ok(())
947}
948
949/// `true` for a file the integration lints scan: `*_test.py`, legacy `test_*.py`,
950/// or `conftest.py` (where fixtures live).
951fn is_python_test_file(path: &Path) -> bool {
952    let name = path
953        .file_name()
954        .and_then(|n| n.to_str())
955        .unwrap_or_default();
956    name == "conftest.py"
957        || name.ends_with("_test.py")
958        || (name.starts_with("test_") && name.ends_with(".py"))
959}
960
961/// `true` for a colocated *unit* test the isolation rule scans: `*_test.py` or
962/// legacy `test_*.py`, but **not** `conftest.py` (fixtures, not a unit).
963fn is_python_unit_test_file(path: &Path) -> bool {
964    let name = path
965        .file_name()
966        .and_then(|n| n.to_str())
967        .unwrap_or_default();
968    name != "conftest.py"
969        && (name.ends_with("_test.py") || (name.starts_with("test_") && name.ends_with(".py")))
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975    use std::sync::atomic::{AtomicU64, Ordering};
976
977    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
978    struct TempDir(PathBuf);
979
980    impl TempDir {
981        fn new() -> Self {
982            static COUNTER: AtomicU64 = AtomicU64::new(0);
983            let dir = std::env::temp_dir().join(format!(
984                "tc-lint-{}-{}",
985                std::process::id(),
986                COUNTER.fetch_add(1, Ordering::Relaxed),
987            ));
988            std::fs::create_dir_all(&dir).unwrap();
989            TempDir(dir)
990        }
991
992        fn write(&self, name: &str, contents: &str) {
993            let path = self.0.join(name);
994            if let Some(parent) = path.parent() {
995                std::fs::create_dir_all(parent).unwrap();
996            }
997            std::fs::write(path, contents).unwrap();
998        }
999    }
1000
1001    impl Drop for TempDir {
1002        fn drop(&mut self) {
1003            let _ = std::fs::remove_dir_all(&self.0);
1004        }
1005    }
1006
1007    #[test]
1008    fn normalize_dist_name_maps_to_import_name() {
1009        assert_eq!(normalize_dist_name("My-Project"), "my_project");
1010        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1011        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
1012        assert_eq!(normalize_dist_name("myproject"), "myproject");
1013    }
1014
1015    /// Parse `src` (a single expression statement) and return its call.
1016    fn parse_call(src: &str) -> ExprCall {
1017        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1018        match suite.into_iter().next().expect("one statement") {
1019            ast::Stmt::Expr(stmt) => match *stmt.value {
1020                Expr::Call(call) => call,
1021                other => panic!("expected a call, got {other:?}"),
1022            },
1023            other => panic!("expected an expression statement, got {other:?}"),
1024        }
1025    }
1026
1027    #[test]
1028    fn patch_string_target_only_reads_string_literals() {
1029        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1030        assert_eq!(patch_string_target(&str_call), Some("pkg.mod.attr"));
1031        // A non-string literal (`patch(42)`), a name (`patch(target)`), and no args
1032        // all yield `None` — a non-literal target can't be classified.
1033        let int_call = parse_call("patch(42)\n");
1034        assert_eq!(patch_string_target(&int_call), None);
1035        let name_call = parse_call("patch(target)\n");
1036        assert_eq!(patch_string_target(&name_call), None);
1037        let empty_call = parse_call("patch()\n");
1038        assert_eq!(patch_string_target(&empty_call), None);
1039    }
1040
1041    #[test]
1042    fn patches_first_party_matches_head_segment() {
1043        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1044        assert!(patches_first_party("myproject", "myproject"));
1045        assert!(!patches_first_party("requests.get", "myproject"));
1046        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1047        assert!(!patches_first_party("", "myproject"));
1048        assert!(!patches_first_party(".leading", "myproject"));
1049    }
1050
1051    #[test]
1052    fn first_party_package_reads_pyproject_name() {
1053        let tree = TempDir::new();
1054        tree.write(
1055            "pyproject.toml",
1056            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1057        );
1058        // Normalized to the import name.
1059        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1060    }
1061
1062    #[test]
1063    fn first_party_package_is_none_without_a_project_name() {
1064        let tree = TempDir::new();
1065        // A pyproject with no `[project].name` — found, but no usable package.
1066        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1067        tree.write(".git", "");
1068        assert_eq!(first_party_package(&tree.0), None);
1069    }
1070
1071    #[test]
1072    fn first_party_package_is_none_when_absent() {
1073        // No pyproject.toml anywhere up the (temp) tree → nothing first-party.
1074        let tree = TempDir::new();
1075        assert_eq!(first_party_package(&tree.0), None);
1076    }
1077
1078    /// Run the unit-isolation visitor over `source` and return the flagged
1079    /// (un-mocked, non-UUT first-party) import displays.
1080    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1081        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1082        let mut visitor = UnitIsolationVisitor {
1083            source,
1084            first_party,
1085            base,
1086            type_checking_depth: 0,
1087            imports: Vec::new(),
1088            patch_targets: Vec::new(),
1089        };
1090        for stmt in suite {
1091            visitor.visit_stmt(stmt);
1092        }
1093        visitor
1094            .imports
1095            .iter()
1096            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1097            .map(|i| i.display.clone())
1098            .collect()
1099    }
1100
1101    #[test]
1102    fn import_head_and_last_segment() {
1103        assert_eq!(import_head("myproject.db.conn"), "myproject");
1104        assert_eq!(import_head("requests"), "requests");
1105        assert_eq!(last_segment("myproject.db.conn"), "conn");
1106        assert_eq!(last_segment("widget"), "widget");
1107    }
1108
1109    #[test]
1110    fn unit_under_test_base_strips_test_affixes() {
1111        assert_eq!(
1112            unit_under_test_base(Path::new("pkg/widget_test.py")),
1113            "widget"
1114        );
1115        assert_eq!(unit_under_test_base(Path::new("test_widget.py")), "widget");
1116        // A name without a test affix falls back to its stem.
1117        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1118    }
1119
1120    #[test]
1121    fn recognizes_python_unit_test_files() {
1122        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1123        assert!(is_python_unit_test_file(Path::new("test_widget.py")));
1124        // conftest holds fixtures, not a unit — excluded from unit isolation.
1125        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1126        assert!(!is_python_unit_test_file(Path::new("widget.py")));
1127    }
1128
1129    #[test]
1130    fn is_mocked_matches_symbol_last_segment_and_module_prefix() {
1131        let symbol = ImportRecord {
1132            display: "myproject.ledger".into(),
1133            line: 1,
1134            is_uut: false,
1135            symbols: vec!["record".into()],
1136            module: None,
1137        };
1138        // The consuming-module patch and the source patch both mock it.
1139        assert!(symbol.is_mocked(&["myproject.widget.record".into()]));
1140        assert!(symbol.is_mocked(&["myproject.ledger.record".into()]));
1141        assert!(!symbol.is_mocked(&["myproject.widget.other".into()]));
1142
1143        let module = ImportRecord {
1144            display: "myproject.db".into(),
1145            line: 1,
1146            is_uut: false,
1147            symbols: Vec::new(),
1148            module: Some("myproject.db".into()),
1149        };
1150        assert!(module.is_mocked(&["myproject.db.conn".into()])); // reaches into it
1151        assert!(module.is_mocked(&["myproject.db".into()])); // the module itself
1152        assert!(!module.is_mocked(&["myproject.dbx.y".into()])); // a different module
1153    }
1154
1155    #[test]
1156    fn visitor_flags_first_party_and_external_collaborators() {
1157        // The UUT is left alone; the first-party collaborator and the third-party
1158        // import are both flagged (slice 3 broadened the rule to external deps).
1159        let found = unmocked(
1160            "widget",
1161            "myproject",
1162            "from myproject.widget import build\n\
1163             from myproject.ledger import record\n\
1164             import requests\n",
1165        );
1166        assert_eq!(
1167            found,
1168            vec!["myproject.ledger".to_string(), "requests".to_string()]
1169        );
1170    }
1171
1172    #[test]
1173    fn visitor_clears_a_mocked_collaborator() {
1174        // The imported `record` is patched (consuming-module name) → not flagged.
1175        let found = unmocked(
1176            "widget",
1177            "myproject",
1178            "from myproject.ledger import record\npatch(\"myproject.widget.record\")\n",
1179        );
1180        assert!(found.is_empty(), "got: {found:?}");
1181    }
1182
1183    #[test]
1184    fn visitor_handles_module_and_relative_imports() {
1185        // A first-party module import, not the UUT, un-mocked → flagged.
1186        assert_eq!(
1187            unmocked("widget", "myproject", "import myproject.db\n"),
1188            vec!["myproject.db".to_string()]
1189        );
1190        // `import myproject.db` reached by a patch → mocked.
1191        assert!(unmocked(
1192            "widget",
1193            "myproject",
1194            "import myproject.db\npatch(\"myproject.db.connect\")\n"
1195        )
1196        .is_empty());
1197        // Relative imports are first-party; `from . import widget` is the UUT.
1198        assert_eq!(
1199            unmocked("widget", "myproject", "from .ledger import record\n"),
1200            vec![".ledger".to_string()]
1201        );
1202        assert_eq!(
1203            unmocked(
1204                "widget",
1205                "myproject",
1206                "from . import ledger\nfrom . import widget\n"
1207            ),
1208            vec![".ledger".to_string()]
1209        );
1210    }
1211
1212    #[test]
1213    fn visitor_skips_type_checking_imports() {
1214        // A first-party import guarded by TYPE_CHECKING is type-only — not a runtime
1215        // collaborator; the runtime `else` import is still seen.
1216        let found = unmocked(
1217            "widget",
1218            "myproject",
1219            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
1220        );
1221        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1222    }
1223
1224    #[test]
1225    fn is_checked_import_classifies_origins() {
1226        assert!(is_checked_import("myproject", "myproject")); // first-party
1227        assert!(!is_checked_import("pytest", "myproject")); // test framework
1228        assert!(!is_checked_import("_pytest", "myproject"));
1229        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
1230        assert!(is_checked_import("socket", "myproject"));
1231        assert!(!is_checked_import("json", "myproject")); // pure stdlib
1232        assert!(!is_checked_import("dataclasses", "myproject"));
1233        assert!(is_checked_import("requests", "myproject")); // third-party
1234        assert!(is_checked_import("stripe", "myproject"));
1235        // dual-nature stdlib heads stay pure (not flagged) — caught by patching, not import
1236        assert!(!is_checked_import("os", "myproject"));
1237        assert!(!is_checked_import("pathlib", "myproject"));
1238        assert!(!is_checked_import("datetime", "myproject"));
1239    }
1240
1241    #[test]
1242    fn visitor_flags_external_collaborators() {
1243        // Third-party + effectful stdlib are flagged; pure stdlib and the framework aren't.
1244        let found = unmocked(
1245            "widget",
1246            "myproject",
1247            "import requests\nimport subprocess\nimport json\nimport pytest\n",
1248        );
1249        assert_eq!(found.len(), 2, "got: {found:?}");
1250        assert!(found.contains(&"requests".to_string()));
1251        assert!(found.contains(&"subprocess".to_string()));
1252    }
1253
1254    #[test]
1255    fn visitor_type_checking_variants_and_plain_if() {
1256        // `typing.TYPE_CHECKING` (attribute form) guards type-only imports — both
1257        // the `from`-import and the plain module import are skipped.
1258        assert!(unmocked(
1259            "widget",
1260            "myproject",
1261            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
1262        )
1263        .is_empty());
1264        // A plain `if` (not TYPE_CHECKING) is walked normally — its first-party
1265        // import is still a collaborator.
1266        assert_eq!(
1267            unmocked(
1268                "widget",
1269                "myproject",
1270                "if ready == 1:\n    from myproject.ledger import record\n"
1271            ),
1272            vec!["myproject.ledger".to_string()]
1273        );
1274    }
1275
1276    #[test]
1277    fn find_unit_isolation_without_pyproject_reports_nothing() {
1278        // No declared package → no first-party collaborators (the early return).
1279        let tree = TempDir::new();
1280        tree.write("widget_test.py", "from myproject.ledger import record\n");
1281        tree.write(".git", "");
1282        assert!(find_unit_isolation_violations(&tree.0)
1283            .expect("a readable tree should succeed")
1284            .is_empty());
1285    }
1286
1287    #[test]
1288    fn find_unit_isolation_walks_subdirs_and_flags() {
1289        let tree = TempDir::new();
1290        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1291        // A nested unit test — exercises the directory recursion.
1292        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1293        let found =
1294            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1295        assert_eq!(found.len(), 1, "got: {found:?}");
1296        assert_eq!(found[0].rule, "unmocked-collaborator");
1297        assert!(found[0].message.contains("myproject.ledger"));
1298    }
1299
1300    #[test]
1301    fn recognizes_python_test_files() {
1302        assert!(is_python_test_file(Path::new("widget_test.py")));
1303        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1304        assert!(is_python_test_file(Path::new("test_widget.py")));
1305        assert!(is_python_test_file(Path::new("conftest.py")));
1306    }
1307
1308    #[test]
1309    fn ignores_non_test_files() {
1310        assert!(!is_python_test_file(Path::new("widget.py")));
1311        assert!(!is_python_test_file(Path::new("conftest.pyi")));
1312        assert!(!is_python_test_file(Path::new("README.md")));
1313        assert!(!is_python_test_file(Path::new("testing.py")));
1314    }
1315
1316    #[test]
1317    fn line_of_counts_newlines() {
1318        let src = "a\nb\nc\n";
1319        assert_eq!(line_of(src, TextSize::from(0)), 1);
1320        assert_eq!(line_of(src, TextSize::from(2)), 2);
1321        assert_eq!(line_of(src, TextSize::from(4)), 3);
1322    }
1323
1324    #[test]
1325    fn recognizes_environ_mutators() {
1326        assert!(is_environ_mutator("update"));
1327        assert!(is_environ_mutator("pop"));
1328        assert!(is_environ_mutator("clear"));
1329        assert!(!is_environ_mutator("get"));
1330        assert!(!is_environ_mutator("keys"));
1331    }
1332
1333    #[test]
1334    fn recognizes_upper_constants() {
1335        assert!(is_upper_constant("CACHE_DIR"));
1336        assert!(is_upper_constant("DEBUG"));
1337        assert!(is_upper_constant("MAX_2"));
1338        assert!(!is_upper_constant("cache_dir"));
1339        assert!(!is_upper_constant("CacheDir"));
1340        assert!(!is_upper_constant("fetch"));
1341        assert!(!is_upper_constant(""));
1342        assert!(!is_upper_constant("_"));
1343        assert!(!is_upper_constant("123"));
1344    }
1345}