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