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    let stored = load_manifest(run)?;
292    let inputs = crate::source_capture::current_sources(root, &stored.manifest)?;
293    Ok(RunInputs { inputs, stored })
294}
295pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
296    load_optional_manifest(run)?.ok_or_else(|| {
297        "This run has no source manifest. Run tests once with this version of Supercov".into()
298    })
299}
300fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
301    if run.metadata.merged == Some(true) {
302        return Err("Use a single run; merged runs have multiple source manifests".into());
303    }
304    let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
305    let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
306    if let Some(cached) = fs::read(run.directory.join(MANIFEST_CACHE_FILE))
307        .ok()
308        .and_then(|bytes| serde_json::from_slice::<ManifestCache>(&bytes).ok())
309        .filter(|cached| cached.evidence_digest == evidence_digest)
310        .filter(|cached| validate_manifest(&cached.manifest).is_ok())
311    {
312        return Ok(Some(RunManifest {
313            manifest: cached.manifest,
314            evidence_digest,
315            statement_exclusions: cached.statement_exclusions,
316        }));
317    }
318    let entries = read_archive_selected(&run.evidence_path, |path| {
319        path == ARCHIVE_PATH || path == "statement-exclusions.json"
320    })
321    .map_err(|e| e.to_string())?;
322    let Some(input) = entries.iter().find(|e| e.path == ARCHIVE_PATH) else {
323        return Ok(None);
324    };
325    let value: Value = serde_json::from_slice(&input.contents).map_err(|e| e.to_string())?;
326    if value["schemaVersion"].as_u64() != Some(u64::from(MANIFEST_SCHEMA)) {
327        return Err(
328            "Unsupported source manifest schema; rerun tests with this version of Supercov".into(),
329        );
330    }
331    let manifest = serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?;
332    validate_manifest(&manifest)?;
333    if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
334        return Err("Run archive changed during read".into());
335    }
336    Ok(Some(RunManifest {
337        manifest,
338        evidence_digest,
339        statement_exclusions: entries
340            .iter()
341            .find(|e| e.path == "statement-exclusions.json")
342            .map(|entry| serde_json::from_slice(&entry.contents).map_err(|e| e.to_string()))
343            .transpose()?
344            .unwrap_or_default(),
345    }))
346}
347fn validate_manifest(manifest: &InputManifest) -> Result<(), String> {
348    if manifest.files.iter().any(|(p, f)| {
349        !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
350    }) {
351        return Err("Invalid source manifest".into());
352    }
353    Ok(())
354}
355
356/// The manifest a run archived, from the inputs the frontend archived it
357/// from and the digest publication took of the archive: what reading the
358/// archive back would give, without decompressing it to find one entry.
359pub(crate) fn archived_manifest(
360    manifest: InputManifest,
361    evidence_digest: String,
362) -> Result<RunManifest, String> {
363    validate_manifest(&manifest)?;
364    Ok(RunManifest {
365        manifest,
366        evidence_digest,
367        statement_exclusions: Vec::new(),
368    })
369}
370fn write_json(
371    root: &Path,
372    run: &StoredRun,
373    name: &str,
374    value: &impl serde::Serialize,
375) -> Result<(), String> {
376    let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
377    bytes.push(b'\n');
378    atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
379}
380/// Write the run's manifest cache and execution record into the unpublished
381/// run directory; the lifecycle publishes them with the run in one rename.
382///
383/// `analysed` is the run's coverage when the caller has already analysed its
384/// evidence. Merged runs and archives without a manifest have neither.
385pub(crate) fn prepare_publication_with(
386    root: &Path,
387    directory: &Path,
388    metadata: &RunMetadata,
389    analysed: Option<&CoverageReport>,
390    archived: Option<RunManifest>,
391) -> Result<(), String> {
392    if metadata.merged == Some(true) {
393        return Ok(());
394    }
395    let run = StoredRun {
396        id: metadata.id.clone(),
397        directory: directory.into(),
398        evidence_path: directory.join("evidence.raw.gz"),
399        metadata_path: directory.join("run.json"),
400        query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
401        metadata: metadata.clone(),
402    };
403    let input = match archived {
404        Some(input) => input,
405        None => match load_optional_manifest(&run)? {
406            Some(input) => input,
407            None => return Ok(()),
408        },
409    };
410    let current = crate::source_capture::current_sources(root, &input.manifest);
411    let owned;
412    let analysed = match analysed {
413        Some(report) => Some(report),
414        None => {
415            owned = coverage(&run).ok();
416            owned.as_ref()
417        }
418    };
419    // What each test ran, so a later edit can be told apart from code the test
420    // never reached. Evidence that will not analyse leaves the record out.
421    if let Some(executions) = analysed.and_then(|report| {
422        executions(
423            report,
424            &input.manifest,
425            current.as_ref().ok().map(|inputs| &inputs.files),
426        )
427    }) {
428        write_json(root, &run, EXECUTIONS_FILE, &executions)?;
429    }
430    // A cache: a run that cannot write it is read from its archive instead.
431    let _ = write_json(
432        root,
433        &run,
434        MANIFEST_CACHE_FILE,
435        &ManifestCache {
436            evidence_digest: input.evidence_digest.clone(),
437            manifest: input.manifest.clone(),
438            statement_exclusions: input.statement_exclusions.clone(),
439        },
440    );
441    Ok(())
442}
443/// Each test's execution, placed in the declarations of the run's own
444/// manifest: for every probe a test fired, the innermost unit holding it.
445/// Also which units hold a probe at all, per file, since only a change
446/// confined to such units can be said to have missed a test. Sources are
447/// needed to place JavaScript columns; without them there is no record.
448pub fn executions(
449    coverage: &CoverageReport,
450    manifest: &InputManifest,
451    sources: Option<&Files>,
452) -> Option<Executions> {
453    // Each point's file, by index into `files`, and unit. Hits are looked up
454    // millions of times on a large run, so the lookup hashes with Fx and a
455    // test's hits are grouped by file index before any name is copied.
456    let mut located: crate::interned::FastMap<&str, (usize, usize)> = Default::default();
457    let mut files: Vec<&str> = Vec::new();
458    let mut file_indexes: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
459    let mut probed: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
460    for point in &coverage.view.points {
461        let meta = &point.meta;
462        let Some(code) = manifest
463            .files
464            .get(&meta.file)
465            .and_then(|fingerprint| fingerprint.code.as_ref())
466        else {
467            continue;
468        };
469        let column = if manifest.language == "javascript" {
470            let source = sources?.get(&meta.file)?;
471            let Some(column) = byte_column(source, meta.line, meta.column, &manifest.language)
472            else {
473                continue;
474            };
475            column
476        } else {
477            meta.column + 1
478        };
479        let unit = code.unit_at(meta.line, column);
480        let file = *file_indexes.entry(meta.file.as_str()).or_insert_with(|| {
481            files.push(meta.file.as_str());
482            files.len() - 1
483        });
484        located.insert(meta.id.as_str(), (file, unit));
485        if code.units[unit].is_code() {
486            probed.entry(meta.file.clone()).or_default().insert(unit);
487        }
488    }
489    // Everything the run reached, whoever reached it. Collected before the
490    // per-test loop because the record that holds what no test could be
491    // credited with has no test file, and the loop below skips it -- which is
492    // exactly the record a parallel suite's coverage lives in.
493    let mut covered_by_file = vec![Vec::new(); files.len()];
494    for test in &coverage.view.tests {
495        for hit in &test.hits {
496            if let Some((file, unit)) = located.get(hit.as_str()) {
497                covered_by_file[*file].push(*unit);
498            }
499        }
500    }
501    let covered = files
502        .iter()
503        .zip(covered_by_file)
504        .filter(|(_, units)| !units.is_empty())
505        .map(|(file, units)| {
506            (
507                (*file).to_owned(),
508                units.into_iter().collect::<BTreeSet<_>>(),
509            )
510        })
511        .collect::<BTreeMap<_, _>>();
512
513    let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
514    for test in &coverage.view.tests {
515        let Some(file) = &test.file else {
516            continue;
517        };
518        let selector = TestSelector {
519            file: file.clone(),
520            name: test.name.clone(),
521        };
522        let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
523            test: selector,
524            passed: false,
525            files: BTreeMap::new(),
526            attribution: crate::coverage_report::ATTRIBUTION_EXACT.to_owned(),
527        });
528        record.passed |= test.outcome == "passed";
529        // One name can be recorded more than once -- retries, a test declared
530        // in two files -- and a single incomplete appearance is enough to make
531        // the whole record's file list a lower bound.
532        if !crate::coverage_report::coverage_is_complete(&record.attribution) {
533            // already the weaker claim
534        } else if !crate::coverage_report::coverage_is_complete(&test.attribution) {
535            record.attribution = test.attribution.clone();
536        }
537        let mut reached = test
538            .hits
539            .iter()
540            .filter_map(|hit| located.get(hit.as_str()).copied())
541            .collect::<Vec<_>>();
542        reached.sort_unstable();
543        for group in reached.chunk_by(|left, right| left.0 == right.0) {
544            record
545                .files
546                .entry(files[group[0].0].to_owned())
547                .or_default()
548                .extend(group.iter().map(|(_, unit)| *unit));
549        }
550    }
551    let mut tests = tests.into_values().collect::<Vec<_>>();
552    for record in &mut tests {
553        for units in record.files.values_mut() {
554            units.sort_unstable();
555            units.dedup();
556        }
557    }
558    Some(Executions {
559        tests,
560        probed: probed
561            .into_iter()
562            .map(|(file, units)| (file, units.into_iter().collect()))
563            .collect(),
564        covered: covered
565            .into_iter()
566            .map(|(file, units)| (file, units.into_iter().collect()))
567            .collect(),
568    })
569}
570/// Which of a run's tests the current checkout's changes could have reached,
571/// from what each test executed and what has changed since, declaration by
572/// declaration. A test is affected by a change in code it ran, in its test
573/// file, or in a file it ran code in whose declarations changed shape; not by
574/// a change confined to code it never ran; not by comments or blank lines. A
575/// test that did not pass is listed as affected regardless: it has a result to
576/// establish. What this cannot see is a file the run never captured -- a file
577/// added since -- and the run's dependencies and configuration, which the
578/// working-tree check answers for.
579pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
580    let stored = load_manifest(run)?;
581    let executions: Executions = fs::read(run.directory.join(EXECUTIONS_FILE))
582        .ok()
583        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
584        .ok_or(
585            "This run has no per-test execution record; rerun tests with this version of Supercov",
586        )?;
587    let executions = &executions;
588    let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
589    let manifest = &stored.manifest;
590    let now = manifest
591        .files
592        .keys()
593        .filter_map(|file| {
594            let path = root.join(file);
595            let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
596            if !canonical.starts_with(&root) || !canonical.is_file() {
597                return None;
598            }
599            let text = fs::read_to_string(canonical).ok()?;
600            Some((file.clone(), FileFingerprint::read(file, &text)))
601        })
602        .collect::<FileManifest>();
603    let changes = manifest
604        .files
605        .keys()
606        .filter_map(|file| {
607            file_change(
608                manifest.files.get(file),
609                now.get(file),
610                executions.probed.get(file).map(Vec::as_slice),
611            )
612            .map(|change| (file.as_str(), change))
613        })
614        .collect::<BTreeMap<_, _>>();
615    let files = changes
616        .iter()
617        .filter_map(|(file, change)| {
618            let (kind, detail) = match change {
619                FileChange::Same | FileChange::Added => return None,
620                FileChange::CommentsOnly => ("formatting", None),
621                FileChange::Removed => ("removed", None),
622                FileChange::Bytes => ("changed", None),
623                FileChange::Code {
624                    before,
625                    after,
626                    diff,
627                    narrow,
628                } => (
629                    if *narrow { "bodies" } else { "declarations" },
630                    Some(describe(before, after, diff)),
631                ),
632            };
633            Some(json!({"file":file,"change":kind,"detail":detail}))
634        })
635        .collect::<Vec<_>>();
636    // What a test whose own reach is unknown could have reached.
637    //
638    // Nothing can narrow such a test below the run it ran in, and nothing
639    // needs to go wider: its reach is bounded above by what the run covered.
640    // So a change inside that bound could have reached it, and a change
641    // outside it -- code no test in the run ever ran, a file added since --
642    // could not have reached any test, this one included.
643    //
644    // That is a real narrowing rather than "always affected". It is also the
645    // only safe direction: reading an empty file list as "this change missed
646    // it" is what let a change to code every test exercised report `0 of 2
647    // tests affected` and exit 0, which an agent running only affected tests
648    // would act on by running nothing.
649    let beyond_the_run = changes
650        .iter()
651        .filter_map(|(file, change)| match change {
652            FileChange::Same | FileChange::CommentsOnly | FileChange::Added => None,
653            FileChange::Removed => Some(format!("{file} removed (the run covered code in it)")),
654            FileChange::Bytes => Some(format!("{file} changed (the run covered code in it)")),
655            FileChange::Code { before, diff, .. } => {
656                // Unit by unit rather than file by file: a function no test
657                // ran sits in the same file as the ones they did, so asking
658                // whether the file was covered would make every change to it
659                // reach every test.
660                let code = manifest.files.get(*file).and_then(|f| f.code.as_ref());
661                let reached = executions
662                    .covered
663                    .get(*file)
664                    .map(Vec::as_slice)
665                    .unwrap_or_default()
666                    .iter()
667                    .flat_map(|unit| match code {
668                        Some(code) if *unit < code.units.len() => {
669                            code.ancestors(*unit).collect::<Vec<_>>()
670                        }
671                        _ => vec![*unit],
672                    })
673                    .collect::<BTreeSet<_>>();
674                let hit = diff
675                    .changed
676                    .iter()
677                    .filter(|unit| reached.contains(unit))
678                    .filter_map(|unit| before.units.get(*unit))
679                    .collect::<Vec<_>>();
680                (!hit.is_empty())
681                    .then(|| format!("{file}: {} changed (the run covered it)", named(hit)))
682            }
683        })
684        .collect::<Vec<_>>();
685
686    // A JVM class's static initializer runs once per process and is credited
687    // to the first test to load the class, so a test that only reads a static
688    // field it set ran nothing recorded in that file. For a change to a
689    // class's declarations, a test whose own file names the class is affected
690    // too. (A module's top level in Python, Ruby, JavaScript or Go runs once
691    // as well, but replaying their histories missed no failing test there and
692    // the same rule grew dry-inflector's selection from 73% to 90% of its
693    // tests, so it is the JVM's alone.)
694    let named_by_tests = named_by_tests(&changes);
695    let mut test_sources = BTreeMap::<&str, Option<String>>::new();
696
697    let mut affected = Vec::new();
698    let mut undetermined = Vec::new();
699    let mut unaffected = Vec::new();
700    for record in &executions.tests {
701        let mut reasons = Vec::new();
702        // The changed declarations this test ran, where: what they held in
703        // the run (their own lines, nested declarations aside) and where
704        // they are now, so a caller can say which of their statements the
705        // test is known to check.
706        let mut changed_code = Vec::new();
707        if !record.passed {
708            reasons.push("did not pass in the run".to_owned());
709        }
710        match changes.get(record.test.file.as_str()) {
711            None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
712            Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
713            Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
714            Some(FileChange::Code {
715                before,
716                after,
717                diff,
718                ..
719            }) => reasons.push(format!(
720                "test file changed: {}",
721                describe(before, after, diff)
722            )),
723        }
724        for (file, units) in &record.files {
725            let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
726            let ran = units
727                .iter()
728                .flat_map(|unit| match code {
729                    Some(code) if *unit < code.units.len() => {
730                        code.ancestors(*unit).collect::<Vec<_>>()
731                    }
732                    _ => vec![*unit],
733                })
734                .collect::<BTreeSet<_>>();
735            match changes.get(file.as_str()) {
736                None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
737                Some(FileChange::Removed) => {
738                    reasons.push(format!("{file} removed (this test ran code in it)"));
739                }
740                Some(FileChange::Bytes) => {
741                    reasons.push(format!("{file} changed (this test ran code in it)"));
742                }
743                Some(FileChange::Code {
744                    before,
745                    after,
746                    diff,
747                    narrow,
748                }) => {
749                    if *narrow {
750                        let hit = diff
751                            .changed
752                            .iter()
753                            .filter(|i| ran.contains(i))
754                            .map(|i| &before.units[*i])
755                            .collect::<Vec<_>>();
756                        let now = after.by_path();
757                        for index in diff.changed.iter().filter(|i| ran.contains(i)) {
758                            let unit = &before.units[*index];
759                            changed_code.push(json!({
760                                "file": file,
761                                "declaration": unit.path,
762                                "own": own_lines(before, *index),
763                                "now": now.get(unit.path.as_str()).map(|u| [u.line, u.end_line]),
764                            }));
765                        }
766                        if !hit.is_empty() {
767                            reasons
768                                .push(format!("{file}: {} changed (this test ran it)", named(hit)));
769                        }
770                    } else {
771                        reasons.push(format!(
772                            "{file}: {} changed (this test ran code in this file)",
773                            describe(before, after, diff)
774                        ));
775                    }
776                }
777            }
778        }
779        for (file, names) in &named_by_tests {
780            if record.files.contains_key(*file) || *file == record.test.file.as_str() {
781                continue; // what it ran there already decided
782            }
783            let source = test_sources
784                .entry(record.test.file.as_str())
785                .or_insert_with(|| fs::read_to_string(root.join(&record.test.file)).ok());
786            if let Some(name) = source
787                .as_deref()
788                .and_then(|text| names.iter().find(|name| names_word(text, name)))
789            {
790                reasons.push(format!(
791                    "{file}: declarations changed, and the test file names {name} (code that runs once per process is credited to the first test to reach it)"
792                ));
793            }
794        }
795        if !crate::coverage_report::coverage_is_complete(&record.attribution) {
796            // What its own record proves is the strong claim, and it keeps it:
797            // a Ruby test credited with the changed line ran the changed line,
798            // whatever else it also ran unrecorded.
799            let proven = !reasons.is_empty();
800            // The run's bound is what stands in for a record this test does
801            // not have. Where it has one that already proves the change
802            // reached it, saying so twice says nothing twice.
803            if !proven {
804                reasons.extend(beyond_the_run.iter().cloned());
805                reasons.sort();
806                reasons.dedup();
807            }
808            let entry = json!({
809                "file": record.test.file,
810                "name": record.test.name,
811                "reasons": reasons,
812                "attribution": record.attribution,
813            });
814            if proven {
815                affected.push(entry);
816            } else if reasons.is_empty() {
817                unaffected.push(entry);
818            } else {
819                undetermined.push(entry);
820            }
821            continue;
822        }
823        let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons,"changedCode":changed_code});
824        if reasons.is_empty() {
825            unaffected.push(entry);
826        } else {
827            affected.push(entry);
828        }
829    }
830    // Most likely to fail first: a test whose own file or name shares words
831    // with a changed file (`test_receivebuffer.py` for `_receivebuffer.py`).
832    // Replaying real commits in nine projects, this put a failing test in the
833    // first three for 20 of 57 breaking commits where file order managed 13
834    // (25 with assertion verdicts breaking its ties).
835    let changed_words = changes
836        .iter()
837        .filter(|(_, change)| !matches!(change, FileChange::Same | FileChange::CommentsOnly))
838        .flat_map(|(file, _)| words_of(file))
839        .collect::<BTreeSet<_>>();
840    for bucket in [&mut affected, &mut undetermined] {
841        for entry in bucket.iter_mut() {
842            entry["nameMatch"] = json!(name_match(entry, &changed_words));
843        }
844        bucket.sort_by_key(|entry| std::cmp::Reverse(entry["nameMatch"].as_u64().unwrap_or(0)));
845    }
846    Ok(json!({
847        "run": run.id,
848        "affected": affected,
849        // Kept apart from `affected` because they are a different claim. A
850        // test is affected when a change reached code it is recorded as having
851        // run; it is undetermined when nothing recorded what it ran and the
852        // change is inside what the run as a whole covered. Merging them would
853        // report a precision the run does not have -- but both have to be run,
854        // so both are in `--names`.
855        "undetermined": undetermined,
856        "unaffected": unaffected,
857        "changedFiles": files,
858        "summary": {
859            "tests": executions.tests.len(),
860            "affected": affected.len(),
861            "undetermined": undetermined.len(),
862            "unaffected": unaffected.len(),
863            "changedFiles": files.len(),
864        },
865        "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."
866    }))
867}
868/// For each changed JVM file whose declarations changed, the names a test
869/// file may use to reach it: its top-level classes and its own name.
870fn named_by_tests<'a>(
871    changes: &BTreeMap<&'a str, FileChange<'_>>,
872) -> BTreeMap<&'a str, BTreeSet<String>> {
873    changes
874        .iter()
875        .filter(|(file, _)| {
876            [".java", ".kt", ".kts", ".groovy", ".scala"]
877                .iter()
878                .any(|extension| file.ends_with(extension))
879        })
880        .filter_map(|(file, change)| match change {
881            FileChange::Code {
882                before,
883                narrow: false,
884                ..
885            } => {
886                let stem = file.rsplit('/').next()?.split('.').next()?.to_owned();
887                let mut names = before
888                    .units
889                    .iter()
890                    .filter(|unit| unit.parent.is_none_or(|p| before.units[p].path.is_empty()))
891                    .filter_map(|unit| unit.path.rsplit('.').next().map(str::to_owned))
892                    .filter(|name| name.len() > 2)
893                    .collect::<BTreeSet<_>>();
894                if stem.len() > 2
895                    && !matches!(stem.as_str(), "mod" | "lib" | "main" | "index" | "__init__")
896                {
897                    names.insert(stem);
898                }
899                Some((*file, names))
900            }
901            _ => None,
902        })
903        .collect::<BTreeMap<_, _>>()
904}
905
906/// The words a file's name is made of, test markers aside:
907/// `tests/test_receive_buffer.py` -> `receive`, `buffer`;
908/// `ReceiveBufferTest.java` -> `receive`, `buffer`.
909fn words_of(path: &str) -> BTreeSet<String> {
910    let stem = path.rsplit(['/', '\\']).next().unwrap_or(path);
911    let stem = stem.split('.').next().unwrap_or(stem);
912    split_words(stem)
913        .into_iter()
914        .filter(|w| !matches!(w.as_str(), "test" | "tests" | "spec" | "specs"))
915        .collect()
916}
917
918/// `receiveBuffer_v2` -> `receive`, `buffer`, `v2`: camel and snake case
919/// split, lowercased, single letters dropped.
920fn split_words(text: &str) -> Vec<String> {
921    let mut words = Vec::new();
922    let mut current = String::new();
923    let mut previous_lower = false;
924    for c in text.chars() {
925        if !c.is_alphanumeric() {
926            if !current.is_empty() {
927                words.push(std::mem::take(&mut current));
928            }
929            previous_lower = false;
930            continue;
931        }
932        if c.is_uppercase() && previous_lower && !current.is_empty() {
933            words.push(std::mem::take(&mut current));
934        }
935        previous_lower = c.is_lowercase() || c.is_ascii_digit();
936        current.extend(c.to_lowercase());
937    }
938    if !current.is_empty() {
939        words.push(current);
940    }
941    words.into_iter().filter(|w| w.len() > 1).collect()
942}
943
944/// How many of the changed files' words a test's file or name shares.
945fn name_match(entry: &Value, changed: &BTreeSet<String>) -> usize {
946    let file = entry["file"].as_str().unwrap_or("");
947    let name = entry["name"].as_str().unwrap_or("");
948    // The test's own name: after the last separator a runner puts between a
949    // file or class and the test (`::`, `#`, `>`, `.`), parameters aside.
950    let own = name
951        .rsplit(['#', ':', '>', '.', '/'])
952        .next()
953        .unwrap_or(name)
954        .split(['[', '('])
955        .next()
956        .unwrap_or("");
957    let mut words = words_of(file);
958    words.extend(split_words(own));
959    words.intersection(changed).count()
960}
961
962/// Whether `text` holds `word` as a whole identifier.
963fn names_word(text: &str, word: &str) -> bool {
964    text.match_indices(word).any(|(at, _)| {
965        let before = text[..at].chars().last();
966        let after = text[at + word.len()..].chars().next();
967        let part = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
968        !part(before) && !part(after)
969    })
970}
971
972/// A declaration's own lines: its span without the spans of the
973/// declarations nested in it, as inclusive `[first, last]` ranges.
974fn own_lines(code: &crate::source_units::Code, index: usize) -> Vec<[usize; 2]> {
975    let unit = &code.units[index];
976    let mut children = code
977        .units
978        .iter()
979        .filter(|u| u.parent == Some(index))
980        .map(|u| (u.line, u.end_line))
981        .collect::<Vec<_>>();
982    children.sort_unstable();
983    let mut out = Vec::new();
984    let mut next = unit.line;
985    for (start, end) in children {
986        if start > next {
987            out.push([next, start - 1]);
988        }
989        next = next.max(end + 1);
990    }
991    if next <= unit.end_line {
992        out.push([next, unit.end_line]);
993    }
994    out
995}
996
997pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
998    analyze_coverage_archive(&ArchiveReportRequest {
999        archive_path: run.evidence_path.clone(),
1000        run_id: run.id.clone(),
1001        generated_at: run.metadata.started_at.clone(),
1002        integrity: None,
1003        test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
1004    })
1005    .map_err(|e| format!("{e:?}"))
1006}
1007fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
1008    byte_column_in(source, &line_starts(source), line, column, language)
1009}
1010
1011/// `byte_column`, given where each line starts, so that resolving a whole
1012/// file's statements looks each line up instead of walking the file to it.
1013fn byte_column_in(
1014    source: &str,
1015    starts: &[usize],
1016    line: usize,
1017    column: usize,
1018    language: &str,
1019) -> Option<usize> {
1020    if line == 0 {
1021        return None;
1022    }
1023    let line = line_text(source, starts, line)?;
1024    if language != "javascript" {
1025        // Native Rust, Python and Ruby manifests use zero-based byte columns.
1026        return column.checked_add(1);
1027    }
1028    if column == 0 {
1029        return None;
1030    }
1031    let mut units = 0;
1032    for (byte, ch) in line.char_indices() {
1033        if units == column - 1 {
1034            return Some(byte + 1);
1035        }
1036        units += ch.len_utf16();
1037    }
1038    (units == column - 1).then_some(line.len() + 1)
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use crate::evidence_archive::{read_archive, write_archive};
1045    use crate::run_store::discover_runs;
1046
1047    fn prepare_publication(
1048        root: &Path,
1049        directory: &Path,
1050        metadata: &RunMetadata,
1051        analysed: Option<&CoverageReport>,
1052    ) -> Result<(), String> {
1053        prepare_publication_with(root, directory, metadata, analysed, None)
1054    }
1055
1056    fn recorded(run: &StoredRun) -> Executions {
1057        serde_json::from_slice(&fs::read(run.directory.join(EXECUTIONS_FILE)).expect("a record"))
1058            .unwrap()
1059    }
1060
1061    /// `byte_column` as it was before it took line starts.
1062    fn reference_byte_column(
1063        source: &str,
1064        line: usize,
1065        column: usize,
1066        language: &str,
1067    ) -> Option<usize> {
1068        if line == 0 {
1069            return None;
1070        }
1071        let line = source.lines().nth(line - 1)?;
1072        if language != "javascript" {
1073            return column.checked_add(1);
1074        }
1075        if column == 0 {
1076            return None;
1077        }
1078        let mut units = 0;
1079        for (byte, ch) in line.char_indices() {
1080            if units == column - 1 {
1081                return Some(byte + 1);
1082            }
1083            units += ch.len_utf16();
1084        }
1085        (units == column - 1).then_some(line.len() + 1)
1086    }
1087
1088    #[test]
1089    fn a_column_from_line_starts_agrees_with_walking_the_file() {
1090        // JavaScript columns count UTF-16 units, so text whose widths differ --
1091        // accents, and an emoji that is two units -- is where they could part.
1092        for source in [
1093            "",
1094            "a",
1095            "a\n",
1096            "a\r\nb\r\n",
1097            "x\r",
1098            "héllo\nwörld\n",
1099            "😀x\ny😀z\n",
1100            "const a = 1;\n  b();\n",
1101        ] {
1102            let starts = line_starts(source);
1103            for language in ["javascript", "python"] {
1104                for line in 0..source.len() + 3 {
1105                    for column in 0..source.len() + 4 {
1106                        assert_eq!(
1107                            byte_column_in(source, &starts, line, column, language),
1108                            reference_byte_column(source, line, column, language),
1109                            "{language} {line}:{column} in {source:?}"
1110                        );
1111                    }
1112                }
1113            }
1114        }
1115    }
1116
1117    #[test]
1118    fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1119        let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1120        let _ = fs::remove_dir_all(&root);
1121        fs::create_dir_all(root.join("src")).unwrap();
1122        fs::create_dir_all(root.join("tests")).unwrap();
1123        // The fixture's one point sits at line 1, column 0, so `work` has to
1124        // start the file: an `export` keyword there would belong to the top
1125        // level, not to the function.
1126        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
1127        let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1128        let config = "export const LIMIT = 5;\nfunction check(n) {\n  return n < LIMIT;\n}\n";
1129        fs::write(root.join("src/app.js"), app).unwrap();
1130        fs::write(root.join("src/config.js"), config).unwrap();
1131        fs::write(root.join("tests/app.test.js"), test).unwrap();
1132        // The fixture run's one point sits at line 1, column 0 of src/app.js
1133        // with a zero-based byte column, which is how every non-JavaScript
1134        // frontend reports; the language is named for that.
1135        let inputs = crate::source_capture::capture(
1136            &root,
1137            "python",
1138            [
1139                "src/app.js".into(),
1140                "src/config.js".into(),
1141                "tests/app.test.js".into(),
1142            ],
1143        )
1144        .unwrap();
1145        let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1146        let path = directory.join("evidence.raw.gz");
1147        let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1148        let archive = write_archive(entries, &path).unwrap();
1149        let metadata_path = directory.join("run.json");
1150        let mut metadata: RunMetadata =
1151            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1152        metadata.raw_evidence.files = archive.files;
1153        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1154        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1155        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1156        prepare_publication(&root, &directory, &metadata, None).unwrap();
1157        let run = discover_runs(&root).unwrap().runs.remove(0);
1158        let stored = load_manifest(&run).unwrap();
1159        let executions = &recorded(&run);
1160        let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1161        let work = code.units.iter().position(|u| u.path == "work").unwrap();
1162        let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1163        assert_eq!(executions.tests.len(), 1);
1164        let record = &executions.tests[0];
1165        assert_eq!(record.test.file, "tests/app.test.js");
1166        assert_eq!(record.test.name, "test");
1167        assert!(record.passed);
1168        assert_eq!(record.files["src/app.js"], vec![work]);
1169        assert_eq!(
1170            executions.probed["src/app.js"],
1171            vec![work],
1172            "idle holds no probe in this run"
1173        );
1174        let _ = idle;
1175
1176        let names = |value: &Value, key: &str| {
1177            value[key]
1178                .as_array()
1179                .unwrap()
1180                .iter()
1181                .map(|t| t["name"].as_str().unwrap().to_owned())
1182                .collect::<Vec<_>>()
1183        };
1184        // Nothing changed.
1185        let report = affected_tests(&root, &run).unwrap();
1186        assert!(names(&report, "affected").is_empty(), "{report}");
1187        assert_eq!(names(&report, "unaffected"), ["test"]);
1188        assert_eq!(report["summary"]["changedFiles"], 0);
1189        // A comment: still nothing.
1190        fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1191        let report = affected_tests(&root, &run).unwrap();
1192        assert!(names(&report, "affected").is_empty(), "{report}");
1193        assert_eq!(report["changedFiles"][0]["change"], "formatting");
1194        // A body the test ran: affected, and it says which.
1195        fs::write(
1196            root.join("src/app.js"),
1197            app.replace("return 1;", "return 1 + 0;"),
1198        )
1199        .unwrap();
1200        let report = affected_tests(&root, &run).unwrap();
1201        assert_eq!(names(&report, "affected"), ["test"]);
1202        assert_eq!(
1203            report["affected"][0]["reasons"][0],
1204            "src/app.js: work (line 1) changed (this test ran it)"
1205        );
1206        // A body the test did not run, once idle has a probe of its own: not
1207        // affected. Here idle holds none, so the change is not one that can
1208        // be said to have missed the test, and it counts.
1209        fs::write(
1210            root.join("src/app.js"),
1211            app.replace("return 2;", "return 2 + 0;"),
1212        )
1213        .unwrap();
1214        let report = affected_tests(&root, &run).unwrap();
1215        assert_eq!(names(&report, "affected"), ["test"], "{report}");
1216        assert!(
1217            report["affected"][0]["reasons"][0]
1218                .as_str()
1219                .unwrap()
1220                .contains("this test ran code in this file"),
1221            "{report}"
1222        );
1223        // A declaration added: affected, the file's shape changed.
1224        fs::write(
1225            root.join("src/app.js"),
1226            format!("{app}function more() {{}}\n"),
1227        )
1228        .unwrap();
1229        let report = affected_tests(&root, &run).unwrap();
1230        assert_eq!(names(&report, "affected"), ["test"]);
1231        assert_eq!(report["changedFiles"][0]["change"], "declarations");
1232        // The test file itself.
1233        fs::write(root.join("src/app.js"), app).unwrap();
1234        fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1235        let report = affected_tests(&root, &run).unwrap();
1236        assert_eq!(names(&report, "affected"), ["test"]);
1237        assert!(
1238            report["affected"][0]["reasons"][0]
1239                .as_str()
1240                .unwrap()
1241                .starts_with("test file changed"),
1242            "{report}"
1243        );
1244        // Top-level code in a file the test names but never ran a statement
1245        // of: outside the JVM, not affected (a JVM class's static initializer
1246        // is the case the naming rule is for).
1247        fs::write(root.join("tests/app.test.js"), test).unwrap();
1248        fs::write(root.join("src/config.js"), config.replace("= 5", "= 6")).unwrap();
1249        let report = affected_tests(&root, &run).unwrap();
1250        assert!(names(&report, "affected").is_empty(), "{report}");
1251        fs::write(root.join("src/config.js"), config).unwrap();
1252        // A source file removed.
1253        fs::remove_file(root.join("src/app.js")).unwrap();
1254        let report = affected_tests(&root, &run).unwrap();
1255        assert_eq!(
1256            report["affected"][0]["reasons"][0],
1257            "src/app.js removed (this test ran code in it)"
1258        );
1259        fs::remove_dir_all(root).unwrap();
1260    }
1261
1262    /// h11's `test_receivebuffer.py` for `_receivebuffer.py`, commons-cli's
1263    /// `ConverterTests#testClassDoesNotInitialize` for `Converter.java`.
1264    #[test]
1265    fn a_test_named_like_the_changed_file_comes_first() {
1266        assert_eq!(
1267            words_of("h11/tests/test_receive_buffer.py"),
1268            BTreeSet::from(["receive".to_owned(), "buffer".to_owned()])
1269        );
1270        assert_eq!(
1271            words_of("src/test/java/org/apache/commons/cli/ConverterTests.java"),
1272            BTreeSet::from(["converter".to_owned()])
1273        );
1274        assert_eq!(
1275            split_words("parseHTTPVersion_v2"),
1276            ["parse", "httpversion", "v2"]
1277        );
1278        let changed = words_of("src/main/java/org/apache/commons/cli/Converter.java");
1279        let entry = |file: &str, name: &str| json!({"file": file, "name": name});
1280        assert_eq!(
1281            name_match(
1282                &entry(
1283                    "src/test/java/x/ConverterTests.java",
1284                    "x.ConverterTests#testClass()"
1285                ),
1286                &changed
1287            ),
1288            1
1289        );
1290        assert_eq!(
1291            name_match(
1292                &entry(
1293                    "src/test/java/x/OptionTests.java",
1294                    "x.OptionTests#testConverter()"
1295                ),
1296                &changed
1297            ),
1298            1,
1299            "the test's own name counts too"
1300        );
1301        assert_eq!(
1302            name_match(
1303                &entry(
1304                    "src/test/java/x/OptionTests.java",
1305                    "x.OptionTests#testBuilder()"
1306                ),
1307                &changed
1308            ),
1309            0
1310        );
1311    }
1312
1313    /// apache/commons-cli's `Converter.CLASS`: a static field set in a
1314    /// static initializer credited to whichever test loaded the class first.
1315    /// Its tests are reached by the class's name; a JavaScript module's top
1316    /// level is not.
1317    #[test]
1318    fn a_jvm_class_changed_in_its_declarations_is_reached_by_its_name() {
1319        let java = "package app;\nclass Converter {\n    static final Object CLASS = null;\n    static int size(int n) {\n        return n;\n    }\n}\n";
1320        let before = FileFingerprint::read("src/app/Converter.java", java);
1321        let after = FileFingerprint::read(
1322            "src/app/Converter.java",
1323            &java.replace("CLASS = null", "CLASS = new Object()"),
1324        );
1325        let js = "export const LIMIT = 5;\n";
1326        let js_before = FileFingerprint::read("src/config.js", js);
1327        let js_after = FileFingerprint::read("src/config.js", &js.replace('5', "6"));
1328        let changes = BTreeMap::from([
1329            (
1330                "src/app/Converter.java",
1331                file_change(Some(&before), Some(&after), Some(&[])).unwrap(),
1332            ),
1333            (
1334                "src/config.js",
1335                file_change(Some(&js_before), Some(&js_after), Some(&[])).unwrap(),
1336            ),
1337        ]);
1338        let named = named_by_tests(&changes);
1339        assert_eq!(
1340            named.get("src/app/Converter.java"),
1341            Some(&BTreeSet::from(["Converter".to_owned()]))
1342        );
1343        assert!(!named.contains_key("src/config.js"));
1344        assert!(names_word("assertEquals(Converter.CLASS, x)", "Converter"));
1345        assert!(!names_word("ConverterTests", "Converter"));
1346    }
1347
1348    #[test]
1349    fn a_test_that_ran_beside_others_is_undetermined_rather_than_unaffected() {
1350        // A parallel suite's coverage sits in the record nobody could claim,
1351        // and the tests beside it hold nothing of their own. Reading "this
1352        // test's record does not mention the changed file" as "this test is
1353        // unaffected" would answer `0 of 1 tests affected` for a change to the
1354        // only code the run executed -- and an agent running only the affected
1355        // tests would run nothing. The run's own bound is what stands in.
1356        let root = std::env::temp_dir().join(format!(
1357            "supercov-undetermined-{}-{}",
1358            std::process::id(),
1359            std::time::SystemTime::now()
1360                .duration_since(std::time::UNIX_EPOCH)
1361                .unwrap()
1362                .as_nanos()
1363        ));
1364        let _ = fs::remove_dir_all(&root);
1365        fs::create_dir_all(root.join("src")).unwrap();
1366        fs::create_dir_all(root.join("tests")).unwrap();
1367        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
1368        let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1369        let config = "export const LIMIT = 5;\nfunction check(n) {\n  return n < LIMIT;\n}\n";
1370        fs::write(root.join("src/app.js"), app).unwrap();
1371        fs::write(root.join("src/config.js"), config).unwrap();
1372        fs::write(root.join("tests/app.test.js"), test).unwrap();
1373        let inputs = crate::source_capture::capture(
1374            &root,
1375            "python",
1376            ["src/app.js".into(), "tests/app.test.js".into()],
1377        )
1378        .unwrap();
1379        let directory = crate::run_store::create_run_wide_test_run(&root, "parallel");
1380        let path = directory.join("evidence.raw.gz");
1381        let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1382        let archive = write_archive(entries, &path).unwrap();
1383        let metadata_path = directory.join("run.json");
1384        let mut metadata: RunMetadata =
1385            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1386        metadata.raw_evidence.files = archive.files;
1387        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1388        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1389        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1390        prepare_publication(&root, &directory, &metadata, None).unwrap();
1391        let run = discover_runs(&root).unwrap().runs.remove(0);
1392
1393        let executions = &recorded(&run);
1394        assert_eq!(executions.tests.len(), 1, "the named test, not the record");
1395        let record = &executions.tests[0];
1396        assert_eq!(
1397            record.attribution,
1398            crate::coverage_report::ATTRIBUTION_RUN_WIDE
1399        );
1400        assert!(
1401            record.files.is_empty(),
1402            "it is credited with nothing: {:?}",
1403            record.files
1404        );
1405        assert!(
1406            !executions.covered["src/app.js"].is_empty(),
1407            "and the run is credited with what it reached"
1408        );
1409
1410        let bucket = |value: &Value, key: &str| {
1411            value[key]
1412                .as_array()
1413                .unwrap()
1414                .iter()
1415                .map(|t| t["name"].as_str().unwrap().to_owned())
1416                .collect::<Vec<_>>()
1417        };
1418
1419        // Nothing changed: nothing to rerun, and the run's bound says nothing
1420        // either.
1421        let report = affected_tests(&root, &run).unwrap();
1422        assert_eq!(bucket(&report, "unaffected"), ["test"], "{report}");
1423        assert!(bucket(&report, "undetermined").is_empty(), "{report}");
1424
1425        // The one function the run executed changed. The test's own record
1426        // cannot say whether it reached it, and the run's can.
1427        fs::write(
1428            root.join("src/app.js"),
1429            app.replace("return 1;", "return 3;"),
1430        )
1431        .unwrap();
1432        let report = affected_tests(&root, &run).unwrap();
1433        assert!(
1434            bucket(&report, "affected").is_empty(),
1435            "nothing proves it ran the change: {report}"
1436        );
1437        assert_eq!(
1438            bucket(&report, "undetermined"),
1439            ["test"],
1440            "the run reached it, so this test might have: {report}"
1441        );
1442        assert_eq!(
1443            report["undetermined"][0]["attribution"],
1444            crate::coverage_report::ATTRIBUTION_RUN_WIDE,
1445            "{report}"
1446        );
1447        fs::remove_dir_all(root).unwrap();
1448    }
1449
1450    #[test]
1451    fn a_lower_bound_still_proves_what_it_recorded() {
1452        // Ruby's is the other incomplete attribution: the test is credited
1453        // with coverage, and that coverage is a floor rather than the whole of
1454        // what it ran. The floor is still evidence -- a test credited with the
1455        // changed line ran the changed line -- so it is affected outright,
1456        // with its own reason, rather than swept into the bucket for tests
1457        // nothing can say anything about.
1458        let root = std::env::temp_dir().join(format!(
1459            "supercov-partial-{}-{}",
1460            std::process::id(),
1461            std::time::SystemTime::now()
1462                .duration_since(std::time::UNIX_EPOCH)
1463                .unwrap()
1464                .as_nanos()
1465        ));
1466        let _ = fs::remove_dir_all(&root);
1467        fs::create_dir_all(root.join("src")).unwrap();
1468        fs::create_dir_all(root.join("tests")).unwrap();
1469        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
1470        let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1471        let config = "export const LIMIT = 5;\nfunction check(n) {\n  return n < LIMIT;\n}\n";
1472        fs::write(root.join("src/app.js"), app).unwrap();
1473        fs::write(root.join("src/config.js"), config).unwrap();
1474        fs::write(root.join("tests/app.test.js"), test).unwrap();
1475        let inputs = crate::source_capture::capture(
1476            &root,
1477            "python",
1478            ["src/app.js".into(), "tests/app.test.js".into()],
1479        )
1480        .unwrap();
1481        let directory = crate::run_store::create_partial_test_run(&root, "partial");
1482        let path = directory.join("evidence.raw.gz");
1483        let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1484        let archive = write_archive(entries, &path).unwrap();
1485        let metadata_path = directory.join("run.json");
1486        let mut metadata: RunMetadata =
1487            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1488        metadata.raw_evidence.files = archive.files;
1489        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1490        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1491        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1492        prepare_publication(&root, &directory, &metadata, None).unwrap();
1493        let run = discover_runs(&root).unwrap().runs.remove(0);
1494
1495        let executions = recorded(&run);
1496        let record = &executions.tests[0];
1497        assert_eq!(
1498            record.attribution,
1499            crate::coverage_report::ATTRIBUTION_PARTIAL
1500        );
1501        assert!(
1502            !record.files.is_empty(),
1503            "a lower bound is coverage it was credited with"
1504        );
1505
1506        let names = |value: &Value, key: &str| {
1507            value[key]
1508                .as_array()
1509                .unwrap()
1510                .iter()
1511                .map(|t| t["name"].as_str().unwrap().to_owned())
1512                .collect::<Vec<_>>()
1513        };
1514        fs::write(
1515            root.join("src/app.js"),
1516            app.replace("return 1;", "return 3;"),
1517        )
1518        .unwrap();
1519        let report = affected_tests(&root, &run).unwrap();
1520        assert_eq!(
1521            names(&report, "affected"),
1522            ["test"],
1523            "what its own record proves, it proves: {report}"
1524        );
1525        assert!(names(&report, "undetermined").is_empty(), "{report}");
1526        // And the reason is its own, not the run's stand-in.
1527        let reasons = report["affected"][0]["reasons"].as_array().unwrap();
1528        assert_eq!(reasons.len(), 1, "{report}");
1529        let reason = reasons[0].as_str().unwrap();
1530        assert!(
1531            reason.contains("this test ran it"),
1532            "the reason is its own: {report}"
1533        );
1534        assert!(
1535            !reason.contains("the run covered"),
1536            "and not the run's stand-in: {report}"
1537        );
1538        fs::remove_dir_all(root).unwrap();
1539    }
1540}