Skip to main content

speccoder_runtime/collection/
mod.rs

1//! Which of an attempt's changed test files the run actually collected:
2//! a test that never ran is evidence of nothing.
3//!
4//! Each test file the attempt added or changed — a test path by
5//! convention that defines tests by its toolchain's markers — is decided,
6//! in order, by the toolchain's static rule (it alone can prove a file is
7//! never collected), the check's fresh JUnit report, and the runner's own
8//! listing (or the check's `list` command). What none of them decides is
9//! `unknown`: it never passes.
10
11pub mod junit_map;
12pub mod listing;
13pub mod runner;
14pub mod static_cfg;
15
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::time::Duration;
19
20use speccoder_core::collection::{
21    COMPILED_OUT, Collection, CollectionRule, DecidedBy, FileCollection, JsRunner, Toolchain,
22    collected, defines_tests, go_breaks, go_declared_tests, go_rule, is_build_or_dependency,
23    not_collected, rust_fixes, rust_reachable, rust_rule, rust_targets, toolchain_of, unknown,
24};
25use speccoder_core::verify::{CheckResult, TestOutcome};
26
27use crate::verify::config::Check;
28use crate::verify::process::CommandRunner;
29use junit_map::Place;
30
31/// What the judge hands the collection check.
32pub struct Input<'a> {
33    /// The worktree, after the attempt.
34    pub root: &'a Path,
35    /// The attempt's changes: path and `added`/`modified`/`deleted`.
36    pub changes: &'a [(String, String)],
37    /// The fast checks as configured.
38    pub checks: &'a [Check],
39    /// Their results after the attempt.
40    pub results: &'a [CheckResult],
41    /// Runs listings (the sandbox).
42    pub runner: &'a dyn CommandRunner,
43    /// Whether listings may run (only for an otherwise passing attempt).
44    pub listings: bool,
45}
46
47/// The attempt's changed files that are judged: test paths by convention
48/// that define tests, not deleted, not build output or dependencies, not
49/// benchmarks or examples.
50#[must_use]
51pub fn judged_files(root: &Path, changes: &[(String, String)]) -> Vec<(String, Toolchain)> {
52    let mut out: Vec<(String, Toolchain)> = changes
53        .iter()
54        .filter(|(_, kind)| kind != "deleted")
55        .filter(|(p, _)| {
56            (crate::tools::paths::is_test_path(p) || test_named(p)) && !is_build_or_dependency(p)
57        })
58        .filter(|(p, _)| !p.split('/').any(|c| c == "benches" || c == "examples"))
59        .filter_map(|(p, _)| {
60            let t = toolchain_of(p)?;
61            let text = std::fs::read_to_string(root.join(p)).ok()?;
62            defines_tests(t, &text).then(|| (p.clone(), t))
63        })
64        .collect();
65    out.sort();
66    out.dedup();
67    out
68}
69
70/// A file whose name says it holds tests (`greet.tests.ts`, `x-spec.js`)
71/// even where speccoder's path conventions do not list it.
72fn test_named(path: &str) -> bool {
73    let name = path.rsplit('/').next().unwrap_or(path);
74    let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
75    stem.split(['.', '_', '-']).any(|w| {
76        matches!(
77            w.to_ascii_lowercase().trim_end_matches('s'),
78            "test" | "spec"
79        )
80    })
81}
82
83/// The listing a file needs, keyed so it runs once per command.
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
85struct Need {
86    argv: Vec<String>,
87    cwd: String,
88    kind: ListKind,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
92enum ListKind {
93    Nextest,
94    Libtest,
95    Pytest,
96    Vitest,
97    Jest,
98    Go,
99    LibtestIgnored,
100    Custom,
101}
102
103/// One file waiting for a listing.
104struct Pending {
105    path: String,
106    place: Place,
107    rule: Option<CollectionRule>,
108    needs: Vec<(Need, Option<String>)>,
109}
110
111/// Decides every judged file of the attempt; `None` when it changed none.
112#[must_use]
113pub fn judge(input: &Input<'_>) -> Option<Collection> {
114    let judged = judged_files(input.root, input.changes);
115    if judged.is_empty() {
116        return None;
117    }
118    let (files, truncated) = static_cfg::walk(input.root);
119    let argvs: Vec<Vec<String>> = input.checks.iter().map(|c| c.argv.clone()).collect();
120    let timeout = input
121        .checks
122        .iter()
123        .map(|c| c.timeout)
124        .max()
125        .unwrap_or(Duration::from_secs(600));
126    let custom = input.checks.iter().find_map(|c| c.list.clone());
127    // A configured listing runs under its own check's deadline.
128    let custom_timeout = input
129        .checks
130        .iter()
131        .find(|c| c.list.is_some())
132        .map_or(timeout, |c| c.timeout);
133    let packages = static_cfg::rust_packages(input.root, &files);
134    let js = static_cfg::js_packages(input.root, &files, &argvs);
135    let go = static_cfg::go_modules(input.root, &files);
136    let pytest = static_cfg::uses_pytest(input.root, &files, &argvs)
137        .then(|| static_cfg::pytest_rule(input.root));
138    let read = |p: &str| std::fs::read_to_string(input.root.join(p)).ok();
139
140    let mut out: Vec<FileCollection> = Vec::new();
141    let mut pending: Vec<Pending> = Vec::new();
142    for (path, toolchain) in &judged {
143        let source = read(path).unwrap_or_default();
144        // 1. The static rule; what it cannot decide waits for a report.
145        let (place, rule, needs) = match toolchain {
146            Toolchain::Rust => {
147                let Some(pkg) = static_cfg::owner(&packages, path) else {
148                    unrecognised(
149                        &mut out,
150                        &mut pending,
151                        custom.as_ref(),
152                        path,
153                        Place::Rust(BTreeMap::new()),
154                        "no Cargo.toml governs it",
155                    );
156                    continue;
157                };
158                let targets = rust_targets(pkg, &files);
159                let reach = rust_reachable(&targets, &|p| read(p));
160                let rule = rust_rule(pkg);
161                let Some(places) = reach.get(path) else {
162                    // The rule cannot prove absence from an incomplete walk,
163                    // nor against a report that names this file's tests.
164                    let stem = path
165                        .rsplit('/')
166                        .next()
167                        .unwrap_or(path)
168                        .trim_end_matches(".rs");
169                    let named = input
170                        .results
171                        .iter()
172                        .filter(|r| runner::runs(*toolchain, &r.argv))
173                        .flat_map(|r| r.tests.iter().flatten())
174                        .any(|t| {
175                            t.id.contains(&format!("::{stem}::"))
176                                || t.id.starts_with(&format!("{}::{stem}::", pkg.name))
177                        });
178                    if truncated || named {
179                        out.push(unknown(
180                            path,
181                            if truncated {
182                                "the checkout has more files than speccoder reads, so its rule cannot prove the file is never built"
183                            } else {
184                                "Cargo's rule says nothing builds it, but the run's report names tests of that name"
185                            },
186                        ));
187                        continue;
188                    }
189                    let tests =
190                        speccoder_core::collection::join(&pkg.root, "tests").unwrap_or_default();
191                    let why = if pkg.autotests && path.starts_with(&format!("{tests}/")) {
192                        "this file is neither"
193                    } else {
194                        "no target or `mod` declaration reaches it"
195                    };
196                    out.push(not_collected(
197                        path,
198                        DecidedBy::Static,
199                        rule,
200                        why,
201                        rust_fixes(&pkg.root, path),
202                    ));
203                    continue;
204                };
205                // A listing as a check the verdict rests on runs it.
206                let virtual_root =
207                    files.contains("Cargo.toml") && !packages.iter().any(|p| p.root.is_empty());
208                let mut needs: Vec<(Need, Option<String>)> = Vec::new();
209                for c in input
210                    .checks
211                    .iter()
212                    .filter(|c| runner::runs(*toolchain, &c.argv))
213                {
214                    if let Some(argv) = runner::nextest_list(&c.argv) {
215                        needs.push((
216                            Need {
217                                argv,
218                                cwd: check_dir(c),
219                                kind: ListKind::Nextest,
220                            },
221                            None,
222                        ));
223                        continue;
224                    }
225                    let root_package =
226                        pkg.root.is_empty() || virtual_root || c.cwd.trim_matches('/') == pkg.root;
227                    for (binary, _) in places {
228                        let kind = match binary.strip_prefix(&format!("{}::", pkg.name)) {
229                            None => ("lib".to_owned(), String::new()),
230                            Some(b) => match b.strip_prefix("bin/") {
231                                Some(bin) => ("bin".to_owned(), bin.to_owned()),
232                                None => ("test".to_owned(), b.to_owned()),
233                            },
234                        };
235                        if !runner::cargo_runs(&c.argv, &pkg.name, root_package, &kind) {
236                            continue;
237                        }
238                        let (list, ignored) = runner::cargo_list(&c.argv, &pkg.name, &kind);
239                        needs.push((
240                            Need {
241                                argv: list,
242                                cwd: check_dir(c),
243                                kind: ListKind::Libtest,
244                            },
245                            Some(binary.clone()),
246                        ));
247                        if let Some(ig) = ignored {
248                            needs.push((
249                                Need {
250                                    argv: ig,
251                                    cwd: check_dir(c),
252                                    kind: ListKind::LibtestIgnored,
253                                },
254                                Some(binary.clone()),
255                            ));
256                        }
257                    }
258                }
259                (Place::Rust(reach), rule, needs)
260            }
261            Toolchain::Python => {
262                let Some(rule) = pytest.as_ref() else {
263                    unrecognised(
264                        &mut out,
265                        &mut pending,
266                        custom.as_ref(),
267                        path,
268                        Place::Python,
269                        "no pytest configuration or check: speccoder knows pytest's collection only",
270                    );
271                    continue;
272                };
273                if let Some(why) = rule.breaks(path) {
274                    out.push(not_collected(
275                        path,
276                        DecidedBy::Static,
277                        rule.rule(),
278                        &why,
279                        rule.fixes(path),
280                    ));
281                    continue;
282                }
283                let needs = input
284                    .checks
285                    .iter()
286                    .filter(|c| runner::runs(*toolchain, &c.argv))
287                    .map(|c| {
288                        (
289                            Need {
290                                argv: runner::pytest_list(&c.argv),
291                                cwd: check_dir(c),
292                                kind: ListKind::Pytest,
293                            },
294                            None,
295                        )
296                    })
297                    .collect();
298                (Place::Python, rule.rule(), needs)
299            }
300            Toolchain::JavaScript => {
301                let Some(pkg) = static_cfg::js_owner(&js, path) else {
302                    unrecognised(
303                        &mut out,
304                        &mut pending,
305                        custom.as_ref(),
306                        path,
307                        Place::Js(path.clone()),
308                        "no package.json uses vitest or jest: speccoder knows their collection only",
309                    );
310                    continue;
311                };
312                let rel = if pkg.root.is_empty() {
313                    path.clone()
314                } else {
315                    path.trim_start_matches(&format!("{}/", pkg.root))
316                        .to_owned()
317                };
318                let rule = match &pkg.rule {
319                    Ok(r) => {
320                        if let Some(why) = r.breaks(&rel) {
321                            out.push(not_collected(
322                                path,
323                                DecidedBy::Static,
324                                r.rule(),
325                                &why,
326                                r.fixes(&rel),
327                            ));
328                            continue;
329                        }
330                        r.rule()
331                    }
332                    Err(_) => CollectionRule {
333                        toolchain: Toolchain::JavaScript,
334                        text: format!(
335                            "{} collects the files its configuration includes",
336                            pkg.runner.name()
337                        ),
338                        source: format!("{}/package.json", pkg.root)
339                            .trim_start_matches('/')
340                            .to_owned(),
341                    },
342                };
343                let needs = input
344                    .checks
345                    .iter()
346                    .filter(|c| runner::runs(*toolchain, &c.argv))
347                    .filter_map(|c| {
348                        let (argv, kind) = match pkg.runner {
349                            JsRunner::Vitest => (runner::vitest_list(&c.argv)?, ListKind::Vitest),
350                            JsRunner::Jest => (runner::jest_list(&c.argv), ListKind::Jest),
351                        };
352                        Some((
353                            Need {
354                                argv,
355                                cwd: pkg.root.clone(),
356                                kind,
357                            },
358                            None,
359                        ))
360                    })
361                    .collect();
362                (Place::Js(rel), rule, needs)
363            }
364            Toolchain::Go => {
365                let Some(module) = go
366                    .iter()
367                    .filter(|m| m.root.is_empty() || path.starts_with(&format!("{}/", m.root)))
368                    .max_by_key(|m| m.root.len())
369                else {
370                    unrecognised(
371                        &mut out,
372                        &mut pending,
373                        custom.as_ref(),
374                        path,
375                        Place::Go(String::new(), Vec::new()),
376                        "no go.mod governs it",
377                    );
378                    continue;
379                };
380                let tags = static_cfg::go_tags(&argvs);
381                if let Some((why, fixes)) = go_breaks(path, &source, &tags) {
382                    out.push(not_collected(
383                        path,
384                        DecidedBy::Static,
385                        go_rule(),
386                        &why,
387                        fixes,
388                    ));
389                    continue;
390                }
391                let dir = path.rsplit_once('/').map_or("", |(d, _)| d);
392                let rel_dir = dir
393                    .strip_prefix(module.root.as_str())
394                    .unwrap_or(dir)
395                    .trim_start_matches('/')
396                    .to_owned();
397                let import = if rel_dir.is_empty() {
398                    module.path.clone()
399                } else {
400                    format!("{}/{rel_dir}", module.path)
401                };
402                let needs = input
403                    .checks
404                    .iter()
405                    .filter(|c| runner::runs(*toolchain, &c.argv))
406                    .map(|c| {
407                        (
408                            Need {
409                                argv: runner::go_list(&c.argv),
410                                cwd: module.root.clone(),
411                                kind: ListKind::Go,
412                            },
413                            None,
414                        )
415                    })
416                    .collect();
417                (
418                    Place::Go(import, go_declared_tests(&source)),
419                    go_rule(),
420                    needs,
421                )
422            }
423        };
424        // 2. The check's fresh JUnit report, from a check that runs this toolchain.
425        let reports: Vec<&CheckResult> = input
426            .results
427            .iter()
428            .filter(|r| r.tests.is_some() && runner::runs(*toolchain, &r.argv))
429            .collect();
430        if !reports.is_empty() {
431            let ids: Vec<String> = reports
432                .iter()
433                .flat_map(|r| r.tests.iter().flatten())
434                .filter(|t| !matches!(t.outcome, TestOutcome::Skipped))
435                .map(|t| t.id.clone())
436                .collect();
437            let mine = junit_map::tests_of(&place, path, &ids);
438            out.push(if mine.is_empty() {
439                not_collected(
440                    path,
441                    DecidedBy::Junit,
442                    rule,
443                    COMPILED_OUT,
444                    compiled_out_fixes(*toolchain),
445                )
446            } else {
447                collected(path, DecidedBy::Junit, &mine)
448            });
449            continue;
450        }
451        // 3. A listing: the check's own `list`, else the runner's.
452        let needs = match &custom {
453            Some(argv) => vec![(
454                Need {
455                    argv: argv.clone(),
456                    cwd: String::new(),
457                    kind: ListKind::Custom,
458                },
459                None,
460            )],
461            None => needs,
462        };
463        // No check the verdict rests on runs this toolchain: nothing to ask.
464        if needs.is_empty() {
465            out.push(unknown(
466                path,
467                &format!(
468                    "no fast check would run it: none runs these {} tests",
469                    toolchain.title()
470                ),
471            ));
472            continue;
473        }
474        // A skip a listing cannot see: only a report can tell.
475        if runner::skips_unseen_by_listing(*toolchain, &source) {
476            out.push(unknown(
477                path,
478                "it may skip its tests, which a listing cannot tell; a JUnit report can",
479            ));
480            continue;
481        }
482        pending.push(Pending {
483            path: path.clone(),
484            place,
485            rule: Some(rule),
486            needs,
487        });
488    }
489
490    let mut runs = Vec::new();
491    if !pending.is_empty() && input.listings {
492        let mut cache: BTreeMap<Need, Result<String, String>> = BTreeMap::new();
493        for p in &pending {
494            let mut ids: Vec<String> = Vec::new();
495            let mut ignored: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
496            let mut failed: Option<String> = None;
497            for (need, binary) in &p.needs {
498                let got = cache.entry(need.clone()).or_insert_with(|| {
499                    let cwd = input.root.join(&need.cwd);
500                    let deadline = if need.kind == ListKind::Custom {
501                        custom_timeout
502                    } else {
503                        timeout
504                    };
505                    let (r, run) = listing::run(input.runner, &cwd, &need.argv, deadline);
506                    runs.push(run);
507                    r.map(|l| l.stdout)
508                });
509                match got {
510                    Err(why) => failed = Some(why.clone()),
511                    Ok(stdout) => {
512                        let root = input.root.join(&need.cwd);
513                        let listed: Option<Vec<String>> = match need.kind {
514                            ListKind::Nextest => listing::nextest_ids(stdout),
515                            ListKind::Libtest => Some(
516                                listing::libtest_names(stdout)
517                                    .into_iter()
518                                    .map(|n| {
519                                        format!("{}::{n}", binary.as_deref().unwrap_or_default())
520                                    })
521                                    .collect(),
522                            ),
523                            ListKind::LibtestIgnored => {
524                                ignored.extend(listing::libtest_names(stdout).into_iter().map(
525                                    |n| format!("{}::{n}", binary.as_deref().unwrap_or_default()),
526                                ));
527                                Some(Vec::new())
528                            }
529                            ListKind::Pytest => Some(listing::pytest_ids(stdout)),
530                            ListKind::Vitest => listing::vitest_files(stdout, &root),
531                            ListKind::Jest => Some(listing::jest_files(stdout, &root)),
532                            ListKind::Go => Some(listing::go_ids(stdout)),
533                            ListKind::Custom => Some(listing::lines(stdout, input.root)),
534                        };
535                        match listed {
536                            Some(l) => ids.extend(l),
537                            None => {
538                                failed = Some(format!(
539                                    "the output of `{}` could not be read",
540                                    need.argv.join(" ")
541                                ))
542                            }
543                        }
544                    }
545                }
546            }
547            ids.retain(|id| !ignored.contains(id));
548            let custom = p.needs.iter().any(|(n, _)| n.kind == ListKind::Custom);
549            let mine = if custom {
550                junit_map::listed(&p.place, &p.path, &ids)
551            } else {
552                match &p.place {
553                    Place::Js(rel) => ids
554                        .iter()
555                        .filter(|f| *f == rel || *f == &p.path)
556                        .cloned()
557                        .collect(),
558                    place => junit_map::tests_of(place, &p.path, &ids),
559                }
560            };
561            let toolchain = p
562                .rule
563                .as_ref()
564                .map(|r| r.toolchain)
565                .or_else(|| toolchain_of(&p.path))
566                .unwrap_or(Toolchain::Rust);
567            // A listing that failed decides nothing it did not name.
568            if let (true, Some(why)) = (mine.is_empty(), failed.as_ref()) {
569                out.push(unknown(&p.path, why));
570                continue;
571            }
572            out.push(match (mine.is_empty(), &p.rule) {
573                (false, _) => collected(&p.path, DecidedBy::Listing, &mine),
574                (true, Some(rule)) => not_collected(
575                    &p.path,
576                    DecidedBy::Listing,
577                    rule.clone(),
578                    COMPILED_OUT,
579                    compiled_out_fixes(toolchain),
580                ),
581                (true, None) => not_collected(
582                    &p.path,
583                    DecidedBy::Listing,
584                    CollectionRule {
585                        toolchain,
586                        text: "the check's `list` command names the tests it runs".to_owned(),
587                        source: "speccoder.toml [checks] list".to_owned(),
588                    },
589                    "it names none from this file",
590                    vec!["put the tests where the check runs them".to_owned()],
591                ),
592            });
593        }
594    } else {
595        for p in &pending {
596            out.push(unknown(
597                &p.path,
598                "no JUnit report names its tests and no listing ran",
599            ));
600        }
601    }
602    out.sort_by(|a, b| a.path.cmp(&b.path));
603    Some(Collection {
604        files: out,
605        listing: runs,
606    })
607}
608
609/// Why the runner would never collect a new file at `path` with `content`,
610/// by the static rule alone (`None`: it may be collected, or the rule is
611/// not known statically). For placing new test files.
612#[must_use]
613pub fn statically_refused(root: &Path, path: &str, content: &str) -> Option<String> {
614    let mut files = static_cfg::files(root);
615    files.insert(path.to_owned());
616    let read = |p: &str| {
617        if p == path {
618            Some(content.to_owned())
619        } else {
620            std::fs::read_to_string(root.join(p)).ok()
621        }
622    };
623    match toolchain_of(path)? {
624        Toolchain::Rust => {
625            let packages = static_cfg::rust_packages(root, &files);
626            let pkg = static_cfg::owner(&packages, path)?;
627            let reach = rust_reachable(&rust_targets(pkg, &files), &read);
628            (!reach.contains_key(path)).then(|| rust_rule(pkg).text)
629        }
630        Toolchain::Python => {
631            let rule = static_cfg::pytest_rule(root);
632            rule.breaks(path).map(|_| rule.rule().text)
633        }
634        Toolchain::JavaScript => {
635            let js = static_cfg::js_packages(root, &files, &[]);
636            let pkg = static_cfg::js_owner(&js, path)?;
637            let r = pkg.rule.as_ref().ok()?;
638            let rel = if pkg.root.is_empty() {
639                path.to_owned()
640            } else {
641                path.trim_start_matches(&format!("{}/", pkg.root))
642                    .to_owned()
643            };
644            r.breaks(&rel).map(|_| r.rule().text)
645        }
646        Toolchain::Go => {
647            go_breaks(path, content, &std::collections::BTreeSet::new()).map(|_| go_rule().text)
648        }
649    }
650}
651
652/// A file whose runner speccoder does not recognise: the check's own
653/// `list` decides it when there is one, else it is unknown.
654fn unrecognised(
655    out: &mut Vec<FileCollection>,
656    pending: &mut Vec<Pending>,
657    custom: Option<&Vec<String>>,
658    path: &str,
659    place: Place,
660    why: &str,
661) {
662    match custom {
663        Some(argv) => pending.push(Pending {
664            path: path.to_owned(),
665            place,
666            rule: None,
667            needs: vec![(
668                Need {
669                    argv: argv.clone(),
670                    cwd: String::new(),
671                    kind: ListKind::Custom,
672                },
673                None,
674            )],
675        }),
676        None => out.push(unknown(path, why)),
677    }
678}
679
680/// A check's working directory, root-relative (`""` for the root).
681fn check_dir(c: &Check) -> String {
682    let d = c.cwd.trim_matches('/');
683    if d == "." {
684        String::new()
685    } else {
686        d.trim_start_matches("./").to_owned()
687    }
688}
689
690fn compiled_out_fixes(t: Toolchain) -> Vec<String> {
691    vec![
692        match t {
693            Toolchain::Rust => {
694                "make the check run them (a `cfg` feature they need, an `#[ignore]` they carry)"
695            }
696            Toolchain::Python => {
697                "make the check collect them (a marker or `-k` that deselects them, a skip)"
698            }
699            Toolchain::JavaScript => {
700                "make the check include them (a project or filter that leaves them out, a `.skip`)"
701            }
702            Toolchain::Go => {
703                "make the check run them (a `-run` filter or build tag that leaves them out)"
704            }
705        }
706        .to_owned(),
707    ]
708}