testing_conventions/co_change.rs
1//! The commit-scoped `co-change` check: 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
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. A **deletion** is a subject only if the source *had* a
10//! colocated test in the base tree — a package barrel (`__init__.py`, `index.ts`)
11//! with no sibling test can be deleted without one appearing in the diff, so it is
12//! not flagged and needs no exemption.
13//!
14//! [`stale_sources`] walks `git diff --name-status <base>...HEAD` for a
15//! [`Language`] and returns every changed source file whose colocated test did
16//! not co-change. A file listed in the config `exempt` table (rule `co-change`)
17//! is a deliberate, reason-required omission. Rust has no sibling test file —
18//! units are inline `#[cfg(test)]` in the same `.rs` — so the rule is
19//! Python/TypeScript only (the CLI rejects `--language rust`).
20
21use std::collections::BTreeSet;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use anyhow::{bail, Context, Result};
26
27use crate::colocated_test::Language;
28
29/// Every source file changed in `repo`'s `<base>...HEAD` diff whose colocated
30/// test did not also change — the stale-test risks — sorted for deterministic
31/// output.
32///
33/// A source file is a subject when it was **modified** (and still holds code), or
34/// **deleted** while it *had* a colocated test in the base tree (the test now at
35/// risk of being orphaned); an **added** file is not (new code is the coverage
36/// floor's concern), nor is a deleted barrel that never had a sibling test.
37/// A subject whose `repo`-relative path is in `exempt` is a deliberate omission and
38/// is skipped. Everything else must have its colocated test (`foo.py` →
39/// `foo_test.py`, per `language`) somewhere in the same diff.
40///
41/// Returns an error if `git diff` fails — e.g. `base` names no resolvable ref —
42/// so an un-diffable range surfaces rather than silently passing as "clean".
43pub fn stale_sources(
44 repo: &Path,
45 base: &str,
46 language: Language,
47 exempt: &BTreeSet<String>,
48) -> Result<Vec<PathBuf>> {
49 let entries = changed_entries(repo, base)?;
50 // Every changed path, so a subject's expected test is a set lookup rather
51 // than a second walk of the diff.
52 let changed: BTreeSet<&str> = entries.iter().map(|(_, path)| path.as_str()).collect();
53 // `<package root>/tests/` belongs to the suite tiers (integration / e2e),
54 // so nothing under it is a co-change subject.
55 let suite_tests = match language {
56 Language::Python => crate::tiers::suite_tests_dir(repo, "pyproject.toml"),
57 Language::TypeScript => crate::tiers::suite_tests_dir(repo, "package.json"),
58 Language::Rust => None,
59 };
60
61 let mut stale = Vec::new();
62 for (status, rel) in &entries {
63 let path = Path::new(rel);
64 // A test file, a support file (Python `conftest.py`), or anything this
65 // language doesn't track is never a co-change subject.
66 if !language.tracks(path) || language.is_test(path) || language.is_support(path) {
67 continue;
68 }
69 if suite_tests
70 .as_ref()
71 .is_some_and(|tests| repo.join(path).starts_with(tests))
72 {
73 continue;
74 }
75 let expected = language
76 .expected_test_path(path)
77 .to_string_lossy()
78 .replace('\\', "/");
79 // Only an edit or a removal can leave a test stale; a brand-new source is
80 // the coverage floor's concern, not this rule's.
81 let is_subject = match status {
82 Status::Modified => {
83 // An empty / comment-only file holds no logic, so editing it needs
84 // no test co-change — consistent with the colocated-test rule.
85 let contents = std::fs::read_to_string(repo.join(path))
86 .with_context(|| format!("reading changed source `{rel}`"))?;
87 language.has_code(&contents)
88 }
89 // A deletion is a subject only if the source *had* a colocated test in
90 // the base tree — the test now at risk of being orphaned. A source that
91 // never had a sibling test (a package barrel: `__init__.py`, `index.ts`)
92 // can be removed without a test appearing in the diff, so it is not
93 // flagged and needs no exemption to delete it. HEAD can't answer
94 // this — the file is gone — so we ask `base`.
95 Status::Deleted => test_exists_in_base(repo, base, &expected)?,
96 Status::Other => false,
97 };
98 if !is_subject || exempt.contains(rel) {
99 continue;
100 }
101 if !changed.contains(expected.as_str()) {
102 stale.push(path.to_path_buf());
103 }
104 }
105 stale.sort();
106 Ok(stale)
107}
108
109/// The diff status of a changed file, narrowed to what the rule acts on.
110enum Status {
111 /// `M` — content changed; a subject if it still holds code.
112 Modified,
113 /// `D` — removed; a subject only if the source had a colocated test in base
114 /// (its test should go too), never for a barrel that never had one.
115 Deleted,
116 /// `A` (added) and the rest (`T`, …) — not a co-change subject.
117 Other,
118}
119
120impl Status {
121 /// The status from a `git diff --name-status` status field. With
122 /// `--no-renames` it is a single letter, so only the first char matters.
123 fn from_code(code: &str) -> Status {
124 match code.chars().next() {
125 Some('M') => Status::Modified,
126 Some('D') => Status::Deleted,
127 _ => Status::Other,
128 }
129 }
130}
131
132/// `true` when `rel` (a `repo`-relative path) exists as a blob in the `base` tree.
133///
134/// Used to tell a deleted source that once had a colocated test — its test should
135/// be removed too, so a stale leftover is worth flagging — from a barrel that never
136/// had one, which can be deleted without a test co-changing. Runs
137/// `git cat-file -e <base>:./<rel>`: the `./` makes git resolve the path relative to
138/// `repo` (the diff's `--relative` root), matching the paths [`changed_entries`]
139/// returns, rather than the repo's top level. A missing blob exits non-zero (→
140/// `false`); the `base` ref itself already resolved for [`changed_entries`], so a
141/// non-zero exit here means "no such path in base", not a bad ref.
142fn test_exists_in_base(repo: &Path, base: &str, rel: &str) -> Result<bool> {
143 let spec = format!("{base}:./{rel}");
144 let output = Command::new("git")
145 .current_dir(repo)
146 .args(["cat-file", "-e", &spec])
147 .output()
148 .with_context(|| format!("running `git cat-file` in `{}`", repo.display()))?;
149 Ok(output.status.success())
150}
151
152/// The status + `repo`-relative path of every file changed in `<base>...HEAD`,
153/// via `git diff --name-status`.
154///
155/// `<base>...HEAD` is the merge-base diff — the changes this branch introduced
156/// (what a PR shows), not whatever else moved on `base`. Rename detection is off
157/// (`--no-renames`), so a rename shows as a delete + an add (each its own line of
158/// `<status>\t<path>`) and the deleted source is still held to its test;
159/// `--relative` scopes the diff to `repo` and reports paths relative to it.
160fn changed_entries(repo: &Path, base: &str) -> Result<Vec<(Status, String)>> {
161 let range = format!("{base}...HEAD");
162 // `-c core.quotepath=off --no-ext-diff` pins the walk against the caller's git
163 // config (#392): a non-ASCII path is emitted raw rather than octal-escaped, so a
164 // `Modified` `src/föö.py` keys correctly (and reads back as a real file) instead of
165 // hard-erroring; a configured external differ is blocked. `--name-status` carries no
166 // `a/`/`b/` prefix, so no prefix pinning is needed here.
167 let output = Command::new("git")
168 .current_dir(repo)
169 .args([
170 "-c",
171 "core.quotepath=off",
172 "diff",
173 "--name-status",
174 "--no-ext-diff",
175 "--no-renames",
176 "--relative",
177 &range,
178 ])
179 .output()
180 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
181 if !output.status.success() {
182 bail!(
183 "`git diff {range}` failed in `{}`: {}",
184 repo.display(),
185 String::from_utf8_lossy(&output.stderr).trim()
186 );
187 }
188 let stdout = String::from_utf8_lossy(&output.stdout);
189 let mut entries = Vec::new();
190 for line in stdout.lines() {
191 // `<status>\t<path>` — the status is a single letter with `--no-renames`.
192 if let Some((status, path)) = line.split_once('\t') {
193 // Decode a residual C-quoted path (a name with a `"` / backslash / control
194 // byte still comes quoted even with `core.quotepath=off`) before normalizing
195 // separators (#392).
196 let path = crate::patch_coverage::unquote_c_path(path.trim_end_matches('\r'));
197 let path = path.replace('\\', "/");
198 entries.push((Status::from_code(status), path));
199 }
200 }
201 Ok(entries)
202}