Skip to main content

speccoder_runtime/bug/
unchanged.rs

1//! The pass-to-pass set, chosen deterministically once the attempt's diff
2//! is known: baseline-green tests in test files that reference a changed
3//! source file (per toolchain), plus each *Unchanged* statement's test. No
4//! model decides what counts as "nearby" (principle IV).
5
6use speccoder_core::bug::{Member, MemberFinal, PassToPass, PassToPassState};
7use speccoder_core::verify::{TestOutcome, TestResult};
8
9/// Why `test_file` (its `content`) is selected for `changed`, if it is.
10#[must_use]
11pub fn rule(
12    changed: &str,
13    test_file: &str,
14    content: &str,
15    crate_name: Option<&str>,
16) -> Option<String> {
17    let path = std::path::Path::new(changed);
18    let ext = path
19        .extension()
20        .and_then(|e| e.to_str())
21        .unwrap_or_default();
22    if test_file == changed {
23        return content
24            .contains("#[cfg(test)]")
25            .then(|| "same_file".to_owned());
26    }
27    match ext {
28        "rs" => {
29            let rel = changed.strip_prefix("src/")?;
30            let module: Vec<&str> = rel
31                .trim_end_matches(".rs")
32                .split('/')
33                .filter(|m| !matches!(*m, "lib" | "main" | "mod"))
34                .collect();
35            let name = crate_name.map(|c| c.replace('-', "_"));
36            let path = module.join("::");
37            let named = |root: &str| {
38                if path.is_empty() {
39                    format!("{root}::")
40                } else {
41                    format!("{root}::{path}")
42                }
43            };
44            let hit = name.as_deref().is_some_and(|n| content.contains(&named(n)))
45                || (test_file.starts_with("src/") && content.contains(&named("crate")));
46            hit.then(|| format!("references:crate::{path}"))
47        }
48        "py" => {
49            let module = changed.trim_end_matches(".py").replace('/', ".");
50            let module = module.trim_end_matches(".__init__");
51            let (parent, leaf) = module.rsplit_once('.').unwrap_or(("", module));
52            let hit = content.lines().any(|l| {
53                let l = l.trim();
54                l.starts_with(&format!("import {module}"))
55                    || l.starts_with(&format!("from {module} import"))
56                    || (!parent.is_empty()
57                        && l.starts_with(&format!("from {parent} import"))
58                        && l.split(['(', ',', ' ']).any(|w| w.trim() == leaf))
59            });
60            hit.then(|| format!("imports:{module}"))
61        }
62        "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
63            let target = changed.rsplit_once('.').map_or(changed, |(a, _)| a);
64            let base = std::path::Path::new(test_file)
65                .parent()
66                .map(|p| p.to_string_lossy().into_owned())
67                .unwrap_or_default();
68            let hit = specifiers(content).iter().any(|s| {
69                s.starts_with('.')
70                    && normalise(&format!("{base}/{s}"))
71                        .trim_end_matches(".js")
72                        .trim_end_matches(".ts")
73                        == target
74            });
75            hit.then(|| format!("imports:{changed}"))
76        }
77        "go" => {
78            let dir = path
79                .parent()
80                .map(|p| p.to_string_lossy().into_owned())
81                .unwrap_or_default();
82            let tdir = std::path::Path::new(test_file)
83                .parent()
84                .map(|p| p.to_string_lossy().into_owned())
85                .unwrap_or_default();
86            (test_file.ends_with("_test.go") && dir == tdir).then(|| format!("package:{dir}"))
87        }
88        _ => None,
89    }
90}
91
92/// The module specifiers a JS/TS file imports.
93fn specifiers(content: &str) -> Vec<String> {
94    let mut out = Vec::new();
95    for l in content.lines() {
96        for q in ['"', '\''] {
97            if (l.contains("import") || l.contains("require("))
98                && let Some(i) = l
99                    .find(" from ")
100                    .map(|i| i + 6)
101                    .or_else(|| l.find("require(").map(|i| i + 8))
102            {
103                let rest = l[i..].trim_start();
104                if let Some(r) = rest.strip_prefix(q)
105                    && let Some(end) = r.find(q)
106                {
107                    out.push(r[..end].to_owned());
108                }
109            }
110        }
111    }
112    out
113}
114
115/// `a/./b/../c` → `a/c`.
116fn normalise(p: &str) -> String {
117    let mut parts: Vec<&str> = Vec::new();
118    for seg in p.split('/') {
119        match seg {
120            "" | "." => {}
121            ".." => {
122                parts.pop();
123            }
124            s => parts.push(s),
125        }
126    }
127    parts.join("/")
128}
129
130/// Whether a runner's test id is a test of `file`.
131#[must_use]
132pub fn belongs(id: &str, file: &str) -> bool {
133    let stem = std::path::Path::new(file)
134        .file_stem()
135        .and_then(|s| s.to_str())
136        .unwrap_or_default();
137    if stem.is_empty() {
138        return false;
139    }
140    let dotted = file
141        .rsplit_once('.')
142        .map_or(file, |(a, _)| a)
143        .replace('/', ".");
144    id.contains(file)
145        || id.contains(&dotted)
146        || id.split([':', '.', '/', ' ']).any(|seg| seg == stem)
147}
148
149/// The members for `changed` source files among `tests` (path and content),
150/// those green on the baseline run (`baseline`), plus `unchanged` (each
151/// `U<n>`'s test file and name).
152#[must_use]
153pub fn select(
154    changed: &[String],
155    tests: &[(String, String)],
156    baseline: &[TestResult],
157    crate_name: Option<&str>,
158    unchanged: &[(String, String, String)],
159) -> PassToPass {
160    let mut members: Vec<Member> = Vec::new();
161    for (file, content) in tests {
162        let Some(why) = changed
163            .iter()
164            .find_map(|c| rule(c, file, content, crate_name))
165        else {
166            continue;
167        };
168        for t in baseline
169            .iter()
170            .filter(|t| t.outcome == TestOutcome::Passed && belongs(&t.id, file))
171        {
172            if members.iter().any(|m| m.test == t.id) {
173                continue;
174            }
175            members.push(Member {
176                test: t.id.clone(),
177                file: file.clone(),
178                rule: why.clone(),
179                final_: MemberFinal::NotRun,
180            });
181        }
182    }
183    for (statement, file, test) in unchanged {
184        members.push(Member {
185            test: test.clone(),
186            file: file.clone(),
187            rule: format!("unchanged:{statement}"),
188            final_: MemberFinal::NotRun,
189        });
190    }
191    PassToPass {
192        state: if members.is_empty() {
193            PassToPassState::NoneSelected
194        } else {
195            PassToPassState::Selected
196        },
197        members,
198    }
199}
200
201/// Each selected member's result in the final run (by name; one the run
202/// does not name is missing). *Unchanged* members are settled by their own
203/// runs (`unchanged_passed`).
204pub fn settle(
205    p2p: &mut PassToPass,
206    finals: &[TestResult],
207    unchanged_passed: &dyn Fn(&str) -> bool,
208) {
209    for m in &mut p2p.members {
210        if let Some(statement) = m.rule.strip_prefix("unchanged:") {
211            m.final_ = if unchanged_passed(statement) {
212                MemberFinal::Passed
213            } else {
214                MemberFinal::Failed
215            };
216            continue;
217        }
218        m.final_ = match finals.iter().find(|t| t.id == m.test).map(|t| t.outcome) {
219            Some(TestOutcome::Passed) => MemberFinal::Passed,
220            Some(TestOutcome::Failed | TestOutcome::Error | TestOutcome::Skipped) => {
221                MemberFinal::Failed
222            }
223            None => MemberFinal::Missing,
224        };
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn passed(id: &str) -> TestResult {
233        TestResult {
234            id: id.to_owned(),
235            outcome: TestOutcome::Passed,
236        }
237    }
238
239    #[test]
240    fn rust_tests_naming_the_changed_module_are_selected() {
241        assert_eq!(
242            rule(
243                "src/title.rs",
244                "tests/save.rs",
245                "use notes::title::save;",
246                Some("notes")
247            ),
248            Some("references:crate::title".to_owned())
249        );
250        assert_eq!(
251            rule(
252                "src/title.rs",
253                "tests/other.rs",
254                "use notes::body;",
255                Some("notes")
256            ),
257            None
258        );
259        assert_eq!(
260            rule(
261                "src/title.rs",
262                "src/title.rs",
263                "#[cfg(test)]\nmod tests {}",
264                Some("notes")
265            ),
266            Some("same_file".to_owned())
267        );
268        assert_eq!(
269            rule(
270                "src/a/mod.rs",
271                "tests/a.rs",
272                "use my_crate::a::f;",
273                Some("my-crate")
274            ),
275            Some("references:crate::a".to_owned())
276        );
277    }
278
279    #[test]
280    fn python_js_and_go_rules() {
281        assert_eq!(
282            rule(
283                "app/title.py",
284                "tests/test_title.py",
285                "from app.title import save\n",
286                None
287            ),
288            Some("imports:app.title".to_owned())
289        );
290        assert_eq!(
291            rule(
292                "app/title.py",
293                "tests/test_x.py",
294                "from app import body, title\n",
295                None
296            ),
297            Some("imports:app.title".to_owned())
298        );
299        assert_eq!(
300            rule(
301                "src/title.ts",
302                "src/title.test.ts",
303                "import { save } from './title';\n",
304                None
305            ),
306            Some("imports:src/title.ts".to_owned())
307        );
308        assert_eq!(
309            rule(
310                "src/title.ts",
311                "test/title.test.ts",
312                "import { save } from \"../src/title.js\";\n",
313                None
314            ),
315            Some("imports:src/title.ts".to_owned())
316        );
317        assert_eq!(
318            rule(
319                "title/title.go",
320                "title/title_test.go",
321                "package title",
322                None
323            ),
324            Some("package:title".to_owned())
325        );
326        assert_eq!(rule("title/title.go", "other/x_test.go", "", None), None);
327    }
328
329    #[test]
330    fn only_baseline_green_tests_of_selected_files_and_none_is_recorded_as_none() {
331        let tests = vec![(
332            "tests/save.rs".to_owned(),
333            "use notes::title::save;".to_owned(),
334        )];
335        let baseline = vec![
336            passed("save::saves_a_title"),
337            TestResult {
338                id: "save::broken".to_owned(),
339                outcome: TestOutcome::Failed,
340            },
341            passed("other::x"),
342        ];
343        let mut p = select(
344            &["src/title.rs".to_owned()],
345            &tests,
346            &baseline,
347            Some("notes"),
348            &[],
349        );
350        assert_eq!(p.state, PassToPassState::Selected);
351        assert_eq!(p.members.len(), 1);
352        settle(&mut p, &[], &|_| true);
353        assert_eq!(
354            p.members[0].final_,
355            MemberFinal::Missing,
356            "a missing test fails"
357        );
358        let none = select(
359            &["src/title.rs".to_owned()],
360            &[],
361            &baseline,
362            Some("notes"),
363            &[],
364        );
365        assert_eq!(none.state, PassToPassState::NoneSelected);
366        let with_u = select(
367            &[],
368            &[],
369            &[],
370            None,
371            &[(
372                "U1".to_owned(),
373                "tests/u1.rs".to_owned(),
374                "keeps".to_owned(),
375            )],
376        );
377        assert_eq!(with_u.members[0].rule, "unchanged:U1");
378        assert!(belongs("tests.test_title.test_x", "tests/test_title.py"));
379        assert!(belongs("save::saves", "tests/save.rs"));
380    }
381}