testing_conventions/co_change.rs
1//! The commit-scoped `co-change` check (#33): a source file that changed in a
2//! git diff must change its colocated test too.
3//!
4//! Convention: when a source file is **modified** (e.g. a function removed from
5//! `foo.py`) or **deleted** in a commit range, its colocated test — the #15/#18
6//! pairing, `foo.py` → `foo_test.py`, `foo.ts` → `foo.test.ts` — must also be in
7//! that diff. This catches edits and removals that leave the test silently stale.
8//! *Added* source files are not subjects: brand-new code is the coverage floor's
9//! job, not this one.
10//!
11//! [`stale_sources`] walks `git diff --name-status <base>...HEAD` for a
12//! [`Language`] and returns every changed source file whose colocated test did
13//! not co-change. A file listed in the config `exempt` table (rule `co-change`)
14//! is a deliberate, reason-required omission. Rust has no sibling test file —
15//! units are inline `#[cfg(test)]` in the same `.rs` — so the rule is
16//! Python/TypeScript only (the CLI rejects `--language rust`).
17
18use std::collections::BTreeSet;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22use anyhow::{bail, Context, Result};
23
24use crate::colocated_test::Language;
25
26/// Every source file changed in `repo`'s `<base>...HEAD` diff whose colocated
27/// test did not also change — the stale-test risks — sorted for deterministic
28/// output.
29///
30/// A source file is a subject when it was **modified** (and still holds code) or
31/// **deleted** in the diff; an **added** file is not (new code is the coverage
32/// floor's concern). A subject whose `repo`-relative path is in `exempt` is a
33/// deliberate omission and is skipped. Everything else must have its colocated
34/// test (`foo.py` → `foo_test.py`, per `language`) somewhere in the same diff.
35///
36/// Returns an error if `git diff` fails — e.g. `base` names no resolvable ref —
37/// so an un-diffable range surfaces rather than silently passing as "clean".
38pub fn stale_sources(
39 repo: &Path,
40 base: &str,
41 language: Language,
42 exempt: &BTreeSet<String>,
43) -> Result<Vec<PathBuf>> {
44 let entries = changed_entries(repo, base)?;
45 // Every changed path, so a subject's expected test is a set lookup rather
46 // than a second walk of the diff.
47 let changed: BTreeSet<&str> = entries.iter().map(|(_, path)| path.as_str()).collect();
48
49 let mut stale = Vec::new();
50 for (status, rel) in &entries {
51 let path = Path::new(rel);
52 // A test file, a support file (Python `conftest.py`), or anything this
53 // language doesn't track is never a co-change subject.
54 if !language.tracks(path) || language.is_test(path) || language.is_support(path) {
55 continue;
56 }
57 // Only an edit or a removal can leave a test stale; a brand-new source is
58 // the coverage floor's concern, not this rule's.
59 let is_subject = match status {
60 Status::Modified => {
61 // An empty / comment-only file holds no logic, so editing it needs
62 // no test co-change — consistent with the colocated-test rule.
63 let contents = std::fs::read_to_string(repo.join(path))
64 .with_context(|| format!("reading changed source `{rel}`"))?;
65 language.has_code(&contents)
66 }
67 Status::Deleted => true,
68 Status::Other => false,
69 };
70 if !is_subject || exempt.contains(rel) {
71 continue;
72 }
73 let expected = language
74 .expected_test_path(path)
75 .to_string_lossy()
76 .replace('\\', "/");
77 if !changed.contains(expected.as_str()) {
78 stale.push(path.to_path_buf());
79 }
80 }
81 stale.sort();
82 Ok(stale)
83}
84
85/// The diff status of a changed file, narrowed to what the rule acts on.
86enum Status {
87 /// `M` — content changed; a subject if it still holds code.
88 Modified,
89 /// `D` — removed; always a subject (its test should go too).
90 Deleted,
91 /// `A` (added) and the rest (`T`, …) — not a co-change subject.
92 Other,
93}
94
95impl Status {
96 /// The status from a `git diff --name-status` status field. With
97 /// `--no-renames` it is a single letter, so only the first char matters.
98 fn from_code(code: &str) -> Status {
99 match code.chars().next() {
100 Some('M') => Status::Modified,
101 Some('D') => Status::Deleted,
102 _ => Status::Other,
103 }
104 }
105}
106
107/// The status + `repo`-relative path of every file changed in `<base>...HEAD`,
108/// via `git diff --name-status`.
109///
110/// `<base>...HEAD` is the merge-base diff — the changes this branch introduced
111/// (what a PR shows), not whatever else moved on `base`. Rename detection is off
112/// (`--no-renames`), so a rename shows as a delete + an add (each its own line of
113/// `<status>\t<path>`) and the deleted source is still held to its test;
114/// `--relative` scopes the diff to `repo` and reports paths relative to it.
115fn changed_entries(repo: &Path, base: &str) -> Result<Vec<(Status, String)>> {
116 let range = format!("{base}...HEAD");
117 let output = Command::new("git")
118 .current_dir(repo)
119 .args([
120 "diff",
121 "--name-status",
122 "--no-renames",
123 "--relative",
124 &range,
125 ])
126 .output()
127 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
128 if !output.status.success() {
129 bail!(
130 "`git diff {range}` failed in `{}`: {}",
131 repo.display(),
132 String::from_utf8_lossy(&output.stderr).trim()
133 );
134 }
135 let stdout = String::from_utf8_lossy(&output.stdout);
136 let mut entries = Vec::new();
137 for line in stdout.lines() {
138 // `<status>\t<path>` — the status is a single letter with `--no-renames`.
139 if let Some((status, path)) = line.split_once('\t') {
140 let path = path.trim_end_matches('\r').replace('\\', "/");
141 entries.push((Status::from_code(status), path));
142 }
143 }
144 Ok(entries)
145}