Skip to main content

supercov_engine/
source_manifest.rs

1//! What a run's sources were: each captured file's bytes and declarations,
2//! the per-test execution record written when the run is published, and the
3//! change model that says which tests a later edit could have reached.
4//! `tests affected`, `runs source` and assertions coverage all read these.
5
6use crate::{
7    coverage_report::{
8        ArchiveReportRequest, CoverageReport, ExitCodeInput, analyze_coverage_archive,
9    },
10    evidence_archive::read_archive_selected,
11    lifecycle::atomic_write,
12    run_store::{RunMetadata, StoredRun},
13    source_units::{Code, Diff, named},
14};
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17use sha2::{Digest, Sha256};
18use std::{
19    collections::{BTreeMap, BTreeSet},
20    fs,
21    path::Path,
22};
23pub type Files = BTreeMap<String, String>;
24pub fn digest(value: &impl Serialize) -> String {
25    format!(
26        "{:x}",
27        Sha256::digest(serde_json::to_vec(value).expect("serializable map"))
28    )
29}
30
31/// One-based lines and UTF-8 byte columns, for every language. Text is exact.
32pub fn local_path(file: &str) -> bool {
33    !file.is_empty()
34        && !file.contains(['\\', ':'])
35        && file.split('/').all(|p| !matches!(p, "" | "." | ".."))
36}
37pub fn line_starts(source: &str) -> Vec<usize> {
38    std::iter::once(0)
39        .chain(source.match_indices('\n').map(|(at, _)| at + 1))
40        .collect()
41}
42
43/// The text of 1-based `line`, exactly as `str::lines` would yield it: without
44/// its line ending, with a carriage return stripped only where a line feed
45/// follows it, and with no empty line after a final line feed.
46pub fn line_text<'s>(source: &'s str, starts: &[usize], line: usize) -> Option<&'s str> {
47    let start = *starts.get(line.checked_sub(1)?)?;
48    if start >= source.len() {
49        return None;
50    }
51    match starts.get(line) {
52        Some(next) => {
53            let text = &source[start..next - 1];
54            Some(text.strip_suffix('\r').unwrap_or(text))
55        }
56        None => Some(&source[start..]),
57    }
58}
59
60/// Source text held in memory for capture or a verified current-checkout query.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase", deny_unknown_fields)]
63pub struct Inputs {
64    pub language: String,
65    pub files: Files,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct FileFingerprint {
71    pub sha256: String,
72    pub bytes: usize,
73    /// The parser's view of the file: what it declares, each declaration
74    /// digested with comments blanked. Absent for a file no parser reads and
75    /// in manifests written before this existed; such a file is compared by
76    /// its bytes, as every file once was.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub code: Option<Code>,
79}
80impl FileFingerprint {
81    pub fn of(source: &str) -> Self {
82        Self {
83            sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
84            bytes: source.len(),
85            code: None,
86        }
87    }
88    /// Bytes and, where Supercov has a parser for the file, its declarations.
89    pub fn read(path: &str, source: &str) -> Self {
90        let mut fingerprint = Self::of(source);
91        fingerprint.code = crate::source_units::code(path, source);
92        fingerprint
93    }
94    pub fn same_bytes(&self, other: &Self) -> bool {
95        self.sha256 == other.sha256
96    }
97}
98pub type FileManifest = BTreeMap<String, FileFingerprint>;
99
100/// The run stores identities and hashes, never complete source files.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct InputManifest {
104    pub schema_version: u32,
105    pub language: String,
106    pub files: FileManifest,
107}
108/// The manifest's schema: files, their bytes and their declarations.
109pub const MANIFEST_SCHEMA: u32 = 3;
110impl Inputs {
111    pub fn manifest(&self) -> InputManifest {
112        InputManifest {
113            schema_version: MANIFEST_SCHEMA,
114            language: self.language.clone(),
115            files: self
116                .files
117                .iter()
118                .map(|(p, s)| (p.clone(), FileFingerprint::read(p, s)))
119                .collect(),
120        }
121    }
122}
123impl InputManifest {
124    pub fn with_sources(&self, files: Files) -> Inputs {
125        Inputs {
126            language: self.language.clone(),
127            files,
128        }
129    }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase", deny_unknown_fields)]
134pub struct TestSelector {
135    pub file: String,
136    pub name: String,
137}
138
139/// What each test of a run executed, in the units of that run's manifest.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase", deny_unknown_fields)]
142pub struct Executions {
143    pub tests: Vec<Execution>,
144    /// Per file, the units that hold a probe of their own. A change confined
145    /// to these can reach a test only by being run.
146    pub probed: BTreeMap<String, Vec<usize>>,
147    /// Per file, the units the run actually reached -- every record's, the one
148    /// holding execution no test could be credited with included.
149    ///
150    /// This is the bound on a test whose own reach nothing recorded. It cannot
151    /// have run more than the run did, so a change outside this reached no
152    /// test at all and a change inside it might have reached any of them.
153    /// Without it the only sound answer for such a test would be "affected by
154    /// everything", which is true and useless.
155    #[serde(default)]
156    pub covered: BTreeMap<String, Vec<usize>>,
157}
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct Execution {
161    pub test: TestSelector,
162    pub passed: bool,
163    /// Per file, the innermost unit of every probe this test fired.
164    pub files: BTreeMap<String, Vec<usize>>,
165    /// How completely `files` describes what this test ran: `exact`, `partial`
166    /// where it is a lower bound, or `run-wide` where nothing was recorded.
167    ///
168    /// Only under `exact` does an absence here mean the test did not run the
169    /// code. A Go test that called `t.Parallel()` records nothing of its own,
170    /// and a Ruby test records only the lines no earlier test had reached --
171    /// so reading either silence as proof is how a change to code a test
172    /// exercised comes back as "this test is unaffected".
173    #[serde(default = "exact_by_default")]
174    pub attribution: String,
175}
176
177/// Every execution recorded before attribution was a question was an exact
178/// one, so a record that does not say is one.
179fn exact_by_default() -> String {
180    crate::coverage_report::ATTRIBUTION_EXACT.to_owned()
181}
182/// How one captured file moved between two runs, judged once and read for
183/// every flow.
184pub enum FileChange<'a> {
185    Same,
186    /// Only comments changed: no program can tell.
187    CommentsOnly,
188    /// Not among the previous run's inputs.
189    Added,
190    Removed,
191    /// No parser reads the file on one side or the other; its bytes moved.
192    Bytes,
193    Code {
194        before: &'a Code,
195        after: &'a Code,
196        diff: Diff,
197        /// The change is confined to declaration bodies that only run: it
198        /// reaches a test only if the test ran one of them.
199        narrow: bool,
200    },
201}
202pub fn file_change<'a>(
203    before: Option<&'a FileFingerprint>,
204    after: Option<&'a FileFingerprint>,
205    probed: Option<&[usize]>,
206) -> Option<FileChange<'a>> {
207    let Some(before) = before else {
208        return after.map(|_| FileChange::Added);
209    };
210    let Some(after) = after else {
211        return Some(FileChange::Removed);
212    };
213    if before.same_bytes(after) {
214        return Some(FileChange::Same);
215    }
216    let (Some(old), Some(new)) = (&before.code, &after.code) else {
217        return Some(FileChange::Bytes);
218    };
219    if old.semantic == new.semantic {
220        return Some(FileChange::CommentsOnly);
221    }
222    let diff = old.diff(new);
223    let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
224    Some(FileChange::Code {
225        before: old,
226        after: new,
227        diff,
228        narrow,
229    })
230}
231/// Every unit that moved, by name: what changed, what arrived, what went.
232pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
233    let mut parts = Vec::new();
234    if !diff.changed.is_empty() {
235        parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
236    }
237    if !diff.added.is_empty() {
238        parts.push(format!(
239            "added {}",
240            named(diff.added.iter().map(|i| &after.units[*i]))
241        ));
242    }
243    if !diff.removed.is_empty() {
244        parts.push(format!(
245            "removed {}",
246            named(diff.removed.iter().map(|i| &before.units[*i]))
247        ));
248    }
249    if parts.is_empty() {
250        "declarations".to_owned()
251    } else {
252        parts.join("; ")
253    }
254}
255
256/// The run's manifest, written beside the archive at publication so a query
257/// need not decompress the archive for one entry. Used only while the
258/// archive's digest is the one it records.
259const MANIFEST_CACHE_FILE: &str = "source-manifest.cache.json";
260/// What each test executed, written at publication; `tests affected` reads it.
261pub const EXECUTIONS_FILE: &str = "test-executions.json";
262/// The archive entry holding the run's source manifest.
263pub use crate::source_capture::ARCHIVE_PATH;
264
265#[derive(serde::Serialize, serde::Deserialize)]
266#[serde(rename_all = "camelCase", deny_unknown_fields)]
267struct ManifestCache {
268    evidence_digest: String,
269    manifest: InputManifest,
270    #[serde(default)]
271    statement_exclusions: Vec<Value>,
272}
273pub struct RunManifest {
274    pub manifest: InputManifest,
275    pub evidence_digest: String,
276    pub statement_exclusions: Vec<Value>,
277}
278pub struct RunInputs {
279    pub inputs: Inputs,
280    pub stored: RunManifest,
281}
282impl std::ops::Deref for RunInputs {
283    type Target = RunManifest;
284    fn deref(&self) -> &RunManifest {
285        &self.stored
286    }
287}
288/// The run's manifest with the current checkout's sources, which must be the
289/// run's own byte for byte.
290pub fn load_inputs(root: &Path, run: &StoredRun) -> Result<RunInputs, String> {
291    load_inputs_for(root, run, |_| true)
292}
293/// [`load_inputs`] checking only the files `needed` names against the run.
294pub fn load_inputs_for(
295    root: &Path,
296    run: &StoredRun,
297    needed: impl Fn(&str) -> bool,
298) -> Result<RunInputs, String> {
299    let stored = load_manifest(run)?;
300    let inputs = crate::source_capture::current_sources_for(root, &stored.manifest, needed)?;
301    Ok(RunInputs { inputs, stored })
302}
303pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
304    load_optional_manifest(run)?.ok_or_else(|| {
305        "This run has no source manifest. Run tests once with this version of Supercov".into()
306    })
307}
308/// An archive entry's name as its run wrote it. A merged run keeps each
309/// parent's entries under `shards/<n>/`, and a parent that was itself merged
310/// brings its own level along.
311fn unsharded(path: &str) -> &str {
312    let mut rest = path;
313    while let Some((shard, inner)) = rest
314        .strip_prefix("shards/")
315        .and_then(|after| after.split_once('/'))
316    {
317        if shard.is_empty() || !shard.bytes().all(|byte| byte.is_ascii_digit()) {
318            break;
319        }
320        rest = inner;
321    }
322    rest
323}
324/// The one value every entry of this name holds. The runs a merge accepts
325/// measured the same source, tests, dependencies and configuration, so each
326/// recorded the same manifest and the merged run has one; a merged run whose
327/// parents did not is not one run's code and is refused.
328fn the_one(
329    entries: &[crate::evidence_archive::EvidenceArchiveEntry],
330    named: impl Fn(&str) -> bool,
331) -> Result<Option<Value>, String> {
332    let mut found: Option<(&[u8], Value)> = None;
333    for entry in entries.iter().filter(|entry| named(&entry.path)) {
334        match &found {
335            Some((bytes, _)) if *bytes == entry.contents.as_slice() => {}
336            Some((_, value)) => {
337                let other: Value =
338                    serde_json::from_slice(&entry.contents).map_err(|e| e.to_string())?;
339                if other != *value {
340                    return Err("The runs merged into this one recorded different source manifests; use one of them instead".into());
341                }
342            }
343            None => {
344                found = Some((
345                    &entry.contents,
346                    serde_json::from_slice(&entry.contents).map_err(|e| e.to_string())?,
347                ));
348            }
349        }
350    }
351    Ok(found.map(|(_, value)| value))
352}
353fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
354    let merged = run.metadata.merged == Some(true);
355    let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
356    let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
357    if let Some(cached) = fs::read(run.directory.join(MANIFEST_CACHE_FILE))
358        .ok()
359        .and_then(|bytes| serde_json::from_slice::<ManifestCache>(&bytes).ok())
360        .filter(|cached| cached.evidence_digest == evidence_digest)
361        .filter(|cached| validate_manifest(&cached.manifest).is_ok())
362    {
363        return Ok(Some(RunManifest {
364            manifest: cached.manifest,
365            evidence_digest,
366            statement_exclusions: cached.statement_exclusions,
367        }));
368    }
369    let named = |path: &str, name: &str| {
370        if merged {
371            unsharded(path) == name
372        } else {
373            path == name
374        }
375    };
376    let entries = read_archive_selected(&run.evidence_path, |path| {
377        named(path, ARCHIVE_PATH) || named(path, "statement-exclusions.json")
378    })
379    .map_err(|e| e.to_string())?;
380    let Some(value) = the_one(&entries, |path| named(path, ARCHIVE_PATH))? else {
381        return Ok(None);
382    };
383    if value["schemaVersion"].as_u64() != Some(u64::from(MANIFEST_SCHEMA)) {
384        return Err(
385            "Unsupported source manifest schema; rerun tests with this version of Supercov".into(),
386        );
387    }
388    let manifest = serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?;
389    validate_manifest(&manifest)?;
390    if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
391        return Err("Run archive changed during read".into());
392    }
393    Ok(Some(RunManifest {
394        manifest,
395        evidence_digest,
396        statement_exclusions: the_one(&entries, |path| named(path, "statement-exclusions.json"))?
397            .map(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
398            .transpose()?
399            .unwrap_or_default(),
400    }))
401}
402fn validate_manifest(manifest: &InputManifest) -> Result<(), String> {
403    if manifest.files.iter().any(|(p, f)| {
404        !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
405    }) {
406        return Err("Invalid source manifest".into());
407    }
408    Ok(())
409}
410
411/// The manifest a run archived, from the inputs the frontend archived it
412/// from and the digest publication took of the archive: what reading the
413/// archive back would give, without decompressing it to find one entry.
414pub(crate) fn archived_manifest(
415    manifest: InputManifest,
416    evidence_digest: String,
417) -> Result<RunManifest, String> {
418    validate_manifest(&manifest)?;
419    Ok(RunManifest {
420        manifest,
421        evidence_digest,
422        statement_exclusions: Vec::new(),
423    })
424}
425fn write_json(
426    root: &Path,
427    run: &StoredRun,
428    name: &str,
429    value: &impl serde::Serialize,
430) -> Result<(), String> {
431    let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
432    bytes.push(b'\n');
433    atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
434}
435/// Write the run's manifest cache and execution record into the unpublished
436/// run directory; the lifecycle publishes them with the run in one rename.
437///
438/// `analysed` is the run's coverage when the caller has already analysed its
439/// evidence. Merged runs and archives without a manifest have neither: a
440/// merged run is analysed by the first query that needs it, not by `merge`.
441pub(crate) fn prepare_publication_with(
442    root: &Path,
443    directory: &Path,
444    metadata: &RunMetadata,
445    analysed: Option<&CoverageReport>,
446    archived: Option<RunManifest>,
447) -> Result<(), String> {
448    if metadata.merged == Some(true) {
449        return Ok(());
450    }
451    let run = StoredRun {
452        id: metadata.id.clone(),
453        directory: directory.into(),
454        evidence_path: directory.join("evidence.raw.gz"),
455        metadata_path: directory.join("run.json"),
456        query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
457        metadata: metadata.clone(),
458    };
459    let input = match archived {
460        Some(input) => input,
461        None => match load_optional_manifest(&run)? {
462            Some(input) => input,
463            None => return Ok(()),
464        },
465    };
466    let current = crate::source_capture::current_sources(root, &input.manifest);
467    let owned;
468    let analysed = match analysed {
469        Some(report) => Some(report),
470        None => {
471            owned = coverage(&run).ok();
472            owned.as_ref()
473        }
474    };
475    // What each test ran, so a later edit can be told apart from code the test
476    // never reached. Evidence that will not analyse leaves the record out.
477    if let Some(executions) = analysed.and_then(|report| {
478        executions(
479            report,
480            &input.manifest,
481            current.as_ref().ok().map(|inputs| &inputs.files),
482        )
483    }) {
484        write_json(root, &run, EXECUTIONS_FILE, &executions)?;
485    }
486    // A cache: a run that cannot write it is read from its archive instead.
487    let _ = write_json(
488        root,
489        &run,
490        MANIFEST_CACHE_FILE,
491        &ManifestCache {
492            evidence_digest: input.evidence_digest.clone(),
493            manifest: input.manifest.clone(),
494            statement_exclusions: input.statement_exclusions.clone(),
495        },
496    );
497    Ok(())
498}
499/// Each test's execution, placed in the declarations of the run's own
500/// manifest: for every probe a test fired, the innermost unit holding it.
501/// Also which units hold a probe at all, per file, since only a change
502/// confined to such units can be said to have missed a test. Sources are
503/// needed to place JavaScript columns; without them there is no record.
504pub fn executions(
505    coverage: &CoverageReport,
506    manifest: &InputManifest,
507    sources: Option<&Files>,
508) -> Option<Executions> {
509    // Each point's file, by index into `files`, and unit. Hits are looked up
510    // millions of times on a large run, so the lookup hashes with Fx and a
511    // test's hits are grouped by file index before any name is copied.
512    let mut located: crate::interned::FastMap<&str, (usize, usize)> = Default::default();
513    let mut files: Vec<&str> = Vec::new();
514    let mut file_indexes: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
515    let mut probed: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
516    for point in &coverage.view.points {
517        let meta = &point.meta;
518        let Some(code) = manifest
519            .files
520            .get(&meta.file)
521            .and_then(|fingerprint| fingerprint.code.as_ref())
522        else {
523            continue;
524        };
525        let column = if manifest.language == "javascript" {
526            let source = sources?.get(&meta.file)?;
527            let Some(column) = byte_column(source, meta.line, meta.column, &manifest.language)
528            else {
529                continue;
530            };
531            column
532        } else {
533            meta.column + 1
534        };
535        let unit = code.unit_at(meta.line, column);
536        let file = *file_indexes.entry(meta.file.as_str()).or_insert_with(|| {
537            files.push(meta.file.as_str());
538            files.len() - 1
539        });
540        located.insert(meta.id.as_str(), (file, unit));
541        if code.units[unit].is_code() {
542            probed.entry(meta.file.clone()).or_default().insert(unit);
543        }
544    }
545    // Everything the run reached, whoever reached it. Collected before the
546    // per-test loop because the record that holds what no test could be
547    // credited with has no test file, and the loop below skips it -- which is
548    // exactly the record a parallel suite's coverage lives in.
549    let mut covered_by_file = vec![Vec::new(); files.len()];
550    for test in &coverage.view.tests {
551        for hit in &test.hits {
552            if let Some((file, unit)) = located.get(hit.as_str()) {
553                covered_by_file[*file].push(*unit);
554            }
555        }
556    }
557    let covered = files
558        .iter()
559        .zip(covered_by_file)
560        .filter(|(_, units)| !units.is_empty())
561        .map(|(file, units)| {
562            (
563                (*file).to_owned(),
564                units.into_iter().collect::<BTreeSet<_>>(),
565            )
566        })
567        .collect::<BTreeMap<_, _>>();
568
569    let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
570    for test in &coverage.view.tests {
571        let Some(file) = &test.file else {
572            continue;
573        };
574        let selector = TestSelector {
575            file: file.clone(),
576            name: test.name.clone(),
577        };
578        let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
579            test: selector,
580            passed: false,
581            files: BTreeMap::new(),
582            attribution: crate::coverage_report::ATTRIBUTION_EXACT.to_owned(),
583        });
584        record.passed |= test.outcome == "passed";
585        // One name can be recorded more than once -- retries, a test declared
586        // in two files -- and a single incomplete appearance is enough to make
587        // the whole record's file list a lower bound.
588        if !crate::coverage_report::coverage_is_complete(&record.attribution) {
589            // already the weaker claim
590        } else if !crate::coverage_report::coverage_is_complete(&test.attribution) {
591            record.attribution = test.attribution.clone();
592        }
593        let mut reached = test
594            .hits
595            .iter()
596            .filter_map(|hit| located.get(hit.as_str()).copied())
597            .collect::<Vec<_>>();
598        reached.sort_unstable();
599        for group in reached.chunk_by(|left, right| left.0 == right.0) {
600            record
601                .files
602                .entry(files[group[0].0].to_owned())
603                .or_default()
604                .extend(group.iter().map(|(_, unit)| *unit));
605        }
606    }
607    let mut tests = tests.into_values().collect::<Vec<_>>();
608    for record in &mut tests {
609        for units in record.files.values_mut() {
610            units.sort_unstable();
611            units.dedup();
612        }
613    }
614    Some(Executions {
615        tests,
616        probed: probed
617            .into_iter()
618            .map(|(file, units)| (file, units.into_iter().collect()))
619            .collect(),
620        covered: covered
621            .into_iter()
622            .map(|(file, units)| (file, units.into_iter().collect()))
623            .collect(),
624    })
625}
626/// Which of a run's tests the current checkout's changes could have reached,
627/// from what each test executed and what has changed since, declaration by
628/// declaration. A test is affected by a change in code it ran, in its test
629/// file, or in a file it ran code in whose declarations changed shape; not by
630/// a change confined to code it never ran; not by comments or blank lines. A
631/// test that did not pass is listed as affected regardless: it has a result to
632/// establish. What this cannot see is a file the run never captured -- a file
633/// added since -- and the run's dependencies and configuration, which the
634/// working-tree check answers for.
635/// The files a run's tests are written in, where the run recorded what each
636/// of its tests ran.
637pub fn test_files(run: &StoredRun) -> Option<BTreeSet<String>> {
638    let executions: Executions =
639        serde_json::from_slice(&fs::read(run.directory.join(EXECUTIONS_FILE)).ok()?).ok()?;
640    Some(
641        executions
642            .tests
643            .into_iter()
644            .map(|record| record.test.file)
645            .collect(),
646    )
647}
648
649/// The files, of those a run was recorded with, that are not as they were.
650pub fn changed_files(recorded: &FileManifest, now: impl Fn(&str) -> Option<String>) -> Vec<String> {
651    recorded
652        .iter()
653        .filter(|(file, was)| now(file).as_deref() != Some(was.sha256.as_str()))
654        .map(|(file, _)| file.clone())
655        .collect()
656}
657
658/// `tests affected` of a merged run: each run it was merged from answers for
659/// its own tests, and a test in several of them is one test, affected where
660/// any of them says so -- as a test failed in either run is failed in the
661/// merge.
662fn affected_tests_of_merge(root: &Path, run: &StoredRun) -> Result<Value, String> {
663    let parents = run
664        .metadata
665        .parents
666        .as_deref()
667        .filter(|parents| !parents.is_empty())
668        .ok_or("This merged run does not say which runs it was merged from")?;
669    let inventory = crate::run_store::discover_runs(root).map_err(|error| error.to_string())?;
670    // Where a test is answered: affected outranks undetermined, which
671    // outranks unaffected.
672    const BUCKETS: [&str; 3] = ["affected", "undetermined", "unaffected"];
673    let mut tests = BTreeMap::<(String, String), (usize, Value)>::new();
674    let mut changed = BTreeMap::<String, Value>::new();
675    let mut meaning = Value::Null;
676    for parent in parents {
677        let Some(stored) = inventory.runs.iter().find(|stored| stored.id == *parent) else {
678            return Err(format!(
679                "Run {} was merged from {parent}, which is no longer stored, and a merge is answered from the runs it was merged from. Ask the others, or measure the suite again",
680                run.id
681            ));
682        };
683        let report = affected_tests(root, stored)?;
684        for (rank, bucket) in BUCKETS.iter().enumerate() {
685            for test in report[*bucket].as_array().into_iter().flatten() {
686                let key = (
687                    test["file"].as_str().unwrap_or_default().to_owned(),
688                    test["name"].as_str().unwrap_or_default().to_owned(),
689                );
690                match tests.get_mut(&key) {
691                    Some((held, _)) if *held < rank => {}
692                    Some((held, entry)) if *held == rank => {
693                        let mut reasons = entry["reasons"].as_array().cloned().unwrap_or_default();
694                        for reason in test["reasons"].as_array().into_iter().flatten() {
695                            if !reasons.contains(reason) {
696                                reasons.push(reason.clone());
697                            }
698                        }
699                        entry["reasons"] = Value::Array(reasons);
700                    }
701                    _ => {
702                        tests.insert(key, (rank, test.clone()));
703                    }
704                }
705            }
706        }
707        for file in report["changedFiles"].as_array().into_iter().flatten() {
708            if let Some(name) = file["file"].as_str() {
709                changed
710                    .entry(name.to_owned())
711                    .or_insert_with(|| file.clone());
712            }
713        }
714        if meaning.is_null() {
715            meaning = report["meaning"].clone();
716        }
717    }
718    let mut buckets = BUCKETS.map(|_| Vec::new());
719    for (rank, test) in tests.into_values() {
720        buckets[rank].push(test);
721    }
722    // Most likely to fail first, as within one run.
723    for bucket in &mut buckets[..2] {
724        bucket.sort_by_key(|entry| std::cmp::Reverse(entry["nameMatch"].as_u64().unwrap_or(0)));
725    }
726    let [affected, undetermined, unaffected] = buckets;
727    let total = affected.len() + undetermined.len() + unaffected.len();
728    Ok(json!({
729        "run": run.id,
730        "mergedFrom": parents,
731        "summary": {
732            "tests": total,
733            "affected": affected.len(),
734            "undetermined": undetermined.len(),
735            "unaffected": unaffected.len(),
736            "changedFiles": changed.len(),
737        },
738        "affected": affected,
739        "undetermined": undetermined,
740        "unaffected": unaffected,
741        "changedFiles": changed.into_values().collect::<Vec<_>>(),
742        "meaning": meaning,
743    }))
744}
745
746pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
747    // What each test ran is recorded when a run is published, beside the
748    // code it ran, and a merge records nothing: its parts hold the records,
749    // and a merge is answered from them when asked.
750    if run.metadata.merged == Some(true) {
751        return affected_tests_of_merge(root, run);
752    }
753    let stored = load_manifest(run)?;
754    let executions: Executions = fs::read(run.directory.join(EXECUTIONS_FILE))
755        .ok()
756        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
757        .ok_or(
758            "This run has no per-test execution record; rerun tests with this version of Supercov",
759        )?;
760    let executions = &executions;
761    let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
762    let manifest = &stored.manifest;
763    let now = manifest
764        .files
765        .keys()
766        .filter_map(|file| {
767            let path = root.join(file);
768            let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
769            if !canonical.starts_with(&root) || !canonical.is_file() {
770                return None;
771            }
772            let text = fs::read_to_string(canonical).ok()?;
773            Some((file.clone(), FileFingerprint::read(file, &text)))
774        })
775        .collect::<FileManifest>();
776    let changes = manifest
777        .files
778        .keys()
779        .filter_map(|file| {
780            file_change(
781                manifest.files.get(file),
782                now.get(file),
783                executions.probed.get(file).map(Vec::as_slice),
784            )
785            .map(|change| (file.as_str(), change))
786        })
787        .collect::<BTreeMap<_, _>>();
788    let files = changes
789        .iter()
790        .filter_map(|(file, change)| {
791            let (kind, detail) = match change {
792                FileChange::Same | FileChange::Added => return None,
793                FileChange::CommentsOnly => ("formatting", None),
794                FileChange::Removed => ("removed", None),
795                FileChange::Bytes => ("changed", None),
796                FileChange::Code {
797                    before,
798                    after,
799                    diff,
800                    narrow,
801                } => (
802                    if *narrow { "bodies" } else { "declarations" },
803                    Some(describe(before, after, diff)),
804                ),
805            };
806            Some(json!({"file":file,"change":kind,"detail":detail}))
807        })
808        .collect::<Vec<_>>();
809    // What a test whose own reach is unknown could have reached.
810    //
811    // Nothing can narrow such a test below the run it ran in, and nothing
812    // needs to go wider: its reach is bounded above by what the run covered.
813    // So a change inside that bound could have reached it, and a change
814    // outside it -- code no test in the run ever ran, a file added since --
815    // could not have reached any test, this one included.
816    //
817    // That is a real narrowing rather than "always affected". It is also the
818    // only safe direction: reading an empty file list as "this change missed
819    // it" is what let a change to code every test exercised report `0 of 2
820    // tests affected` and exit 0, which an agent running only affected tests
821    // would act on by running nothing.
822    let beyond_the_run = changes
823        .iter()
824        .filter_map(|(file, change)| match change {
825            FileChange::Same | FileChange::CommentsOnly | FileChange::Added => None,
826            FileChange::Removed => Some(format!("{file} removed (the run covered code in it)")),
827            FileChange::Bytes => Some(format!("{file} changed (the run covered code in it)")),
828            FileChange::Code { before, diff, .. } => {
829                // Unit by unit rather than file by file: a function no test
830                // ran sits in the same file as the ones they did, so asking
831                // whether the file was covered would make every change to it
832                // reach every test.
833                let code = manifest.files.get(*file).and_then(|f| f.code.as_ref());
834                let reached = executions
835                    .covered
836                    .get(*file)
837                    .map(Vec::as_slice)
838                    .unwrap_or_default()
839                    .iter()
840                    .flat_map(|unit| match code {
841                        Some(code) if *unit < code.units.len() => {
842                            code.ancestors(*unit).collect::<Vec<_>>()
843                        }
844                        _ => vec![*unit],
845                    })
846                    .collect::<BTreeSet<_>>();
847                let hit = diff
848                    .changed
849                    .iter()
850                    .filter(|unit| reached.contains(unit))
851                    .filter_map(|unit| before.units.get(*unit))
852                    .collect::<Vec<_>>();
853                (!hit.is_empty())
854                    .then(|| format!("{file}: {} changed (the run covered it)", named(hit)))
855            }
856        })
857        .collect::<Vec<_>>();
858
859    // A JVM class's static initializer runs once per process and is credited
860    // to the first test to load the class, so a test that only reads a static
861    // field it set ran nothing recorded in that file. For a change to a
862    // class's declarations, a test whose own file names the class is affected
863    // too. (A module's top level in Python, Ruby, JavaScript or Go runs once
864    // as well, but replaying their histories missed no failing test there and
865    // the same rule grew dry-inflector's selection from 73% to 90% of its
866    // tests, so it is the JVM's alone.)
867    // Files tests load that nothing probes -- helpers, fixtures, support code
868    // beside the tests -- and that changed. No record says which test ran
869    // them, so a test is affected when its own file loads one: read from its
870    // imports where Supercov reads them, and otherwise by any change. Without
871    // this a helper every test called changing left every test unaffected.
872    let own_tests = executions
873        .tests
874        .iter()
875        .map(|record| record.test.file.clone())
876        .collect::<BTreeSet<_>>();
877    let known_tests = crate::run_store::discover_runs(&root)
878        .map(|inventory| {
879            inventory
880                .runs
881                .iter()
882                .filter_map(test_files)
883                .flatten()
884                .collect::<BTreeSet<_>>()
885        })
886        .unwrap_or_default()
887        .union(&own_tests)
888        .cloned()
889        .collect::<BTreeSet<_>>();
890    // What is measured, where the run's coverage manifest says: everything
891    // else first-party is a helper. Where it does not, only files beside the
892    // tests are; a measured file's top level changing is judged by what each
893    // test ran, above.
894    let measured = read_archive_selected(&run.evidence_path, |path| path == "manifest.json")
895        .ok()
896        .and_then(|entries| entries.into_iter().next())
897        .and_then(|entry| serde_json::from_slice::<Value>(&entry.contents).ok())
898        .and_then(|manifest| {
899            Some(
900                manifest["scope"]["entries"]
901                    .as_array()?
902                    .iter()
903                    .filter(|entry| entry["status"] == "included")
904                    .filter_map(|entry| entry["file"].as_str().map(str::to_owned))
905                    .collect::<BTreeSet<_>>(),
906            )
907        });
908    let helper = |file: &str| match &measured {
909        Some(measured) => !measured.contains(file),
910        None => crate::run_currency::beside_the_tests(file),
911    };
912    let helpers = changes
913        .iter()
914        .filter(|(file, change)| {
915            !matches!(
916                change,
917                FileChange::Same | FileChange::CommentsOnly | FileChange::Added
918            ) && !known_tests.contains(**file)
919                && helper(file)
920                && !crate::run_currency::configuration_file(file)
921                && !crate::run_currency::dependency_file(file)
922        })
923        // A change that only adds functions reaches no test written before.
924        .filter(|(file, change)| match change {
925            FileChange::Code { before, .. } => fs::read_to_string(root.join(file))
926                .ok()
927                .and_then(|text| crate::source_units::only_added(before, file, &text))
928                .is_none(),
929            _ => true,
930        })
931        .map(|(file, change)| {
932            let what = match change {
933                FileChange::Code {
934                    before,
935                    after,
936                    diff,
937                    ..
938                } => {
939                    format!("{file} changed: {}", describe(before, after, diff))
940                }
941                FileChange::Removed => format!("{file} removed"),
942                _ => format!("{file} changed"),
943            };
944            (*file, what)
945        })
946        .collect::<Vec<_>>();
947    let checkout =
948        (!helpers.is_empty()).then(|| crate::run_currency::Checkout::new(&root, &known_tests));
949    let ran = (!helpers.is_empty()).then(|| crate::run_currency::Ran::of(run));
950    let configurations = manifest
951        .files
952        .keys()
953        .filter(|file| crate::run_currency::configuration_file(file))
954        .cloned()
955        .collect::<Vec<_>>();
956    let mut loads_of = BTreeMap::<String, Option<crate::test_loads::Loads>>::new();
957    let named_by_tests = named_by_tests(&changes);
958    let mut test_sources = BTreeMap::<&str, Option<String>>::new();
959
960    let mut affected = Vec::new();
961    let mut undetermined = Vec::new();
962    let mut unaffected = Vec::new();
963    for record in &executions.tests {
964        let mut reasons = Vec::new();
965        // The changed declarations this test ran, where: what they held in
966        // the run (their own lines, nested declarations aside) and where
967        // they are now, so a caller can say which of their statements the
968        // test is known to check.
969        let mut changed_code = Vec::new();
970        if !record.passed {
971            reasons.push("did not pass in the run".to_owned());
972        }
973        match changes.get(record.test.file.as_str()) {
974            None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
975            Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
976            Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
977            Some(FileChange::Code {
978                before,
979                after,
980                diff,
981                ..
982            }) => reasons.push(format!(
983                "test file changed: {}",
984                describe(before, after, diff)
985            )),
986        }
987        for (file, units) in &record.files {
988            let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
989            let ran = units
990                .iter()
991                .flat_map(|unit| match code {
992                    Some(code) if *unit < code.units.len() => {
993                        code.ancestors(*unit).collect::<Vec<_>>()
994                    }
995                    _ => vec![*unit],
996                })
997                .collect::<BTreeSet<_>>();
998            match changes.get(file.as_str()) {
999                None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
1000                Some(FileChange::Removed) => {
1001                    reasons.push(format!("{file} removed (this test ran code in it)"));
1002                }
1003                Some(FileChange::Bytes) => {
1004                    reasons.push(format!("{file} changed (this test ran code in it)"));
1005                }
1006                Some(FileChange::Code {
1007                    before,
1008                    after,
1009                    diff,
1010                    narrow,
1011                }) => {
1012                    if *narrow {
1013                        let hit = diff
1014                            .changed
1015                            .iter()
1016                            .filter(|i| ran.contains(i))
1017                            .map(|i| &before.units[*i])
1018                            .collect::<Vec<_>>();
1019                        let now = after.by_path();
1020                        for index in diff.changed.iter().filter(|i| ran.contains(i)) {
1021                            let unit = &before.units[*index];
1022                            changed_code.push(json!({
1023                                "file": file,
1024                                "declaration": unit.path,
1025                                "own": own_lines(before, *index),
1026                                "now": now.get(unit.path.as_str()).map(|u| [u.line, u.end_line]),
1027                            }));
1028                        }
1029                        if !hit.is_empty() {
1030                            reasons
1031                                .push(format!("{file}: {} changed (this test ran it)", named(hit)));
1032                        }
1033                    } else {
1034                        reasons.push(format!(
1035                            "{file}: {} changed (this test ran code in this file)",
1036                            describe(before, after, diff)
1037                        ));
1038                    }
1039                }
1040            }
1041        }
1042        if let (Some(checkout), Some(ran)) = (&checkout, &ran) {
1043            let loads = loads_of.entry(record.test.file.clone()).or_insert_with(|| {
1044                checkout.loads(
1045                    &BTreeSet::from([record.test.file.clone()]),
1046                    ran.runners.as_ref(),
1047                    &configurations,
1048                )
1049            });
1050            for (file, what) in &helpers {
1051                // A file a process that served the run's tests loaded -- a
1052                // mock server's -- reached those tests and no others.
1053                let served_by = ran.support.as_ref().and_then(|support| {
1054                    let tests = support.files.get(*file)?;
1055                    Some(tests.iter().any(|test| {
1056                        support
1057                            .names
1058                            .get(test)
1059                            .is_some_and(|name| *name == record.test.name)
1060                    }))
1061                });
1062                let imported = matches!(loads, Some(loads) if loads.open.is_none() && loads.files.contains(*file));
1063                if let Some(served) = served_by
1064                    && !imported
1065                {
1066                    if served {
1067                        reasons.push(format!("{what} (a server that served this test loaded it)"));
1068                    }
1069                    continue;
1070                }
1071                let loaded = match loads {
1072                    Some(loads) if loads.open.is_none() => {
1073                        loads.files.contains(*file) || !checkout.places(file)
1074                    }
1075                    _ => true,
1076                };
1077                if loaded {
1078                    reasons.push(match loads {
1079                        Some(loads) if loads.open.is_none() && loads.files.contains(*file) => {
1080                            format!("{what} (this test's file loads it)")
1081                        }
1082                        _ => format!("{what} (a file tests may load)"),
1083                    });
1084                }
1085            }
1086        }
1087        for (file, names) in &named_by_tests {
1088            if record.files.contains_key(*file) || *file == record.test.file.as_str() {
1089                continue; // what it ran there already decided
1090            }
1091            let source = test_sources
1092                .entry(record.test.file.as_str())
1093                .or_insert_with(|| fs::read_to_string(root.join(&record.test.file)).ok());
1094            if let Some(name) = source
1095                .as_deref()
1096                .and_then(|text| names.iter().find(|name| names_word(text, name)))
1097            {
1098                reasons.push(format!(
1099                    "{file}: declarations changed, and the test file names {name} (code that runs once per process is credited to the first test to reach it)"
1100                ));
1101            }
1102        }
1103        if !crate::coverage_report::coverage_is_complete(&record.attribution) {
1104            // What its own record proves is the strong claim, and it keeps it:
1105            // a Ruby test credited with the changed line ran the changed line,
1106            // whatever else it also ran unrecorded.
1107            let proven = !reasons.is_empty();
1108            // The run's bound is what stands in for a record this test does
1109            // not have. Where it has one that already proves the change
1110            // reached it, saying so twice says nothing twice.
1111            if !proven {
1112                reasons.extend(beyond_the_run.iter().cloned());
1113                reasons.sort();
1114                reasons.dedup();
1115            }
1116            let entry = json!({
1117                "file": record.test.file,
1118                "name": record.test.name,
1119                "reasons": reasons,
1120                "attribution": record.attribution,
1121            });
1122            if proven {
1123                affected.push(entry);
1124            } else if reasons.is_empty() {
1125                unaffected.push(entry);
1126            } else {
1127                undetermined.push(entry);
1128            }
1129            continue;
1130        }
1131        let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons,"changedCode":changed_code});
1132        if reasons.is_empty() {
1133            unaffected.push(entry);
1134        } else {
1135            affected.push(entry);
1136        }
1137    }
1138    // Most likely to fail first: a test whose own file or name shares words
1139    // with a changed file (`test_receivebuffer.py` for `_receivebuffer.py`).
1140    // Replaying real commits in nine projects, this put a failing test in the
1141    // first three for 20 of 57 breaking commits where file order managed 13
1142    // (25 with assertion verdicts breaking its ties).
1143    let changed_words = changes
1144        .iter()
1145        .filter(|(_, change)| !matches!(change, FileChange::Same | FileChange::CommentsOnly))
1146        .flat_map(|(file, _)| words_of(file))
1147        .collect::<BTreeSet<_>>();
1148    for bucket in [&mut affected, &mut undetermined] {
1149        for entry in bucket.iter_mut() {
1150            entry["nameMatch"] = json!(name_match(entry, &changed_words));
1151        }
1152        bucket.sort_by_key(|entry| std::cmp::Reverse(entry["nameMatch"].as_u64().unwrap_or(0)));
1153    }
1154    Ok(json!({
1155        "run": run.id,
1156        "affected": affected,
1157        // Kept apart from `affected` because they are a different claim. A
1158        // test is affected when a change reached code it is recorded as having
1159        // run; it is undetermined when nothing recorded what it ran and the
1160        // change is inside what the run as a whole covered. Merging them would
1161        // report a precision the run does not have -- but both have to be run,
1162        // so both are in `--names`.
1163        "undetermined": undetermined,
1164        "unaffected": unaffected,
1165        "changedFiles": files,
1166        "summary": {
1167            "tests": executions.tests.len(),
1168            "affected": affected.len(),
1169            "undetermined": undetermined.len(),
1170            "unaffected": unaffected.len(),
1171            "changedFiles": files.len(),
1172        },
1173        "meaning": "Tests whose recorded execution a change since the run could have reached, and those whose execution nothing recorded: a test that ran alongside others has no coverage of its own, so it is undetermined whenever a change reaches anything the run covered. Run both. A file the run never captured, a dependency or a configuration change is not seen here; see workingTree."
1174    }))
1175}
1176/// For each changed JVM file whose declarations changed, the names a test
1177/// file may use to reach it: its top-level classes and its own name.
1178fn named_by_tests<'a>(
1179    changes: &BTreeMap<&'a str, FileChange<'_>>,
1180) -> BTreeMap<&'a str, BTreeSet<String>> {
1181    changes
1182        .iter()
1183        .filter(|(file, _)| {
1184            [".java", ".kt", ".kts", ".groovy", ".scala"]
1185                .iter()
1186                .any(|extension| file.ends_with(extension))
1187        })
1188        .filter_map(|(file, change)| match change {
1189            FileChange::Code {
1190                before,
1191                narrow: false,
1192                ..
1193            } => {
1194                let stem = file.rsplit('/').next()?.split('.').next()?.to_owned();
1195                let mut names = before
1196                    .units
1197                    .iter()
1198                    .filter(|unit| unit.parent.is_none_or(|p| before.units[p].path.is_empty()))
1199                    .filter_map(|unit| unit.path.rsplit('.').next().map(str::to_owned))
1200                    .filter(|name| name.len() > 2)
1201                    .collect::<BTreeSet<_>>();
1202                if stem.len() > 2
1203                    && !matches!(stem.as_str(), "mod" | "lib" | "main" | "index" | "__init__")
1204                {
1205                    names.insert(stem);
1206                }
1207                Some((*file, names))
1208            }
1209            _ => None,
1210        })
1211        .collect::<BTreeMap<_, _>>()
1212}
1213
1214/// The words a file's name is made of, test markers aside:
1215/// `tests/test_receive_buffer.py` -> `receive`, `buffer`;
1216/// `ReceiveBufferTest.java` -> `receive`, `buffer`.
1217fn words_of(path: &str) -> BTreeSet<String> {
1218    let stem = path.rsplit(['/', '\\']).next().unwrap_or(path);
1219    let stem = stem.split('.').next().unwrap_or(stem);
1220    split_words(stem)
1221        .into_iter()
1222        .filter(|w| !matches!(w.as_str(), "test" | "tests" | "spec" | "specs"))
1223        .collect()
1224}
1225
1226/// `receiveBuffer_v2` -> `receive`, `buffer`, `v2`: camel and snake case
1227/// split, lowercased, single letters dropped.
1228fn split_words(text: &str) -> Vec<String> {
1229    let mut words = Vec::new();
1230    let mut current = String::new();
1231    let mut previous_lower = false;
1232    for c in text.chars() {
1233        if !c.is_alphanumeric() {
1234            if !current.is_empty() {
1235                words.push(std::mem::take(&mut current));
1236            }
1237            previous_lower = false;
1238            continue;
1239        }
1240        if c.is_uppercase() && previous_lower && !current.is_empty() {
1241            words.push(std::mem::take(&mut current));
1242        }
1243        previous_lower = c.is_lowercase() || c.is_ascii_digit();
1244        current.extend(c.to_lowercase());
1245    }
1246    if !current.is_empty() {
1247        words.push(current);
1248    }
1249    words.into_iter().filter(|w| w.len() > 1).collect()
1250}
1251
1252/// How many of the changed files' words a test's file or name shares.
1253fn name_match(entry: &Value, changed: &BTreeSet<String>) -> usize {
1254    let file = entry["file"].as_str().unwrap_or("");
1255    let name = entry["name"].as_str().unwrap_or("");
1256    // The test's own name: after the last separator a runner puts between a
1257    // file or class and the test (`::`, `#`, `>`, `.`), parameters aside.
1258    let own = name
1259        .rsplit(['#', ':', '>', '.', '/'])
1260        .next()
1261        .unwrap_or(name)
1262        .split(['[', '('])
1263        .next()
1264        .unwrap_or("");
1265    let mut words = words_of(file);
1266    words.extend(split_words(own));
1267    words.intersection(changed).count()
1268}
1269
1270/// Whether `text` holds `word` as a whole identifier.
1271fn names_word(text: &str, word: &str) -> bool {
1272    text.match_indices(word).any(|(at, _)| {
1273        let before = text[..at].chars().last();
1274        let after = text[at + word.len()..].chars().next();
1275        let part = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
1276        !part(before) && !part(after)
1277    })
1278}
1279
1280/// A declaration's own lines: its span without the spans of the
1281/// declarations nested in it, as inclusive `[first, last]` ranges.
1282fn own_lines(code: &crate::source_units::Code, index: usize) -> Vec<[usize; 2]> {
1283    let unit = &code.units[index];
1284    let mut children = code
1285        .units
1286        .iter()
1287        .filter(|u| u.parent == Some(index))
1288        .map(|u| (u.line, u.end_line))
1289        .collect::<Vec<_>>();
1290    children.sort_unstable();
1291    let mut out = Vec::new();
1292    let mut next = unit.line;
1293    for (start, end) in children {
1294        if start > next {
1295            out.push([next, start - 1]);
1296        }
1297        next = next.max(end + 1);
1298    }
1299    if next <= unit.end_line {
1300        out.push([next, unit.end_line]);
1301    }
1302    out
1303}
1304
1305pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
1306    analyze_coverage_archive(&ArchiveReportRequest {
1307        archive_path: run.evidence_path.clone(),
1308        run_id: run.id.clone(),
1309        generated_at: run.metadata.started_at.clone(),
1310        integrity: None,
1311        test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
1312        test_filter: None,
1313    })
1314    .map_err(|e| format!("{e:?}"))
1315}
1316fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
1317    byte_column_in(source, &line_starts(source), line, column, language)
1318}
1319
1320/// `byte_column`, given where each line starts, so that resolving a whole
1321/// file's statements looks each line up instead of walking the file to it.
1322fn byte_column_in(
1323    source: &str,
1324    starts: &[usize],
1325    line: usize,
1326    column: usize,
1327    language: &str,
1328) -> Option<usize> {
1329    if line == 0 {
1330        return None;
1331    }
1332    let line = line_text(source, starts, line)?;
1333    if language != "javascript" {
1334        // Native Rust, Python and Ruby manifests use zero-based byte columns.
1335        return column.checked_add(1);
1336    }
1337    if column == 0 {
1338        return None;
1339    }
1340    let mut units = 0;
1341    for (byte, ch) in line.char_indices() {
1342        if units == column - 1 {
1343            return Some(byte + 1);
1344        }
1345        units += ch.len_utf16();
1346    }
1347    (units == column - 1).then_some(line.len() + 1)
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352    use super::*;
1353    use crate::evidence_archive::{read_archive, write_archive};
1354    use crate::run_store::discover_runs;
1355
1356    fn prepare_publication(
1357        root: &Path,
1358        directory: &Path,
1359        metadata: &RunMetadata,
1360        analysed: Option<&CoverageReport>,
1361    ) -> Result<(), String> {
1362        prepare_publication_with(root, directory, metadata, analysed, None)
1363    }
1364
1365    fn recorded(run: &StoredRun) -> Executions {
1366        serde_json::from_slice(&fs::read(run.directory.join(EXECUTIONS_FILE)).expect("a record"))
1367            .unwrap()
1368    }
1369
1370    #[test]
1371    fn a_merged_run_has_the_manifest_its_parents_share() {
1372        // `shards/<n>/` is where a merge puts each parent's entries; a parent
1373        // that was merged itself brings its own level.
1374        assert_eq!(unsharded("source-inputs.json"), "source-inputs.json");
1375        assert_eq!(
1376            unsharded("shards/0/source-inputs.json"),
1377            "source-inputs.json"
1378        );
1379        assert_eq!(
1380            unsharded("shards/12/shards/1/source-inputs.json"),
1381            "source-inputs.json"
1382        );
1383        assert_eq!(
1384            unsharded("shards/x/source-inputs.json"),
1385            "shards/x/source-inputs.json"
1386        );
1387        assert_eq!(
1388            unsharded("shards/source-inputs.json"),
1389            "shards/source-inputs.json"
1390        );
1391
1392        let entry = |path: &str, contents: &str| crate::evidence_archive::EvidenceArchiveEntry {
1393            path: path.into(),
1394            contents: contents.as_bytes().to_vec(),
1395        };
1396        let named = |path: &str| unsharded(path) == ARCHIVE_PATH;
1397        // The same manifest three times, once spelled differently, is one.
1398        let same = [
1399            entry("shards/0/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1400            entry("shards/1/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1401            entry("shards/2/source-inputs.json", r#"{ "files": {"a.js": 1} }"#),
1402            entry("shards/0/tests.jsonl", "not read"),
1403        ];
1404        assert_eq!(
1405            the_one(&same, named).unwrap(),
1406            Some(json!({"files": {"a.js": 1}}))
1407        );
1408        // Two different ones are refused: the merged run is not one run's code.
1409        let different = [
1410            entry("shards/0/source-inputs.json", r#"{"files":{"a.js":1}}"#),
1411            entry("shards/1/source-inputs.json", r#"{"files":{"a.js":2}}"#),
1412        ];
1413        assert!(
1414            the_one(&different, named)
1415                .unwrap_err()
1416                .contains("different source manifests")
1417        );
1418        assert_eq!(the_one(&[], named).unwrap(), None);
1419    }
1420
1421    /// `byte_column` as it was before it took line starts.
1422    fn reference_byte_column(
1423        source: &str,
1424        line: usize,
1425        column: usize,
1426        language: &str,
1427    ) -> Option<usize> {
1428        if line == 0 {
1429            return None;
1430        }
1431        let line = source.lines().nth(line - 1)?;
1432        if language != "javascript" {
1433            return column.checked_add(1);
1434        }
1435        if column == 0 {
1436            return None;
1437        }
1438        let mut units = 0;
1439        for (byte, ch) in line.char_indices() {
1440            if units == column - 1 {
1441                return Some(byte + 1);
1442            }
1443            units += ch.len_utf16();
1444        }
1445        (units == column - 1).then_some(line.len() + 1)
1446    }
1447
1448    #[test]
1449    fn a_column_from_line_starts_agrees_with_walking_the_file() {
1450        // JavaScript columns count UTF-16 units, so text whose widths differ --
1451        // accents, and an emoji that is two units -- is where they could part.
1452        for source in [
1453            "",
1454            "a",
1455            "a\n",
1456            "a\r\nb\r\n",
1457            "x\r",
1458            "héllo\nwörld\n",
1459            "😀x\ny😀z\n",
1460            "const a = 1;\n  b();\n",
1461        ] {
1462            let starts = line_starts(source);
1463            for language in ["javascript", "python"] {
1464                for line in 0..source.len() + 3 {
1465                    for column in 0..source.len() + 4 {
1466                        assert_eq!(
1467                            byte_column_in(source, &starts, line, column, language),
1468                            reference_byte_column(source, line, column, language),
1469                            "{language} {line}:{column} in {source:?}"
1470                        );
1471                    }
1472                }
1473            }
1474        }
1475    }
1476
1477    #[test]
1478    fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1479        let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1480        let _ = fs::remove_dir_all(&root);
1481        fs::create_dir_all(root.join("src")).unwrap();
1482        fs::create_dir_all(root.join("tests")).unwrap();
1483        // The fixture's one point sits at line 1, column 0, so `work` has to
1484        // start the file: an `export` keyword there would belong to the top
1485        // level, not to the function.
1486        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
1487        let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1488        let config = "export const LIMIT = 5;\nfunction check(n) {\n  return n < LIMIT;\n}\n";
1489        fs::write(root.join("src/app.js"), app).unwrap();
1490        fs::write(root.join("src/config.js"), config).unwrap();
1491        fs::write(root.join("tests/app.test.js"), test).unwrap();
1492        // The fixture run's one point sits at line 1, column 0 of src/app.js
1493        // with a zero-based byte column, which is how every non-JavaScript
1494        // frontend reports; the language is named for that.
1495        let inputs = crate::source_capture::capture(
1496            &root,
1497            "python",
1498            [
1499                "src/app.js".into(),
1500                "src/config.js".into(),
1501                "tests/app.test.js".into(),
1502            ],
1503        )
1504        .unwrap();
1505        let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1506        let path = directory.join("evidence.raw.gz");
1507        let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1508        let archive = write_archive(entries, &path).unwrap();
1509        let metadata_path = directory.join("run.json");
1510        let mut metadata: RunMetadata =
1511            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1512        metadata.raw_evidence.files = archive.files;
1513        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1514        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1515        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1516        prepare_publication(&root, &directory, &metadata, None).unwrap();
1517        let run = discover_runs(&root).unwrap().runs.remove(0);
1518        let stored = load_manifest(&run).unwrap();
1519        let executions = &recorded(&run);
1520        let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1521        let work = code.units.iter().position(|u| u.path == "work").unwrap();
1522        let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1523        assert_eq!(executions.tests.len(), 1);
1524        let record = &executions.tests[0];
1525        assert_eq!(record.test.file, "tests/app.test.js");
1526        assert_eq!(record.test.name, "test");
1527        assert!(record.passed);
1528        assert_eq!(record.files["src/app.js"], vec![work]);
1529        assert_eq!(
1530            executions.probed["src/app.js"],
1531            vec![work],
1532            "idle holds no probe in this run"
1533        );
1534        let _ = idle;
1535
1536        let names = |value: &Value, key: &str| {
1537            value[key]
1538                .as_array()
1539                .unwrap()
1540                .iter()
1541                .map(|t| t["name"].as_str().unwrap().to_owned())
1542                .collect::<Vec<_>>()
1543        };
1544        // Nothing changed.
1545        let report = affected_tests(&root, &run).unwrap();
1546        assert!(names(&report, "affected").is_empty(), "{report}");
1547        assert_eq!(names(&report, "unaffected"), ["test"]);
1548        assert_eq!(report["summary"]["changedFiles"], 0);
1549        // A comment: still nothing.
1550        fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1551        let report = affected_tests(&root, &run).unwrap();
1552        assert!(names(&report, "affected").is_empty(), "{report}");
1553        assert_eq!(report["changedFiles"][0]["change"], "formatting");
1554        // A body the test ran: affected, and it says which.
1555        fs::write(
1556            root.join("src/app.js"),
1557            app.replace("return 1;", "return 1 + 0;"),
1558        )
1559        .unwrap();
1560        let report = affected_tests(&root, &run).unwrap();
1561        assert_eq!(names(&report, "affected"), ["test"]);
1562        assert_eq!(
1563            report["affected"][0]["reasons"][0],
1564            "src/app.js: work (line 1) changed (this test ran it)"
1565        );
1566        // A body the test did not run, once idle has a probe of its own: not
1567        // affected. Here idle holds none, so the change is not one that can
1568        // be said to have missed the test, and it counts.
1569        fs::write(
1570            root.join("src/app.js"),
1571            app.replace("return 2;", "return 2 + 0;"),
1572        )
1573        .unwrap();
1574        let report = affected_tests(&root, &run).unwrap();
1575        assert_eq!(names(&report, "affected"), ["test"], "{report}");
1576        assert!(
1577            report["affected"][0]["reasons"][0]
1578                .as_str()
1579                .unwrap()
1580                .contains("this test ran code in this file"),
1581            "{report}"
1582        );
1583        // A declaration added: affected, the file's shape changed.
1584        fs::write(
1585            root.join("src/app.js"),
1586            format!("{app}function more() {{}}\n"),
1587        )
1588        .unwrap();
1589        let report = affected_tests(&root, &run).unwrap();
1590        assert_eq!(names(&report, "affected"), ["test"]);
1591        assert_eq!(report["changedFiles"][0]["change"], "declarations");
1592        // The test file itself.
1593        fs::write(root.join("src/app.js"), app).unwrap();
1594        fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1595        let report = affected_tests(&root, &run).unwrap();
1596        assert_eq!(names(&report, "affected"), ["test"]);
1597        assert!(
1598            report["affected"][0]["reasons"][0]
1599                .as_str()
1600                .unwrap()
1601                .starts_with("test file changed"),
1602            "{report}"
1603        );
1604        // Top-level code in a file the test names but never ran a statement
1605        // of: outside the JVM, not affected (a JVM class's static initializer
1606        // is the case the naming rule is for).
1607        fs::write(root.join("tests/app.test.js"), test).unwrap();
1608        fs::write(root.join("src/config.js"), config.replace("= 5", "= 6")).unwrap();
1609        let report = affected_tests(&root, &run).unwrap();
1610        assert!(names(&report, "affected").is_empty(), "{report}");
1611        fs::write(root.join("src/config.js"), config).unwrap();
1612        // A source file removed.
1613        fs::remove_file(root.join("src/app.js")).unwrap();
1614        let report = affected_tests(&root, &run).unwrap();
1615        assert_eq!(
1616            report["affected"][0]["reasons"][0],
1617            "src/app.js removed (this test ran code in it)"
1618        );
1619        fs::remove_dir_all(root).unwrap();
1620    }
1621
1622    /// h11's `test_receivebuffer.py` for `_receivebuffer.py`, commons-cli's
1623    /// `ConverterTests#testClassDoesNotInitialize` for `Converter.java`.
1624    #[test]
1625    fn a_test_named_like_the_changed_file_comes_first() {
1626        assert_eq!(
1627            words_of("h11/tests/test_receive_buffer.py"),
1628            BTreeSet::from(["receive".to_owned(), "buffer".to_owned()])
1629        );
1630        assert_eq!(
1631            words_of("src/test/java/org/apache/commons/cli/ConverterTests.java"),
1632            BTreeSet::from(["converter".to_owned()])
1633        );
1634        assert_eq!(
1635            split_words("parseHTTPVersion_v2"),
1636            ["parse", "httpversion", "v2"]
1637        );
1638        let changed = words_of("src/main/java/org/apache/commons/cli/Converter.java");
1639        let entry = |file: &str, name: &str| json!({"file": file, "name": name});
1640        assert_eq!(
1641            name_match(
1642                &entry(
1643                    "src/test/java/x/ConverterTests.java",
1644                    "x.ConverterTests#testClass()"
1645                ),
1646                &changed
1647            ),
1648            1
1649        );
1650        assert_eq!(
1651            name_match(
1652                &entry(
1653                    "src/test/java/x/OptionTests.java",
1654                    "x.OptionTests#testConverter()"
1655                ),
1656                &changed
1657            ),
1658            1,
1659            "the test's own name counts too"
1660        );
1661        assert_eq!(
1662            name_match(
1663                &entry(
1664                    "src/test/java/x/OptionTests.java",
1665                    "x.OptionTests#testBuilder()"
1666                ),
1667                &changed
1668            ),
1669            0
1670        );
1671    }
1672
1673    /// apache/commons-cli's `Converter.CLASS`: a static field set in a
1674    /// static initializer credited to whichever test loaded the class first.
1675    /// Its tests are reached by the class's name; a JavaScript module's top
1676    /// level is not.
1677    #[test]
1678    fn a_jvm_class_changed_in_its_declarations_is_reached_by_its_name() {
1679        let java = "package app;\nclass Converter {\n    static final Object CLASS = null;\n    static int size(int n) {\n        return n;\n    }\n}\n";
1680        let before = FileFingerprint::read("src/app/Converter.java", java);
1681        let after = FileFingerprint::read(
1682            "src/app/Converter.java",
1683            &java.replace("CLASS = null", "CLASS = new Object()"),
1684        );
1685        let js = "export const LIMIT = 5;\n";
1686        let js_before = FileFingerprint::read("src/config.js", js);
1687        let js_after = FileFingerprint::read("src/config.js", &js.replace('5', "6"));
1688        let changes = BTreeMap::from([
1689            (
1690                "src/app/Converter.java",
1691                file_change(Some(&before), Some(&after), Some(&[])).unwrap(),
1692            ),
1693            (
1694                "src/config.js",
1695                file_change(Some(&js_before), Some(&js_after), Some(&[])).unwrap(),
1696            ),
1697        ]);
1698        let named = named_by_tests(&changes);
1699        assert_eq!(
1700            named.get("src/app/Converter.java"),
1701            Some(&BTreeSet::from(["Converter".to_owned()]))
1702        );
1703        assert!(!named.contains_key("src/config.js"));
1704        assert!(names_word("assertEquals(Converter.CLASS, x)", "Converter"));
1705        assert!(!names_word("ConverterTests", "Converter"));
1706    }
1707
1708    #[test]
1709    fn a_test_that_ran_beside_others_is_undetermined_rather_than_unaffected() {
1710        // A parallel suite's coverage sits in the record nobody could claim,
1711        // and the tests beside it hold nothing of their own. Reading "this
1712        // test's record does not mention the changed file" as "this test is
1713        // unaffected" would answer `0 of 1 tests affected` for a change to the
1714        // only code the run executed -- and an agent running only the affected
1715        // tests would run nothing. The run's own bound is what stands in.
1716        let root = std::env::temp_dir().join(format!(
1717            "supercov-undetermined-{}-{}",
1718            std::process::id(),
1719            std::time::SystemTime::now()
1720                .duration_since(std::time::UNIX_EPOCH)
1721                .unwrap()
1722                .as_nanos()
1723        ));
1724        let _ = fs::remove_dir_all(&root);
1725        fs::create_dir_all(root.join("src")).unwrap();
1726        fs::create_dir_all(root.join("tests")).unwrap();
1727        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
1728        let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1729        let config = "export const LIMIT = 5;\nfunction check(n) {\n  return n < LIMIT;\n}\n";
1730        fs::write(root.join("src/app.js"), app).unwrap();
1731        fs::write(root.join("src/config.js"), config).unwrap();
1732        fs::write(root.join("tests/app.test.js"), test).unwrap();
1733        let inputs = crate::source_capture::capture(
1734            &root,
1735            "python",
1736            ["src/app.js".into(), "tests/app.test.js".into()],
1737        )
1738        .unwrap();
1739        let directory = crate::run_store::create_run_wide_test_run(&root, "parallel");
1740        let path = directory.join("evidence.raw.gz");
1741        let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1742        let archive = write_archive(entries, &path).unwrap();
1743        let metadata_path = directory.join("run.json");
1744        let mut metadata: RunMetadata =
1745            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1746        metadata.raw_evidence.files = archive.files;
1747        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1748        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1749        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1750        prepare_publication(&root, &directory, &metadata, None).unwrap();
1751        let run = discover_runs(&root).unwrap().runs.remove(0);
1752
1753        let executions = &recorded(&run);
1754        assert_eq!(executions.tests.len(), 1, "the named test, not the record");
1755        let record = &executions.tests[0];
1756        assert_eq!(
1757            record.attribution,
1758            crate::coverage_report::ATTRIBUTION_RUN_WIDE
1759        );
1760        assert!(
1761            record.files.is_empty(),
1762            "it is credited with nothing: {:?}",
1763            record.files
1764        );
1765        assert!(
1766            !executions.covered["src/app.js"].is_empty(),
1767            "and the run is credited with what it reached"
1768        );
1769
1770        let bucket = |value: &Value, key: &str| {
1771            value[key]
1772                .as_array()
1773                .unwrap()
1774                .iter()
1775                .map(|t| t["name"].as_str().unwrap().to_owned())
1776                .collect::<Vec<_>>()
1777        };
1778
1779        // Nothing changed: nothing to rerun, and the run's bound says nothing
1780        // either.
1781        let report = affected_tests(&root, &run).unwrap();
1782        assert_eq!(bucket(&report, "unaffected"), ["test"], "{report}");
1783        assert!(bucket(&report, "undetermined").is_empty(), "{report}");
1784
1785        // The one function the run executed changed. The test's own record
1786        // cannot say whether it reached it, and the run's can.
1787        fs::write(
1788            root.join("src/app.js"),
1789            app.replace("return 1;", "return 3;"),
1790        )
1791        .unwrap();
1792        let report = affected_tests(&root, &run).unwrap();
1793        assert!(
1794            bucket(&report, "affected").is_empty(),
1795            "nothing proves it ran the change: {report}"
1796        );
1797        assert_eq!(
1798            bucket(&report, "undetermined"),
1799            ["test"],
1800            "the run reached it, so this test might have: {report}"
1801        );
1802        assert_eq!(
1803            report["undetermined"][0]["attribution"],
1804            crate::coverage_report::ATTRIBUTION_RUN_WIDE,
1805            "{report}"
1806        );
1807        fs::remove_dir_all(root).unwrap();
1808    }
1809
1810    #[test]
1811    fn a_lower_bound_still_proves_what_it_recorded() {
1812        // Ruby's is the other incomplete attribution: the test is credited
1813        // with coverage, and that coverage is a floor rather than the whole of
1814        // what it ran. The floor is still evidence -- a test credited with the
1815        // changed line ran the changed line -- so it is affected outright,
1816        // with its own reason, rather than swept into the bucket for tests
1817        // nothing can say anything about.
1818        let root = std::env::temp_dir().join(format!(
1819            "supercov-partial-{}-{}",
1820            std::process::id(),
1821            std::time::SystemTime::now()
1822                .duration_since(std::time::UNIX_EPOCH)
1823                .unwrap()
1824                .as_nanos()
1825        ));
1826        let _ = fs::remove_dir_all(&root);
1827        fs::create_dir_all(root.join("src")).unwrap();
1828        fs::create_dir_all(root.join("tests")).unwrap();
1829        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
1830        let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1831        let config = "export const LIMIT = 5;\nfunction check(n) {\n  return n < LIMIT;\n}\n";
1832        fs::write(root.join("src/app.js"), app).unwrap();
1833        fs::write(root.join("src/config.js"), config).unwrap();
1834        fs::write(root.join("tests/app.test.js"), test).unwrap();
1835        let inputs = crate::source_capture::capture(
1836            &root,
1837            "python",
1838            ["src/app.js".into(), "tests/app.test.js".into()],
1839        )
1840        .unwrap();
1841        let directory = crate::run_store::create_partial_test_run(&root, "partial");
1842        let path = directory.join("evidence.raw.gz");
1843        let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1844        let archive = write_archive(entries, &path).unwrap();
1845        let metadata_path = directory.join("run.json");
1846        let mut metadata: RunMetadata =
1847            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1848        metadata.raw_evidence.files = archive.files;
1849        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1850        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1851        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1852        prepare_publication(&root, &directory, &metadata, None).unwrap();
1853        let run = discover_runs(&root).unwrap().runs.remove(0);
1854
1855        let executions = recorded(&run);
1856        let record = &executions.tests[0];
1857        assert_eq!(
1858            record.attribution,
1859            crate::coverage_report::ATTRIBUTION_PARTIAL
1860        );
1861        assert!(
1862            !record.files.is_empty(),
1863            "a lower bound is coverage it was credited with"
1864        );
1865
1866        let names = |value: &Value, key: &str| {
1867            value[key]
1868                .as_array()
1869                .unwrap()
1870                .iter()
1871                .map(|t| t["name"].as_str().unwrap().to_owned())
1872                .collect::<Vec<_>>()
1873        };
1874        fs::write(
1875            root.join("src/app.js"),
1876            app.replace("return 1;", "return 3;"),
1877        )
1878        .unwrap();
1879        let report = affected_tests(&root, &run).unwrap();
1880        assert_eq!(
1881            names(&report, "affected"),
1882            ["test"],
1883            "what its own record proves, it proves: {report}"
1884        );
1885        assert!(names(&report, "undetermined").is_empty(), "{report}");
1886        // And the reason is its own, not the run's stand-in.
1887        let reasons = report["affected"][0]["reasons"].as_array().unwrap();
1888        assert_eq!(reasons.len(), 1, "{report}");
1889        let reason = reasons[0].as_str().unwrap();
1890        assert!(
1891            reason.contains("this test ran it"),
1892            "the reason is its own: {report}"
1893        );
1894        assert!(
1895            !reason.contains("the run covered"),
1896            "and not the run's stand-in: {report}"
1897        );
1898        fs::remove_dir_all(root).unwrap();
1899    }
1900}