Skip to main content

car_server_core/coder/
overlap.rs

1//! Does this session's diff touch the files its own contract executes?
2//!
3//! The outcome contract is the automated trust boundary, and its *derivation* is
4//! already well hardened: [`OutcomeContract::validate`] rejects assertion-less
5//! checks, toolchain-only no-ops and placeholder names; `strip_exit_masking_pipes`
6//! blocks pipelines that always exit 0; and the derivation prompt encodes a
7//! dogfooded anti-hacking rule about `-k` filters and bespoke reproduction
8//! snippets.
9//!
10//! What remains is that the agent can edit the test files the contract runs.
11//! `coder::policy` addresses that deliberately and correctly:
12//!
13//! > "Deliberately scoped to what is mechanically decidable. `conftest.py`,
14//! > `pyproject.toml`, `tox.ini`, and `setup.cfg` are NOT denied: editing them is
15//! > often the actual task, and no matcher can separate 'add a fixture' from
16//! > 'change how tests run'."
17//!
18//! **This module does not change that.** Denying test-adjacent edits would break
19//! the many sessions where editing tests *is* the task. But denial is not the
20//! only option: *disclosure* is mechanically decidable and was simply absent. If
21//! check `run_failing_test` runs `pytest tests/test_x.py` and the diff modifies
22//! `tests/test_x.py`, that is a fact computable from data already in hand — and
23//! it was never computed, let alone surfaced at the approval gate.
24//!
25//! ## Why the extraction errs toward flagging
26//!
27//! Pulling paths out of a shell command is a heuristic; there is no parse that
28//! is both general and exact. So it follows `policy.rs`'s established posture —
29//! conservative token matching, biased toward reporting — because the costs are
30//! wildly asymmetric: a false positive costs one line of disclosure that a human
31//! dismisses in a second, while a false negative silently restores the status
32//! quo this exists to fix.
33
34use serde::{Deserialize, Serialize};
35
36use super::contract::{ContractCheck, OutcomeContract};
37
38/// One check whose command references paths the session's diff also touched.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct CheckOverlap {
41    /// The contract check that executes the path(s).
42    pub check: String,
43    /// Changed paths this check's command references, sorted, deduped.
44    pub paths: Vec<String>,
45}
46
47/// Source-ish extensions worth treating as a path even without a `/`, so a
48/// bare `pytest test_x.py` at the repo root is still matched.
49const PATHY_EXTENSIONS: [&str; 12] = [
50    ".py", ".rs", ".ts", ".tsx", ".js", ".jsx", ".go", ".java", ".rb", ".swift", ".kt", ".c",
51];
52
53/// Strip shell noise from one token so it can be compared to a repo path:
54/// surrounding quotes, a pytest `::node_id` selector, a trailing `:line`, and
55/// `./` prefixes.
56fn normalize_token(raw: &str) -> Option<String> {
57    let mut t = raw.trim();
58    // Quotes may wrap the whole token (`"tests/test_x.py"`), including the
59    // combined form `'tests/test_x.py::test_a'`.
60    t = t.trim_matches(|c| c == '"' || c == '\'');
61    // pytest node ids and `file:line` references both name a real file first.
62    if let Some((head, _)) = t.split_once("::") {
63        t = head;
64    }
65    // Only split a trailing `:<digits>` — a Windows-style `C:\...` or a URL
66    // must not be mangled.
67    if let Some((head, tail)) = t.rsplit_once(':') {
68        if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
69            t = head;
70        }
71    }
72    t = t.trim_start_matches("./");
73    // Decide path-likeness BEFORE trimming the trailing slash: `tests/` is
74    // obviously a path, but the trimmed `tests` has neither a separator nor an
75    // extension, so testing after the trim silently dropped every bare
76    // directory argument (`pytest tests/`, `ruff check src/`) — the exact
77    // reference that most needs matching, since it covers a whole tree.
78    let lower = t.to_ascii_lowercase();
79    let pathy = t.contains('/') || PATHY_EXTENSIONS.iter().any(|e| lower.ends_with(e));
80    t = t.trim_end_matches('/');
81    if t.is_empty() || t.starts_with('-') {
82        return None;
83    }
84    pathy.then(|| t.to_string())
85}
86
87/// Path-like tokens referenced by a check's command.
88///
89/// Deliberately dumb: split on shell word boundaries and keep what looks like a
90/// path. It does not resolve variables, globs, or `&&`-chained subcommands
91/// beyond treating them as separators — anything cleverer would be guessing.
92pub fn referenced_paths(command: &str) -> Vec<String> {
93    let mut out: Vec<String> = command
94        .split(|c: char| c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')' | '&'))
95        .filter_map(normalize_token)
96        .collect();
97    out.sort();
98    out.dedup();
99    out
100}
101
102/// Whether `changed` is the same file as, or lives under, `referenced`.
103///
104/// Directory containment is what makes `pytest tests/` match a change to
105/// `tests/test_x.py`. The `/` guard keeps `tests_helpers/x.py` from matching a
106/// reference to `tests`.
107fn covers(referenced: &str, changed: &str) -> bool {
108    changed == referenced || changed.starts_with(&format!("{referenced}/"))
109}
110
111/// Which of `changed_paths` each contract check executes.
112///
113/// Returns only checks with at least one overlapping path, so an empty result
114/// means "the diff touched nothing the contract runs".
115pub fn contract_overlap(contract: &OutcomeContract, changed_paths: &[String]) -> Vec<CheckOverlap> {
116    contract
117        .checks
118        .iter()
119        .filter_map(|check| {
120            let overlap = overlapping_paths(check, changed_paths);
121            (!overlap.is_empty()).then(|| CheckOverlap {
122                check: check.name.clone(),
123                paths: overlap,
124            })
125        })
126        .collect()
127}
128
129fn overlapping_paths(check: &ContractCheck, changed_paths: &[String]) -> Vec<String> {
130    let referenced = referenced_paths(&check.command);
131    let mut hits: Vec<String> = changed_paths
132        .iter()
133        .filter(|changed| referenced.iter().any(|r| covers(r, changed)))
134        .cloned()
135        .collect();
136    hits.sort();
137    hits.dedup();
138    hits
139}
140
141/// One-line disclosure for the approval surface. `None` when nothing overlaps.
142///
143/// Phrased as a fact, not an accusation: editing the tests is frequently the
144/// task, and the human at the gate is the one positioned to judge which case
145/// this is.
146pub fn disclosure(overlap: &[CheckOverlap]) -> Option<String> {
147    if overlap.is_empty() {
148        return None;
149    }
150    let parts: Vec<String> = overlap
151        .iter()
152        .map(|o| format!("{} (run by check `{}`)", o.paths.join(", "), o.check))
153        .collect();
154    Some(format!(
155        "This session modified files that its own contract executes: {}. \
156         That is often legitimate — editing tests is frequently the task — but it \
157         means those checks are not an independent verdict on this change.",
158        parts.join("; ")
159    ))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn check(name: &str, command: &str) -> ContractCheck {
167        ContractCheck {
168            name: name.into(),
169            command: command.into(),
170            expect_exit_zero: true,
171            output_contains: None,
172            timeout_secs: 120,
173            baseline: false,
174            differential: None,
175        }
176    }
177
178    fn contract(checks: Vec<ContractCheck>) -> OutcomeContract {
179        OutcomeContract {
180            allow_credentials: false,
181            description: "d".into(),
182            checks,
183        }
184    }
185
186    #[test]
187    fn extracts_paths_and_ignores_flags_and_bare_words() {
188        let paths = referenced_paths("python -m pytest -q tests/test_x.py --maxfail=1");
189        assert_eq!(paths, vec!["tests/test_x.py"]);
190    }
191
192    #[test]
193    fn strips_pytest_node_ids_quotes_and_line_suffixes() {
194        assert_eq!(
195            referenced_paths("pytest 'tests/test_x.py::test_a'"),
196            vec!["tests/test_x.py"]
197        );
198        assert_eq!(
199            referenced_paths("pytest \"tests/test_x.py\""),
200            vec!["tests/test_x.py"]
201        );
202        assert_eq!(referenced_paths("vet src/main.rs:42"), vec!["src/main.rs"]);
203    }
204
205    #[test]
206    fn a_bare_source_file_counts_even_without_a_separator() {
207        assert_eq!(referenced_paths("pytest test_x.py"), vec!["test_x.py"]);
208        // ...but a plain word is not a path, or every command would "reference"
209        // its own program name.
210        assert!(referenced_paths("cargo test").is_empty());
211    }
212
213    #[test]
214    fn splits_on_shell_operators_so_chained_commands_are_covered() {
215        let paths = referenced_paths("cargo build && pytest tests/test_x.py | tee out.log");
216        assert!(paths.contains(&"tests/test_x.py".to_string()));
217    }
218
219    /// The issue's worked example: a check runs `tests/test_x.py` and the diff
220    /// edits that file.
221    #[test]
222    fn a_diff_touching_a_contract_executed_file_is_flagged() {
223        let c = contract(vec![check("run_failing_test", "pytest tests/test_x.py")]);
224        let changed = vec!["tests/test_x.py".to_string(), "src/lib.rs".to_string()];
225
226        let overlap = contract_overlap(&c, &changed);
227        assert_eq!(
228            overlap,
229            vec![CheckOverlap {
230                check: "run_failing_test".into(),
231                paths: vec!["tests/test_x.py".into()],
232            }]
233        );
234        assert!(disclosure(&overlap).unwrap().contains("tests/test_x.py"));
235    }
236
237    /// ...and the other half of the acceptance criterion: a session that only
238    /// edits `src/` is not flagged.
239    #[test]
240    fn a_diff_touching_only_source_is_not_flagged() {
241        let c = contract(vec![check("run_failing_test", "pytest tests/test_x.py")]);
242        let changed = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
243        assert!(contract_overlap(&c, &changed).is_empty());
244        assert!(disclosure(&[]).is_none());
245    }
246
247    /// A directory reference covers files beneath it — `pytest tests/` does run
248    /// `tests/test_x.py`.
249    #[test]
250    fn a_directory_reference_covers_files_under_it() {
251        let c = contract(vec![check("suite", "pytest tests/")]);
252        let changed = vec!["tests/unit/test_x.py".to_string()];
253        assert_eq!(
254            contract_overlap(&c, &changed)[0].paths,
255            vec!["tests/unit/test_x.py"]
256        );
257    }
258
259    /// ...but only on a real segment boundary, or a sibling directory with a
260    /// shared prefix would be a false positive.
261    #[test]
262    fn a_prefix_that_is_not_a_path_segment_does_not_match() {
263        let c = contract(vec![check("suite", "pytest tests/")]);
264        let changed = vec!["tests_helpers/util.py".to_string()];
265        assert!(contract_overlap(&c, &changed).is_empty());
266    }
267
268    #[test]
269    fn several_checks_each_report_their_own_paths() {
270        let c = contract(vec![
271            check("unit", "pytest tests/test_x.py"),
272            check("lint", "ruff check src/"),
273            check("unrelated", "cargo build"),
274        ]);
275        let changed = vec![
276            "tests/test_x.py".to_string(),
277            "src/app.py".to_string(),
278            "README.md".to_string(),
279        ];
280
281        let overlap = contract_overlap(&c, &changed);
282        assert_eq!(
283            overlap.len(),
284            2,
285            "the `cargo build` check references no path"
286        );
287        assert_eq!(overlap[0].check, "unit");
288        assert_eq!(overlap[0].paths, vec!["tests/test_x.py"]);
289        assert_eq!(overlap[1].check, "lint");
290        assert_eq!(overlap[1].paths, vec!["src/app.py"]);
291    }
292
293    #[test]
294    fn disclosure_reads_as_a_fact_not_an_accusation() {
295        let overlap = vec![CheckOverlap {
296            check: "unit".into(),
297            paths: vec!["tests/test_x.py".into()],
298        }];
299        let text = disclosure(&overlap).unwrap();
300        assert!(text.contains("often legitimate"));
301        assert!(text.contains("not an independent verdict"));
302    }
303}