Skip to main content

supercov_engine/
assertion_store.rs

1//! Run-owned assertion map lifecycle and execution-backed, agent-assessed score.
2use crate::{
3    assertion_inputs::ARCHIVE_PATH,
4    assertion_map::{self as model, *},
5    coverage_report::{
6        ArchiveReportRequest, CoverageReport, ExitCodeInput, analyze_coverage_archive,
7    },
8    evidence_archive::read_archive_selected,
9    lifecycle::atomic_write,
10    run_store::{RunFingerprint, RunMetadata, StoredRun, discover_runs},
11    source_units::named,
12};
13use serde_json::{Value, json};
14use sha2::{Digest, Sha256};
15use std::{
16    collections::{BTreeMap, BTreeSet},
17    fs,
18    path::Path,
19};
20
21pub const MAP_FILE: &str = "assertions.json";
22pub const STATE_FILE: &str = "assertions.state.json";
23const REPORT_CACHE_FILE: &str = "assertions.report.cache.json";
24/// Kept apart from the full report so a summary can never be read back as one.
25const SUMMARY_CACHE_FILE: &str = "assertions.summary.cache.json";
26/// The run's archived assertion inputs, written beside the archive at
27/// publication so a later run inheriting its map need not decompress a
28/// whole archive for one entry. Used only while the archive's digest is the
29/// one it records.
30const INPUTS_CACHE_FILE: &str = "assertions.inputs.cache.json";
31
32#[derive(serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34struct InputsCache {
35    evidence_digest: String,
36    manifest: InputManifest,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    legacy_digest: Option<String>,
39    #[serde(default)]
40    statement_exclusions: Vec<Value>,
41}
42pub struct RunManifest {
43    pub manifest: InputManifest,
44    pub evidence_digest: String,
45    legacy_digest: Option<String>,
46    pub statement_exclusions: Vec<Value>,
47}
48pub struct RunInputs {
49    pub inputs: Inputs,
50    pub stored: RunManifest,
51}
52impl std::ops::Deref for RunInputs {
53    type Target = RunManifest;
54    fn deref(&self) -> &RunManifest {
55        &self.stored
56    }
57}
58pub fn load_inputs(root: &Path, run: &StoredRun) -> Result<RunInputs, String> {
59    let stored = load_manifest(run)?;
60    let inputs = crate::assertion_inputs::current_sources(root, &stored.manifest)?;
61    Ok(RunInputs { inputs, stored })
62}
63pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
64    load_optional_manifest(run)?.ok_or_else(|| {
65        "This older run has no assertion manifest. Run tests once with this version of Supercov"
66            .into()
67    })
68}
69fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
70    if run.metadata.merged == Some(true) {
71        return Err(
72            "Use a single run for assertion maps; merged runs have multiple input manifests".into(),
73        );
74    }
75    let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
76    let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
77    if let Some(cached) = fs::read(run.directory.join(INPUTS_CACHE_FILE))
78        .ok()
79        .and_then(|bytes| serde_json::from_slice::<InputsCache>(&bytes).ok())
80        .filter(|cached| cached.evidence_digest == evidence_digest)
81        .filter(|cached| validate_manifest(&cached.manifest).is_ok())
82    {
83        return Ok(Some(RunManifest {
84            manifest: cached.manifest,
85            evidence_digest,
86            legacy_digest: cached.legacy_digest,
87            statement_exclusions: cached.statement_exclusions,
88        }));
89    }
90    let entries = read_archive_selected(&run.evidence_path, |path| {
91        path == ARCHIVE_PATH || path == "statement-exclusions.json"
92    })
93    .map_err(|e| e.to_string())?;
94    let Some(input) = entries.iter().find(|e| e.path == ARCHIVE_PATH) else {
95        return Ok(None);
96    };
97    let value: Value = serde_json::from_slice(&input.contents).map_err(|e| e.to_string())?;
98    let (manifest, legacy_digest) = match value["schemaVersion"].as_u64() {
99        Some(2) => (
100            serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?,
101            None,
102        ),
103        Some(1) => {
104            // Import old maps without requiring their former checkout. Legacy
105            // sources are used only to obtain hashes, never as today's source.
106            let legacy: Inputs = serde_json::from_value(value).map_err(|e| e.to_string())?;
107            if legacy
108                .assertions
109                .iter()
110                .any(|s| s.at.offset(&legacy.files).is_none())
111            {
112                return Err("Invalid legacy assertion inputs".into());
113            }
114            (legacy.manifest(), Some(digest(&legacy)))
115        }
116        _ => return Err("Unsupported assertion input schema".into()),
117    };
118    validate_manifest(&manifest)?;
119    if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
120        return Err("Run archive changed during read".into());
121    }
122    Ok(Some(RunManifest {
123        manifest,
124        evidence_digest,
125        legacy_digest,
126        statement_exclusions: entries
127            .iter()
128            .find(|e| e.path == "statement-exclusions.json")
129            .map(|entry| serde_json::from_slice(&entry.contents).map_err(|e| e.to_string()))
130            .transpose()?
131            .unwrap_or_default(),
132    }))
133}
134
135fn validate_manifest(manifest: &InputManifest) -> Result<(), String> {
136    if manifest.files.iter().any(|(p, f)| {
137        !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
138    }) || manifest.assertions.iter().any(|s| {
139        !manifest.files.contains_key(&s.at.file)
140            || s.at.line == 0
141            || s.at.column == 0
142            || s.at.text.is_empty()
143    }) {
144        return Err("Invalid assertion input manifest".into());
145    }
146    Ok(())
147}
148
149/// The manifest a run archived, from the inputs the frontend archived it
150/// from and the digest publication took of the archive: what reading the
151/// archive back would give, without decompressing it to find one entry.
152pub(crate) fn archived_manifest(
153    manifest: InputManifest,
154    evidence_digest: String,
155) -> Result<RunManifest, String> {
156    validate_manifest(&manifest)?;
157    Ok(RunManifest {
158        manifest,
159        evidence_digest,
160        legacy_digest: None,
161        statement_exclusions: Vec::new(),
162    })
163}
164pub fn load(run: &StoredRun, input: &RunManifest) -> Result<(AssertionMap, State), String> {
165    let read = |file: &str| {
166        fs::read(run.directory.join(file))
167            .map_err(|e| format!("{file}: {e}; new test runs create assertion maps automatically"))
168    };
169    let map = model::parse_stored(&read(MAP_FILE)?).map_err(|e| format!("{MAP_FILE}: {e}"))?;
170    let state = model::parse_state(
171        &read(STATE_FILE)?,
172        &map,
173        &input.manifest,
174        &input.evidence_digest,
175        input.legacy_digest.as_deref(),
176    )?;
177    Ok((map, state))
178}
179fn write_json(
180    root: &Path,
181    run: &StoredRun,
182    name: &str,
183    value: &impl serde::Serialize,
184) -> Result<(), String> {
185    let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
186    bytes.push(b'\n');
187    atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
188}
189/// Create the map inside the unpublished run directory. The lifecycle publishes
190/// evidence, map and review state together with one directory rename. Older
191/// archives without assertion manifests and merged runs retain their existing behavior.
192///
193/// `analysed` is the run's coverage when the caller has already analysed its
194/// evidence -- publication does so once for everything it derives. Without it
195/// the archive is analysed here.
196#[cfg(test)]
197pub(crate) fn prepare_publication(
198    root: &Path,
199    directory: &Path,
200    metadata: &RunMetadata,
201    analysed: Option<&CoverageReport>,
202) -> Result<(), String> {
203    prepare_publication_with(root, directory, metadata, analysed, None)
204}
205
206/// `prepare_publication`, with the run's archived manifest when the caller
207/// holds it (see `archived_manifest`).
208pub(crate) fn prepare_publication_with(
209    root: &Path,
210    directory: &Path,
211    metadata: &RunMetadata,
212    analysed: Option<&CoverageReport>,
213    archived: Option<RunManifest>,
214) -> Result<(), String> {
215    if metadata.merged == Some(true) {
216        return Ok(());
217    }
218    let run = StoredRun {
219        id: metadata.id.clone(),
220        directory: directory.into(),
221        evidence_path: directory.join("evidence.raw.gz"),
222        metadata_path: directory.join("run.json"),
223        query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
224        metadata: metadata.clone(),
225    };
226    let input = match archived {
227        Some(input) => input,
228        None => match load_optional_manifest(&run)? {
229            Some(input) => input,
230            None => return Ok(()),
231        },
232    };
233    // Refuse replacement even if this helper is accidentally called twice.
234    if directory.join(MAP_FILE).exists() || directory.join(STATE_FILE).exists() {
235        return Err("Refusing to replace an existing assertion map or review state".into());
236    }
237    let current = crate::assertion_inputs::current_sources(root, &input.manifest);
238    let inventory = discover_runs(root).map_err(|e| e.to_string())?;
239    let mut inheritance = Inheritance::default();
240    let mut inherited = None;
241    for previous in &inventory.runs {
242        if previous.id == run.id
243            || previous.metadata.merged == Some(true)
244            || previous.metadata.command != metadata.command
245            || (!previous.directory.join(MAP_FILE).exists()
246                && !previous.directory.join(STATE_FILE).exists())
247        {
248            continue;
249        }
250        let attempt = (|| {
251            let old = load_manifest(previous)?;
252            if old.manifest.language != input.manifest.language {
253                return Ok(None);
254            }
255            let (map, state) = load(previous, &old)?;
256            let a = &previous.metadata.integrity.fingerprint;
257            let b = &metadata.integrity.fingerprint;
258            let delta = context_delta(
259                a,
260                b,
261                &old.manifest.context_digest,
262                &input.manifest.context_digest,
263            );
264            let context_changed = delta.execution;
265            let dependencies_changed = delta.dependencies;
266            let current = match &current {
267                Ok(current) => current,
268                Err(reason) => {
269                    // Publish the run even if files were edited during testing.
270                    // Preserve authored work as suggestions instead of guessing
271                    // locations in a checkout that no longer matches this run.
272                    let (mut next, mut next_state) =
273                        seed_manifest(&input.manifest, &input.evidence_digest);
274                    next.retired_assertions = map.retired_assertions;
275                    for assertion in map.assertions {
276                        if let Some(site) =
277                            next.assertions.iter_mut().find(|a| a.at == assertion.at)
278                        {
279                            *site = assertion;
280                        } else {
281                            next.retired_assertions.push(Retired {
282                                assertion,
283                                reason: reason.clone(),
284                            });
285                        }
286                    }
287                    let mut reserved = next
288                        .retired_assertions
289                        .iter()
290                        .map(|r| r.assertion.id.clone())
291                        .chain(
292                            next.assertions
293                                .iter()
294                                .filter(|a| !a.flows.is_empty())
295                                .map(|a| a.id.clone()),
296                        )
297                        .collect::<BTreeSet<_>>();
298                    for assertion in next.assertions.iter_mut().filter(|a| a.flows.is_empty()) {
299                        while !reserved.insert(assertion.id.clone()) {
300                            assertion.id.push('_');
301                        }
302                    }
303                    invalidate(&mut next_state, &next, reason);
304                    add_change(
305                        &mut next_state,
306                        None,
307                        None,
308                        None,
309                        reason.clone(),
310                        BTreeSet::new(),
311                        BTreeSet::new(),
312                    );
313                    return Ok(Some((next, next_state)));
314                }
315            };
316            model::carry(
317                &map,
318                &state,
319                &old.manifest,
320                current,
321                &input.evidence_digest,
322                context_changed,
323            )
324            .map(|(next, mut next_state)| {
325                if dependencies_changed {
326                    add_change(
327                        &mut next_state,
328                        None,
329                        Some(a.dependencies.clone()),
330                        Some(b.dependencies.clone()),
331                        "installed dependencies changed".into(),
332                        BTreeSet::new(),
333                        BTreeSet::new(),
334                    );
335                }
336                Some((next, next_state))
337            })
338        })();
339        match attempt {
340            Ok(Some(pair)) => {
341                inheritance.from = Some(previous.id.clone());
342                inherited = Some(pair);
343                break;
344            }
345            Ok(None) => (),
346            Err(reason) => inheritance.skipped.push(SkippedMap {
347                run: previous.id.clone(),
348                reason,
349            }),
350        }
351    }
352    let (map, mut state) =
353        inherited.unwrap_or_else(|| seed_manifest(&input.manifest, &input.evidence_digest));
354    // A malformed newer map might contain changed claims. An older fallback
355    // preserves work, but must not silently restore its previous credit.
356    if !inheritance.skipped.is_empty() {
357        invalidate(
358            &mut state,
359            &map,
360            "newer assertion map could not be reused; inspect inherited claims",
361        );
362    }
363    // What each test ran, so the next carry can tell a change the test saw
364    // from one it could not have. Evidence that will not analyse leaves the
365    // record out; every flow is then judged by its files, as before.
366    let owned;
367    let analysed = match analysed {
368        Some(report) => Some(report),
369        None => {
370            owned = coverage(&run).ok();
371            owned.as_ref()
372        }
373    };
374    state.executions = analysed.and_then(|report| {
375        executions(
376            report,
377            &input.manifest,
378            current.as_ref().ok().map(|inputs| &inputs.files),
379        )
380    });
381    if let Err(reason) = &current {
382        invalidate(&mut state, &map, reason);
383        add_change(
384            &mut state,
385            None,
386            None,
387            None,
388            reason.clone(),
389            BTreeSet::new(),
390            BTreeSet::new(),
391        );
392    }
393    state.inheritance = Some(inheritance);
394    write_json(root, &run, MAP_FILE, &map)?;
395    write_json(root, &run, STATE_FILE, &state)?;
396    // A cache: a run that cannot write it is read from its archive instead.
397    let _ = write_json(
398        root,
399        &run,
400        INPUTS_CACHE_FILE,
401        &InputsCache {
402            evidence_digest: input.evidence_digest.clone(),
403            manifest: input.manifest.clone(),
404            legacy_digest: input.legacy_digest.clone(),
405            statement_exclusions: input.statement_exclusions.clone(),
406        },
407    );
408    // The summary `runs latest` shows, from the coverage already in hand. It is
409    // a cache: failing to write it costs the first query the analysis, nothing
410    // more, so it never fails the publication.
411    if let (Some(report), Ok(inputs)) = (analysed, current) {
412        // These inputs were already validated for this publication. Reopening
413        // them would decompress and validate the whole archive a second time.
414        // Queries still load current sources before accepting this cache.
415        let input = RunInputs {
416            inputs,
417            stored: input,
418        };
419        let _ = report_with_inputs(root, &run, None, false, Some(report), &input);
420    }
421    Ok(())
422}
423/// Each test's execution, placed in the declarations of the run's own
424/// manifest: for every probe a test fired, the innermost unit holding it.
425/// Also which units hold a probe at all, per file, since only a change
426/// confined to such units can be said to have missed a test. Sources are
427/// needed to place JavaScript columns; without them there is no record.
428pub fn executions(
429    coverage: &CoverageReport,
430    manifest: &InputManifest,
431    sources: Option<&Files>,
432) -> Option<Executions> {
433    // Each point's file, by index into `files`, and unit. Hits are looked up
434    // millions of times on a large run, so the lookup hashes with Fx and a
435    // test's hits are grouped by file index before any name is copied.
436    let mut located: crate::interned::FastMap<&str, (usize, usize)> = Default::default();
437    let mut files: Vec<&str> = Vec::new();
438    let mut file_indexes: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
439    let mut probed: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
440    for point in &coverage.view.points {
441        let meta = &point.meta;
442        let Some(code) = manifest
443            .files
444            .get(&meta.file)
445            .and_then(|fingerprint| fingerprint.code.as_ref())
446        else {
447            continue;
448        };
449        let column = if manifest.language == "javascript" {
450            let source = sources?.get(&meta.file)?;
451            let Some(column) = byte_column(source, meta.line, meta.column, &manifest.language)
452            else {
453                continue;
454            };
455            column
456        } else {
457            meta.column + 1
458        };
459        let unit = code.unit_at(meta.line, column);
460        let file = *file_indexes.entry(meta.file.as_str()).or_insert_with(|| {
461            files.push(meta.file.as_str());
462            files.len() - 1
463        });
464        located.insert(meta.id.as_str(), (file, unit));
465        if code.units[unit].is_code() {
466            probed.entry(meta.file.clone()).or_default().insert(unit);
467        }
468    }
469    // Everything the run reached, whoever reached it. Collected before the
470    // per-test loop because the record that holds what no test could be
471    // credited with has no test file, and the loop below skips it -- which is
472    // exactly the record a parallel suite's coverage lives in.
473    let mut covered_by_file = vec![Vec::new(); files.len()];
474    for test in &coverage.view.tests {
475        for hit in &test.hits {
476            if let Some((file, unit)) = located.get(hit.as_str()) {
477                covered_by_file[*file].push(*unit);
478            }
479        }
480    }
481    let covered = files
482        .iter()
483        .zip(covered_by_file)
484        .filter(|(_, units)| !units.is_empty())
485        .map(|(file, units)| {
486            (
487                (*file).to_owned(),
488                units.into_iter().collect::<BTreeSet<_>>(),
489            )
490        })
491        .collect::<BTreeMap<_, _>>();
492
493    let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
494    for test in &coverage.view.tests {
495        let Some(file) = &test.file else {
496            continue;
497        };
498        let selector = TestSelector {
499            file: file.clone(),
500            name: test.name.clone(),
501        };
502        let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
503            test: selector,
504            passed: false,
505            files: BTreeMap::new(),
506            attribution: crate::coverage_report::ATTRIBUTION_EXACT.to_owned(),
507        });
508        record.passed |= test.outcome == "passed";
509        // One name can be recorded more than once -- retries, a test declared
510        // in two files -- and a single incomplete appearance is enough to make
511        // the whole record's file list a lower bound.
512        if !crate::coverage_report::coverage_is_complete(&record.attribution) {
513            // already the weaker claim
514        } else if !crate::coverage_report::coverage_is_complete(&test.attribution) {
515            record.attribution = test.attribution.clone();
516        }
517        let mut reached = test
518            .hits
519            .iter()
520            .filter_map(|hit| located.get(hit.as_str()).copied())
521            .collect::<Vec<_>>();
522        reached.sort_unstable();
523        for group in reached.chunk_by(|left, right| left.0 == right.0) {
524            record
525                .files
526                .entry(files[group[0].0].to_owned())
527                .or_default()
528                .extend(group.iter().map(|(_, unit)| *unit));
529        }
530    }
531    let mut tests = tests.into_values().collect::<Vec<_>>();
532    for record in &mut tests {
533        for units in record.files.values_mut() {
534            units.sort_unstable();
535            units.dedup();
536        }
537    }
538    Some(Executions {
539        tests,
540        probed: probed
541            .into_iter()
542            .map(|(file, units)| (file, units.into_iter().collect()))
543            .collect(),
544        covered: covered
545            .into_iter()
546            .map(|(file, units)| (file, units.into_iter().collect()))
547            .collect(),
548    })
549}
550/// Which of a run's tests the current checkout's changes could have reached,
551/// from what each test executed and what has changed since, declaration by
552/// declaration. A test is affected by a change in code it ran, in its test
553/// file, or in a file it ran code in whose declarations changed shape; not by
554/// a change confined to code it never ran; not by comments or blank lines. A
555/// test that did not pass is listed as affected regardless: it has a result to
556/// establish. What this cannot see is a file the run never captured -- a file
557/// added since -- and the run's dependencies and configuration, which the
558/// working-tree check answers for.
559pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
560    let stored = load_manifest(run)?;
561    let (_, state) = load(run, &stored)?;
562    let Some(executions) = &state.executions else {
563        return Err(
564            "This run has no per-test execution record; rerun tests with this version of Supercov"
565                .into(),
566        );
567    };
568    let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
569    let manifest = &stored.manifest;
570    let now = manifest
571        .files
572        .keys()
573        .filter_map(|file| {
574            let path = root.join(file);
575            let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
576            if !canonical.starts_with(&root) || !canonical.is_file() {
577                return None;
578            }
579            let text = fs::read_to_string(canonical).ok()?;
580            Some((file.clone(), FileFingerprint::read(file, &text)))
581        })
582        .collect::<FileManifest>();
583    let changes = manifest
584        .files
585        .keys()
586        .filter_map(|file| {
587            model::file_change(
588                manifest.files.get(file),
589                now.get(file),
590                executions.probed.get(file).map(Vec::as_slice),
591            )
592            .map(|change| (file.as_str(), change))
593        })
594        .collect::<BTreeMap<_, _>>();
595    let files = changes
596        .iter()
597        .filter_map(|(file, change)| {
598            let (kind, detail) = match change {
599                FileChange::Same | FileChange::Added => return None,
600                FileChange::CommentsOnly => ("formatting", None),
601                FileChange::Removed => ("removed", None),
602                FileChange::Bytes => ("changed", None),
603                FileChange::Code {
604                    before,
605                    after,
606                    diff,
607                    narrow,
608                } => (
609                    if *narrow { "bodies" } else { "declarations" },
610                    Some(model::describe(before, after, diff)),
611                ),
612            };
613            Some(json!({"file":file,"change":kind,"detail":detail}))
614        })
615        .collect::<Vec<_>>();
616    // What a test whose own reach is unknown could have reached.
617    //
618    // Nothing can narrow such a test below the run it ran in, and nothing
619    // needs to go wider: its reach is bounded above by what the run covered.
620    // So a change inside that bound could have reached it, and a change
621    // outside it -- code no test in the run ever ran, a file added since --
622    // could not have reached any test, this one included.
623    //
624    // That is a real narrowing rather than "always affected". It is also the
625    // only safe direction: reading an empty file list as "this change missed
626    // it" is what let a change to code every test exercised report `0 of 2
627    // tests affected` and exit 0, which an agent running only affected tests
628    // would act on by running nothing.
629    let beyond_the_run = changes
630        .iter()
631        .filter_map(|(file, change)| match change {
632            FileChange::Same | FileChange::CommentsOnly | FileChange::Added => None,
633            FileChange::Removed => Some(format!("{file} removed (the run covered code in it)")),
634            FileChange::Bytes => Some(format!("{file} changed (the run covered code in it)")),
635            FileChange::Code { before, diff, .. } => {
636                // Unit by unit rather than file by file: a function no test
637                // ran sits in the same file as the ones they did, so asking
638                // whether the file was covered would make every change to it
639                // reach every test.
640                let code = manifest.files.get(*file).and_then(|f| f.code.as_ref());
641                let reached = executions
642                    .covered
643                    .get(*file)
644                    .map(Vec::as_slice)
645                    .unwrap_or_default()
646                    .iter()
647                    .flat_map(|unit| match code {
648                        Some(code) if *unit < code.units.len() => {
649                            code.ancestors(*unit).collect::<Vec<_>>()
650                        }
651                        _ => vec![*unit],
652                    })
653                    .collect::<BTreeSet<_>>();
654                let hit = diff
655                    .changed
656                    .iter()
657                    .filter(|unit| reached.contains(unit))
658                    .filter_map(|unit| before.units.get(*unit))
659                    .collect::<Vec<_>>();
660                (!hit.is_empty())
661                    .then(|| format!("{file}: {} changed (the run covered it)", named(hit)))
662            }
663        })
664        .collect::<Vec<_>>();
665
666    let mut affected = Vec::new();
667    let mut undetermined = Vec::new();
668    let mut unaffected = Vec::new();
669    for record in &executions.tests {
670        let mut reasons = Vec::new();
671        if !record.passed {
672            reasons.push("did not pass in the run".to_owned());
673        }
674        match changes.get(record.test.file.as_str()) {
675            None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
676            Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
677            Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
678            Some(FileChange::Code {
679                before,
680                after,
681                diff,
682                ..
683            }) => reasons.push(format!(
684                "test file changed: {}",
685                model::describe(before, after, diff)
686            )),
687        }
688        for (file, units) in &record.files {
689            let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
690            let ran = units
691                .iter()
692                .flat_map(|unit| match code {
693                    Some(code) if *unit < code.units.len() => {
694                        code.ancestors(*unit).collect::<Vec<_>>()
695                    }
696                    _ => vec![*unit],
697                })
698                .collect::<BTreeSet<_>>();
699            match changes.get(file.as_str()) {
700                None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
701                Some(FileChange::Removed) => {
702                    reasons.push(format!("{file} removed (this test ran code in it)"));
703                }
704                Some(FileChange::Bytes) => {
705                    reasons.push(format!("{file} changed (this test ran code in it)"));
706                }
707                Some(FileChange::Code {
708                    before,
709                    after,
710                    diff,
711                    narrow,
712                }) => {
713                    if *narrow {
714                        let hit = diff
715                            .changed
716                            .iter()
717                            .filter(|i| ran.contains(i))
718                            .map(|i| &before.units[*i])
719                            .collect::<Vec<_>>();
720                        if !hit.is_empty() {
721                            reasons
722                                .push(format!("{file}: {} changed (this test ran it)", named(hit)));
723                        }
724                    } else {
725                        reasons.push(format!(
726                            "{file}: {} changed (this test ran code in this file)",
727                            model::describe(before, after, diff)
728                        ));
729                    }
730                }
731            }
732        }
733        if !crate::coverage_report::coverage_is_complete(&record.attribution) {
734            // What its own record proves is the strong claim, and it keeps it:
735            // a Ruby test credited with the changed line ran the changed line,
736            // whatever else it also ran unrecorded.
737            let proven = !reasons.is_empty();
738            // The run's bound is what stands in for a record this test does
739            // not have. Where it has one that already proves the change
740            // reached it, saying so twice says nothing twice.
741            if !proven {
742                reasons.extend(beyond_the_run.iter().cloned());
743                reasons.sort();
744                reasons.dedup();
745            }
746            let entry = json!({
747                "file": record.test.file,
748                "name": record.test.name,
749                "reasons": reasons,
750                "attribution": record.attribution,
751            });
752            if proven {
753                affected.push(entry);
754            } else if reasons.is_empty() {
755                unaffected.push(entry);
756            } else {
757                undetermined.push(entry);
758            }
759            continue;
760        }
761        let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons});
762        if reasons.is_empty() {
763            unaffected.push(entry);
764        } else {
765            affected.push(entry);
766        }
767    }
768    Ok(json!({
769        "run": run.id,
770        "affected": affected,
771        // Kept apart from `affected` because they are a different claim. A
772        // test is affected when a change reached code it is recorded as having
773        // run; it is undetermined when nothing recorded what it ran and the
774        // change is inside what the run as a whole covered. Merging them would
775        // report a precision the run does not have -- but both have to be run,
776        // so both are in `--names`.
777        "undetermined": undetermined,
778        "unaffected": unaffected,
779        "changedFiles": files,
780        "summary": {
781            "tests": executions.tests.len(),
782            "affected": affected.len(),
783            "undetermined": undetermined.len(),
784            "unaffected": unaffected.len(),
785            "changedFiles": files.len(),
786        },
787        "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."
788    }))
789}
790/// How a difference between two runs reaches the flows inherited across it.
791struct ContextDelta {
792    /// What actually executes moved, so every inherited flow has to be read
793    /// again before it can be trusted.
794    execution: bool,
795    /// The installed dependency set moved. Recorded as one change to assess
796    /// rather than as staleness on every flow.
797    dependencies: bool,
798}
799
800/// Source hashes are checked through anchors and watches by `carry`; this is
801/// only about what surrounds them.
802///
803/// The instrumenter is deliberately absent. It is Supercov's own version, so
804/// including it made every release mark every map in the world stale -- for
805/// claims that are about the project's code, not about Supercov. When the
806/// meaning of credit itself changes, the basis domain is the thing that moves.
807///
808/// Dependencies are separated rather than dropped. An upgrade can falsify an
809/// authored explanation without touching a single project file, so it cannot
810/// pass unsaid; but it usually falsifies nothing, and making every flow stale
811/// for it spends the attention the author needs for the changes that do
812/// matter. An acknowledgement demanded six hundred times at once stops being
813/// read, which is the opposite of what it is for.
814fn context_delta(
815    a: &RunFingerprint,
816    b: &RunFingerprint,
817    old_context: &str,
818    new_context: &str,
819) -> ContextDelta {
820    ContextDelta {
821        execution: a.configuration != b.configuration || old_context != new_context,
822        dependencies: a.dependencies != b.dependencies,
823    }
824}
825
826pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
827    analyze_coverage_archive(&ArchiveReportRequest {
828        archive_path: run.evidence_path.clone(),
829        run_id: run.id.clone(),
830        generated_at: run.metadata.started_at.clone(),
831        integrity: None,
832        test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
833    })
834    .map_err(|e| format!("{e:?}"))
835}
836fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
837    byte_column_in(
838        source,
839        &crate::assertion_map::line_starts(source),
840        line,
841        column,
842        language,
843    )
844}
845
846/// `byte_column`, given where each line starts, so that resolving a whole
847/// file's statements looks each line up instead of walking the file to it.
848fn byte_column_in(
849    source: &str,
850    starts: &[usize],
851    line: usize,
852    column: usize,
853    language: &str,
854) -> Option<usize> {
855    if line == 0 {
856        return None;
857    }
858    let line = crate::assertion_map::line_text(source, starts, line)?;
859    if language != "javascript" {
860        // Native Rust, Python and Ruby manifests use zero-based byte columns.
861        return column.checked_add(1);
862    }
863    if column == 0 {
864        return None;
865    }
866    let mut units = 0;
867    for (byte, ch) in line.char_indices() {
868        if units == column - 1 {
869            return Some(byte + 1);
870        }
871        units += ch.len_utf16();
872    }
873    (units == column - 1).then_some(line.len() + 1)
874}
875fn phase_location<'a>(
876    phase: &'a crate::coverage_report::CoveragePhase,
877    inputs: &Inputs,
878) -> Option<(&'a str, usize, usize)> {
879    if phase.kind != "assertion" || phase.status.as_deref() != Some("passed") {
880        return None;
881    }
882    let source = phase
883        .operation
884        .strip_prefix("Rust assertion at ")
885        .or(phase.source.as_deref());
886    let location = source?;
887    let mut parts = location.rsplitn(3, ':');
888    let column = parts.next()?.parse::<usize>().ok()?;
889    let line = parts.next()?.parse::<usize>().ok()?;
890    let file = parts.next()?;
891    let column = byte_column(inputs.files.get(file)?, line, column, &inputs.language)?;
892    Some((file, line, column))
893}
894
895/// Cache derived assessments separately from immutable coverage evidence.
896/// Every query still verifies current source, map and managed-state identities.
897pub fn report(root: &Path, run: &StoredRun) -> Result<Value, String> {
898    report_with_detail(root, run, None, true)
899}
900/// The report `runs latest` shows: every count, none of the per-statement and
901/// per-line lists. See `assess_summary`.
902pub fn report_summary(root: &Path, run: &StoredRun) -> Result<Value, String> {
903    report_with_detail(root, run, None, false)
904}
905/// Read authored flows and their assessment from the same map snapshot.
906pub fn assertion(root: &Path, run: &StoredRun, id: &str) -> Result<Value, String> {
907    report_with_detail(root, run, Some(id), true)
908}
909fn report_with_detail(
910    root: &Path,
911    run: &StoredRun,
912    id: Option<&str>,
913    detail: bool,
914) -> Result<Value, String> {
915    report_with_detail_using(root, run, id, detail, None)
916}
917/// `report_with_detail`, given the run's coverage when it is already analysed.
918fn report_with_detail_using(
919    root: &Path,
920    run: &StoredRun,
921    id: Option<&str>,
922    detail: bool,
923    analysed: Option<&CoverageReport>,
924) -> Result<Value, String> {
925    let input = load_inputs(root, run)?;
926    report_with_inputs(root, run, id, detail, analysed, &input)
927}
928
929fn report_with_inputs(
930    root: &Path,
931    run: &StoredRun,
932    id: Option<&str>,
933    detail: bool,
934    analysed: Option<&CoverageReport>,
935    input: &RunInputs,
936) -> Result<Value, String> {
937    let (map, state) = load(run, input)?;
938    let cache_key = digest(&(
939        env!("SUPERCOV_ENGINE_SOURCE_SHA256"),
940        &run.id,
941        run.metadata.test_exit_code,
942        &map,
943        &state,
944        &input.evidence_digest,
945    ));
946    let cache_file = if detail {
947        REPORT_CACHE_FILE
948    } else {
949        SUMMARY_CACHE_FILE
950    };
951    let cache_path = run.directory.join(cache_file);
952    let mut report = read_report_cache(&cache_path, &cache_key).unwrap_or_else(|| Value::Null);
953    if report.is_null() {
954        let owned;
955        let coverage = match analysed {
956            Some(report) => report,
957            None => {
958                owned = coverage(run)?;
959                &owned
960            }
961        };
962        report = assess_with(
963            &map,
964            &state,
965            &input.inputs,
966            coverage,
967            run.metadata.test_exit_code == Some(0),
968            detail,
969        );
970        if detail {
971            report["excludedStatements"] = json!(input.statement_exclusions);
972        }
973        report["summary"]["excludedStatements"] = json!(input.statement_exclusions.len());
974        // This is disposable acceleration. A read-only directory or corrupt
975        // cache must never prevent a freshly computed report from working.
976        let cached = json!({"key":cache_key,"digest":digest(&report),"report":report});
977        let _ = write_json(root, run, cache_file, &cached);
978    }
979    if let Some(id) = id {
980        let matches = report["assertions"]
981            .as_array()
982            .unwrap()
983            .iter()
984            .filter(|a| a["id"] == id)
985            .collect::<Vec<_>>();
986        let mut row = match matches.as_slice() {
987            [row] => (*row).clone(),
988            [] => {
989                return Err(format!(
990                    "Unknown assertion ID: {id}; use runs {} assertions to list IDs",
991                    run.id
992                ));
993            }
994            _ => {
995                return Err(format!(
996                    "Ambiguous assertion ID: {id}; repair duplicate IDs in assertions.json"
997                ));
998            }
999        };
1000        if let Some(authored) = map.assertions.iter().find(|a| a.id == id) {
1001            for (flow, authored) in row["flows"]
1002                .as_array_mut()
1003                .unwrap()
1004                .iter_mut()
1005                .zip(&authored.flows)
1006            {
1007                let assessment = flow.as_object().unwrap().clone();
1008                *flow = serde_json::to_value(authored).map_err(|e| e.to_string())?;
1009                flow.as_object_mut().unwrap().extend(assessment);
1010            }
1011        }
1012        report["assertion"] = row;
1013    }
1014    report["inheritance"] = json!(state.inheritance);
1015    report["revision"] = json!(digest(&(&map, &state, &input.evidence_digest)));
1016    Ok(report)
1017}
1018
1019fn read_report_cache(path: &Path, key: &str) -> Option<Value> {
1020    if fs::metadata(path).ok()?.len() > 256 * 1024 * 1024 {
1021        return None;
1022    }
1023    let cached: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
1024    let report = &cached["report"];
1025    (cached["key"] == key
1026        && cached["digest"] == digest(report)
1027        && report["summary"].is_object()
1028        && report["assertions"].is_array())
1029    .then(|| report.clone())
1030}
1031
1032pub fn assess(
1033    map: &AssertionMap,
1034    state: &State,
1035    inputs: &Inputs,
1036    coverage: &CoverageReport,
1037    passed: bool,
1038) -> Value {
1039    assess_with(map, state, inputs, coverage, passed, true)
1040}
1041
1042/// The assessment's summary and everything but its per-statement and per-line
1043/// lists. `runs latest` shows five fields of the assessment and used to build
1044/// all of it to get them: every measured statement, each carrying the source of
1045/// the node around it. On a 300-file project that was 300,000 entries, a 177 MB
1046/// cache, and 4 GB held to print a handful of counts. Every count is computed
1047/// the same way here as in the full assessment; only the lists are skipped.
1048pub fn assess_summary(
1049    map: &AssertionMap,
1050    state: &State,
1051    inputs: &Inputs,
1052    coverage: &CoverageReport,
1053    passed: bool,
1054) -> Value {
1055    assess_with(map, state, inputs, coverage, passed, false)
1056}
1057
1058fn assess_with(
1059    map: &AssertionMap,
1060    state: &State,
1061    inputs: &Inputs,
1062    coverage: &CoverageReport,
1063    passed: bool,
1064    detail: bool,
1065) -> Value {
1066    let manifest = inputs.manifest();
1067    // Every statement is located in its file below, so each file's line starts
1068    // are found once here rather than by a walk from the top per statement.
1069    let lines = crate::assertion_map::LineIndex::new(&inputs.files);
1070    let ledger = model::Ledger::new(map, state, &manifest);
1071    let validation = model::validation(map, state, inputs);
1072    let errors = validation["errors"]
1073        .as_array()
1074        .unwrap()
1075        .iter()
1076        .map(|e| e.as_str().unwrap().to_owned())
1077        .collect::<Vec<_>>();
1078    let pending_changes = validation["changes"]
1079        .as_array()
1080        .unwrap()
1081        .iter()
1082        .filter(|c| c["current"] != true)
1083        .count();
1084    let view = &coverage.filters.passed;
1085    let tests = view
1086        .tests
1087        .iter()
1088        .filter(|t| t.role == "test")
1089        .map(|t| (t.id.as_str(), t))
1090        .collect::<BTreeMap<_, _>>();
1091    let measured_statements = view
1092        .points
1093        .iter()
1094        .filter(|p| p.measured && p.meta.kind == crate::coverage_analysis::PointKind::Statement)
1095        .collect::<Vec<_>>();
1096    let all_points = coverage
1097        .view
1098        .points
1099        .iter()
1100        .map(|p| (&p.meta.id, p))
1101        .collect::<BTreeMap<_, _>>();
1102    let all_tests = coverage
1103        .view
1104        .tests
1105        .iter()
1106        .map(|t| (t.id.as_str(), t))
1107        .collect::<BTreeMap<_, _>>();
1108    let mut by_file = BTreeMap::<&str, Vec<(usize, &crate::coverage_report::PointResult)>>::new();
1109    let mut by_line = BTreeMap::<(String, usize), Vec<&crate::coverage_report::PointResult>>::new();
1110    for point in &measured_statements {
1111        by_line
1112            .entry((point.meta.file.clone(), point.meta.line))
1113            .or_default()
1114            .push(point);
1115        if let Some((text, starts)) = lines.get(&point.meta.file)
1116            && let Some(column) = byte_column_in(
1117                text,
1118                starts,
1119                point.meta.line,
1120                point.meta.column,
1121                &inputs.language,
1122            )
1123        {
1124            let at = Anchor {
1125                file: point.meta.file.clone(),
1126                line: point.meta.line,
1127                column,
1128                text: point.meta.source.clone(),
1129            };
1130            if let Some(start) = at.offset_in(text, starts) {
1131                by_file
1132                    .entry(&point.meta.file)
1133                    .or_default()
1134                    .push((start, point));
1135            }
1136        }
1137    }
1138    for points in by_file.values_mut() {
1139        points.sort_by_key(|(pos, _)| *pos);
1140    }
1141    let mut rows = Vec::new();
1142    let mut claimed_points = BTreeSet::new();
1143    let mut credited_points = BTreeSet::new();
1144    let mut point_flows = BTreeMap::<String, BTreeSet<String>>::new();
1145    let mut draft_flows = 0;
1146    let mut stale_flows = 0;
1147    let mut invalid_flows = 0;
1148    let mut current_flows = 0;
1149    let mut credit_flows = 0;
1150    let mut assertions_without_current_explanation = 0;
1151    let mut line_assertions = BTreeMap::<(String, usize), BTreeSet<String>>::new();
1152    // Duplicate identities invalidate all credit; stale anchors only invalidate
1153    // their own flow, so an agent can repair a large map incrementally.
1154    let identities_valid = state.inputs_digest == inputs.identity()
1155        && errors.iter().all(|e| {
1156            !e.contains("duplicate") && !e.contains("schema") && !e.contains("assertion ID")
1157        });
1158    // Join exact identities once, rather than rescanning the complete inventory
1159    // for every assertion/phase pair. This is evidence lookup, not inference.
1160    let mut phases_by_location = BTreeMap::new();
1161    for p in &view.phases {
1162        if !tests.contains_key(p.test.as_str()) {
1163            continue;
1164        }
1165        if let Some(location) = phase_location(&p.phase, inputs) {
1166            phases_by_location
1167                .entry(location)
1168                .or_insert_with(Vec::new)
1169                .push(p);
1170        }
1171    }
1172    let mut inventory = BTreeMap::<&Anchor, BTreeSet<&str>>::new();
1173    for site in &inputs.assertions {
1174        inventory
1175            .entry(&site.at)
1176            .or_default()
1177            .insert(&site.operation);
1178    }
1179    let mapped_anchors = map
1180        .assertions
1181        .iter()
1182        .map(|a| &a.at)
1183        .collect::<BTreeSet<_>>();
1184    let mut used_ids = map
1185        .assertions
1186        .iter()
1187        .map(|a| a.id.clone())
1188        .chain(
1189            map.retired_assertions
1190                .iter()
1191                .map(|r| r.assertion.id.clone()),
1192        )
1193        .collect::<BTreeSet<_>>();
1194    let missing = seed(inputs, "")
1195        .0
1196        .assertions
1197        .into_iter()
1198        .filter(|a| !mapped_anchors.contains(&a.at))
1199        .map(|mut a| {
1200            while !used_ids.insert(a.id.clone()) {
1201                a.id.push('_');
1202            }
1203            a
1204        })
1205        .collect::<Vec<_>>();
1206    for (a, in_map) in map
1207        .assertions
1208        .iter()
1209        .map(|a| (a, true))
1210        .chain(missing.iter().map(|a| (a, false)))
1211    {
1212        let witnesses = phases_by_location
1213            .get(&(a.at.file.as_str(), a.at.line, a.at.column))
1214            .into_iter()
1215            .flatten()
1216            // Coordinates alone cannot identify a JS assertion: retain the
1217            // complete inventoried expression and operation requirement.
1218            .filter(|p| {
1219                inputs.language != "javascript"
1220                    || inventory
1221                        .get(&a.at)
1222                        .is_some_and(|operations| operations.contains(p.phase.operation.as_str()))
1223            })
1224            .map(|p| p.test.clone())
1225            .collect::<BTreeSet<_>>();
1226        let mut flows = Vec::new();
1227        for f in &a.flows {
1228            let dirty = model::reasons_with(a, f, state, inputs, &manifest, &ledger);
1229            let valid = model::validate_flow(f, &inputs.files).is_empty()
1230                && a.at.offset(&inputs.files).is_some();
1231            let freshness = if f.basis.is_none() {
1232                draft_flows += 1;
1233                "draft"
1234            } else if f.basis.as_deref()
1235                != Some(model::expected_basis_with(a, f, state, &manifest, &ledger).as_str())
1236            {
1237                stale_flows += 1;
1238                "stale"
1239            } else {
1240                "current"
1241            };
1242            if !valid {
1243                invalid_flows += 1;
1244            }
1245            if dirty.is_empty() {
1246                current_flows += 1;
1247            }
1248            // A file + exact displayed name must resolve to one logical test.
1249            // Retry attempts remain under that identity; duplicate names do not.
1250            let mut resolved = BTreeSet::new();
1251            let mut selectors = Vec::new();
1252            for selector in &f.applies_to {
1253                let matches = coverage
1254                    .view
1255                    .tests
1256                    .iter()
1257                    .filter(|t| {
1258                        t.file.as_deref() == Some(selector.file.as_str()) && t.name == selector.name
1259                    })
1260                    .collect::<Vec<_>>();
1261                let status = match matches.as_slice() {
1262                    [test]
1263                        if witnesses.contains(test.id.as_str())
1264                            && tests.contains_key(test.id.as_str()) =>
1265                    {
1266                        resolved.insert(test.id.clone());
1267                        "observed"
1268                    }
1269                    [] => "missing",
1270                    [_] => "unobserved",
1271                    _ => "ambiguous",
1272                };
1273                selectors.push(json!({"file":selector.file,"name":selector.name,"status":status,
1274                    "outcomes":matches.iter().map(|t| &t.outcome).collect::<Vec<_>>(),
1275                    "reason":match matches.as_slice() {
1276                        [] => "No test execution record matches this selector. The test may be disabled or outside this run.",
1277                        [test] if test.outcome != "passed" => "The selected test has no passing outcome.",
1278                        [_] if status == "unobserved" => "The test passed but this assertion occurrence was not recorded. Its branch may not have run, or attribution may be missing.",
1279                        [_] => "A passing assertion occurrence matches this test.",
1280                        _ => "Multiple test identities match this selector."
1281                    }}));
1282            }
1283            let applicable = resolved;
1284            let eligible = passed && identities_valid && dirty.is_empty() && !applicable.is_empty();
1285            let mut blockers = Vec::new();
1286            if !passed {
1287                blockers.push("run did not pass");
1288            }
1289            if !identities_valid {
1290                blockers.push("invalid map identities");
1291            }
1292            if !dirty.is_empty() {
1293                blockers.push("flow requires review or reference repair");
1294            }
1295            if applicable.is_empty() {
1296                blockers.push("no matching passing assertion occurrence for appliesTo");
1297            }
1298            if eligible {
1299                credit_flows += 1;
1300            }
1301            let mut lines = BTreeSet::new();
1302            let mut node_credit = Vec::new();
1303            for node in &f.nodes {
1304                let claimed = f.counts_as_asserted.contains(&node.id);
1305                let start = node.at.offset(&inputs.files);
1306                let mut matched = Vec::new();
1307                if let Some(start) = start
1308                    && let Some(points) = by_file.get(node.at.file.as_str())
1309                {
1310                    let first = points.partition_point(|(pos, _)| *pos < start);
1311                    for (_, point) in points[first..].iter().take_while(|(pos, _)| *pos == start) {
1312                        // Exact statement identity only: a guard/block does not
1313                        // include its nested statements in the score or diagnostic.
1314                        if point.meta.source == node.at.text {
1315                            matched.push(*point);
1316                        }
1317                    }
1318                }
1319                let matching_tests = matched
1320                    .iter()
1321                    .filter(|p| p.covered)
1322                    .flat_map(|p| p.tests.iter())
1323                    .filter(|test| applicable.contains(test.as_str()))
1324                    .collect::<BTreeSet<_>>();
1325                let credited = claimed && eligible && !matching_tests.is_empty();
1326                let mut reasons = Vec::new();
1327                let mut reason = |code: &str, message: String| {
1328                    reasons.push(json!({"code":code,"message":message}));
1329                };
1330                if !claimed {
1331                    reason(
1332                        "context_only",
1333                        "The agent included this node as context, not in countsAsAsserted.".into(),
1334                    );
1335                } else if credited {
1336                    reason("same_test_execution", "Current agent claim, passing assertion, and statement execution in the same selected test.".into());
1337                } else {
1338                    if start.is_none() {
1339                        reason(
1340                            "invalid_source_anchor",
1341                            "The node's source anchor does not match the run's current source."
1342                                .into(),
1343                        );
1344                    } else if matched.is_empty() {
1345                        reason("no_measured_statement", "The node does not exactly identify a measured production statement in this run.".into());
1346                    }
1347                    if !passed {
1348                        reason("run_failed", "The test run did not pass.".into());
1349                    }
1350                    if !identities_valid {
1351                        reason("invalid_map_identity", "Map identities or managed input state are invalid; see validation errors.".into());
1352                    }
1353                    if !dirty.is_empty() {
1354                        reason(
1355                            "flow_needs_attention",
1356                            format!(
1357                                "Flow needs investigation: {}",
1358                                dirty.iter().cloned().collect::<Vec<_>>().join("; ")
1359                            ),
1360                        );
1361                    }
1362                    if applicable.is_empty() {
1363                        reason("no_passing_assertion", "No selected test has a matching passing occurrence of this assertion; see flow selectors.".into());
1364                    }
1365                    if !matched.is_empty() {
1366                        if !matched.iter().any(|p| p.covered) {
1367                            let executed = matched
1368                                .iter()
1369                                .filter_map(|p| all_points.get(&p.meta.id))
1370                                .filter(|p| p.covered)
1371                                .collect::<Vec<_>>();
1372                            if !executed.is_empty() {
1373                                let setup = executed.iter().flat_map(|p| &p.tests).any(|id| {
1374                                    all_tests
1375                                        .get(id.as_str())
1376                                        .is_some_and(|t| t.role == "setup")
1377                                });
1378                                reason(if setup {"shared_setup_execution"} else {"execution_outside_passing_tests"},
1379                                    if setup {"Execution was recorded in a separate setup scope. Shared setup is not automatically credited to consuming tests."} else {"Execution was recorded outside passing tests (for example module initialization, background work or a failed test). It cannot establish same-test execution."}.into());
1380                            }
1381                            reason(
1382                                "no_passing_execution",
1383                                "No execution evidence from passing tests for this statement."
1384                                    .into(),
1385                            );
1386                        } else if !applicable.is_empty() && matching_tests.is_empty() {
1387                            if matched.iter().flat_map(|p| &p.tests).any(|id| {
1388                                all_tests
1389                                    .get(id.as_str())
1390                                    .is_some_and(|t| t.role == "setup")
1391                            }) {
1392                                reason("shared_setup_execution", "Execution was recorded in a separate setup scope. Shared setup is not automatically credited to consuming tests.".into());
1393                            }
1394                            reason("no_same_test_execution", "No execution evidence attributed to a selected passing test for this assertion.".into());
1395                        }
1396                    }
1397                }
1398                node_credit.push(json!({
1399                    "nodeId":node.id,
1400                    "location":{"file":node.at.file,"line":node.at.line,"column":node.at.column},
1401                    "status":if !claimed {"context"} else if credited {"credited"} else {"notCredited"},
1402                    "statementIds":matched.iter().map(|p| &p.meta.id).collect::<Vec<_>>(),
1403                    "matchingTests":matching_tests,
1404                    "reasons":reasons,
1405                }));
1406                for point in matched.into_iter().filter(|_| claimed) {
1407                    claimed_points.insert(point.meta.id.clone());
1408                    if eligible
1409                        && point.covered
1410                        && point.tests.iter().any(|t| applicable.contains(t.as_str()))
1411                    {
1412                        credited_points.insert(point.meta.id.clone());
1413                        point_flows
1414                            .entry(point.meta.id.clone())
1415                            .or_default()
1416                            .insert(flow_key(a, f));
1417                        lines.insert((node.at.file.clone(), point.meta.line));
1418                        line_assertions
1419                            .entry((node.at.file.clone(), point.meta.line))
1420                            .or_default()
1421                            .insert(a.id.clone());
1422                    }
1423                }
1424            }
1425            let notices = state
1426                .flows
1427                .get(&flow_key(a, f))
1428                .map(|s| s.notices.clone())
1429                .unwrap_or_default();
1430            let exposed_to = state
1431                .changes
1432                .iter()
1433                .filter(|c| c.exposed.contains(&flow_key(a, f)))
1434                .map(|c| c.id.clone())
1435                .collect::<Vec<_>>();
1436            flows.push(json!({"id":f.id,"freshness":freshness,"valid":valid,"current":dirty.is_empty(),"expectedBasis":model::expected_basis_with(a,f,state,&manifest,&ledger),"selectors":selectors,"questions":f.questions,"reasons":dirty,"notices":notices,"exposedTo":exposed_to,"eligible":eligible,"blockers":blockers,"matchingTests":applicable,"creditedStatementLines":lines,"nodeCredit":node_credit}));
1437        }
1438        let observation = if !witnesses.is_empty() {
1439            "Passing assertion occurrence recorded."
1440        } else if flows
1441            .iter()
1442            .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
1443            .any(|s| s["status"] == "missing")
1444        {
1445            "No passing occurrence. Some selected tests have no execution record (for example disabled tests or tests outside this run)."
1446        } else if flows
1447            .iter()
1448            .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
1449            .any(|s| {
1450                s["outcomes"]
1451                    .as_array()
1452                    .is_some_and(|outcomes| outcomes.iter().any(|o| o == "skipped"))
1453            })
1454        {
1455            "No passing occurrence. Selected tests include skipped/TODO executions."
1456        } else {
1457            "No passing occurrence recorded. The assertion may be in an untaken branch or its execution attribution may be missing; inspect the selected tests."
1458        };
1459        // Keep the exact anchor lookup typed. Scanning and serializing the
1460        // entire inventory for each JSON row made publication quadratic in
1461        // assertion count, including for maps with no authored flows.
1462        if inventory.contains_key(&a.at)
1463            && !witnesses.is_empty()
1464            && !flows.iter().any(|f| f["eligible"] == true)
1465        {
1466            assertions_without_current_explanation += 1;
1467        }
1468        rows.push(json!({"observation":observation,"id":a.id,"at":a.at,"questions":a.questions,"inMap":in_map,"observes":a.observes,"operations":inventory.get(&a.at).cloned().unwrap_or_default(),"observedPassingTests":witnesses,"flows":flows}));
1469    }
1470    // Only lines carrying a measured statement can ever be asserted: credit is
1471    // claimed per statement, and statements are keyed by their anchor line. A
1472    // measured line without one — a continuation line of a multi-line
1473    // statement, or a nested function/arrow body with no statement of its own —
1474    // is unclaimable by construction, so counting it here would cap the
1475    // percentage below 100% for structural reasons and list it under
1476    // `unassertedLines` as if an agent could act on it.
1477    let denominator = coverage
1478        .view
1479        .lines
1480        .iter()
1481        .filter(|l| l.measured)
1482        .map(|l| (l.file.clone(), l.line))
1483        .filter(|location| by_line.contains_key(location))
1484        .collect::<BTreeSet<_>>();
1485    let mut credited = BTreeSet::new();
1486    let mut declared = BTreeSet::new();
1487    for location in &denominator {
1488        let statements = by_line.get(location).map(Vec::as_slice).unwrap_or(&[]);
1489        if !statements.is_empty()
1490            && statements
1491                .iter()
1492                .all(|p| claimed_points.contains(&p.meta.id))
1493        {
1494            declared.insert(location.clone());
1495        }
1496        if !statements.is_empty()
1497            && statements
1498                .iter()
1499                .all(|p| credited_points.contains(&p.meta.id))
1500        {
1501            credited.insert(location.clone());
1502        }
1503    }
1504    let missing_inventory = inputs
1505        .assertions
1506        .iter()
1507        .filter(|s| !mapped_anchors.contains(&s.at))
1508        .count();
1509    let (status, reason) = if !passed || !identities_valid {
1510        ("unavailable", "Run failed or map identities are invalid")
1511    } else if pending_changes > 0 {
1512        ("pending", "Source changes need impact assessment")
1513    } else if measured_statements.is_empty() {
1514        ("notApplicable", "No measured statements")
1515    } else if credit_flows > 0 {
1516        (
1517            "available",
1518            "Agent-assessed statements; mapping completeness is unknown",
1519        )
1520    } else if map.assertions.iter().all(|a| a.flows.is_empty()) {
1521        ("notAssessed", "No recorded flow explanations")
1522    } else {
1523        (
1524            "pending",
1525            "No current flow with matching passing assertion evidence",
1526        )
1527    };
1528    let total = denominator.len();
1529    // Each measured statement is located once. The detail list and the
1530    // summary's count of statements that cannot be located read these same
1531    // answers, so a summary built without the list cannot disagree with it.
1532    let located = measured_statements
1533        .iter()
1534        .map(|p| {
1535            let (text, starts) = lines.get(&p.meta.file)?;
1536            let column =
1537                byte_column_in(text, starts, p.meta.line, p.meta.column, &inputs.language)?;
1538            crate::assertion_map::locate(
1539                &p.meta.file,
1540                p.meta.line,
1541                column,
1542                &p.meta.source,
1543                text,
1544                starts,
1545            )
1546            .map(|_| column)
1547        })
1548        .collect::<Vec<_>>();
1549    let unanchored = located.iter().filter(|column| column.is_none()).count();
1550    let statements = if !detail {
1551        Vec::new()
1552    } else {
1553        measured_statements.iter().zip(&located).map(|(p, column)| {
1554        let at = column.map(|column| Anchor { file: p.meta.file.clone(), line: p.meta.line, column, text: p.meta.source.clone() });
1555        let all = all_points.get(&p.meta.id);
1556        json!({"id":p.meta.id,"file":p.meta.file,"line":p.meta.line,"at":at,"covered":p.covered,"tests":p.tests,"declared":claimed_points.contains(&p.meta.id),"asserted":credited_points.contains(&p.meta.id),"flows":point_flows.get(&p.meta.id).cloned().unwrap_or_default(),
1557            "executionEvidence":{"anyExecution":all.is_some_and(|p| p.covered),"passingTests":p.tests.iter().filter(|id| tests.contains_key(id.as_str())).collect::<Vec<_>>(),
1558                "outsidePassingTests":all.into_iter().flat_map(|p| &p.tests).filter(|id| !tests.contains_key(id.as_str())).collect::<Vec<_>>()}})
1559    }).collect::<Vec<_>>()
1560    };
1561    json!({"basis":"agent-assessed; passing assertion identity and same-test execution required; not mutation resistance",
1562        "summary":{"status":status,"reason":reason,"pendingChanges":pending_changes,"metric":"measured statements","statements":{"asserted":credited_points.len(),"declared":claimed_points.len(),"total":measured_statements.len(),"percentage":if status != "available" { None } else {Some(credited_points.len() as f64 * 100.0 / measured_statements.len() as f64)}},"assertions":map.assertions.len(),"inventoryAssertions":inputs.assertions.len(),"missingInventoryAssertions":missing_inventory,
1563            "assertionsWithFlows":rows.iter().filter(|a| a["flows"].as_array().is_some_and(|f| !f.is_empty())).count(),
1564            "assertionsWithoutFlows":rows.iter().filter(|a| a["flows"].as_array().is_none_or(Vec::is_empty)).count(),
1565            "observedAssertionsWithoutCurrentExplanation":assertions_without_current_explanation,
1566            "questions":map.assertions.iter().map(|a| a.questions.len()+a.flows.iter().map(|f| f.questions.len()).sum::<usize>()).sum::<usize>(),
1567            "currentFlows":current_flows,"draftFlows":draft_flows,"staleFlows":stale_flows,"invalidFlows":invalid_flows,"eligibleFlows":credit_flows,"retiredAssertions":map.retired_assertions.len(),
1568            "unobservedAssertions":rows.iter().filter(|a| a["observedPassingTests"].as_array().is_none_or(Vec::is_empty)).count(),
1569            "inventoryFailures":inputs.limitations.iter().filter(|s| s.starts_with("Inventory unavailable for ")).count(),
1570            "unanchoredStatements":unanchored,
1571            "runPassed":passed,
1572            "lines":{"asserted":credited.len(),"declared":declared.len(),"total":total,"percentage":if total==0 || status != "available" {None} else {Some(credited.len() as f64 * 100.0 / total as f64)}}},
1573        "assertions":rows,"statements":statements,"tests":coverage.view.tests.iter().map(|t| json!({"id":t.id,"file":t.file,"name":t.name,"role":t.role,"outcome":t.outcome,"provenance":t.provenance,
1574            "hasExecutionEvidence":!t.hits.is_empty() || !t.decisions.is_empty() || !t.lines.is_empty()})).collect::<Vec<_>>(),
1575        "creditedLines":if !detail { Vec::new() } else { credited.iter().map(|loc| json!({"file":loc.0,"line":loc.1,"assertions":line_assertions.get(loc)})).collect::<Vec<_>>() },
1576        "unassertedLines":if !detail { Vec::new() } else { denominator.difference(&credited).map(|(f,l)| json!({"file":f,"line":l})).collect::<Vec<_>>() },
1577        "changes":validation["changes"],"validationErrors":errors,"advisories":model::advisories(map),"limitations":inputs.limitations})
1578}
1579
1580#[cfg(test)]
1581mod tests {
1582    use super::*;
1583    use crate::evidence_archive::read_archive;
1584    fn fingerprint() -> RunFingerprint {
1585        RunFingerprint {
1586            algorithm: "sha256".into(),
1587            source: "source".into(),
1588            tests: "tests".into(),
1589            dependencies: "dependencies".into(),
1590            configuration: "configuration".into(),
1591            instrumenter: "instrumenter".into(),
1592            execution: "execution".into(),
1593            combined: "combined".into(),
1594            source_files: 1,
1595            test_files: 1,
1596        }
1597    }
1598
1599    /// `byte_column` as it was before it took line starts.
1600    fn reference_byte_column(
1601        source: &str,
1602        line: usize,
1603        column: usize,
1604        language: &str,
1605    ) -> Option<usize> {
1606        if line == 0 {
1607            return None;
1608        }
1609        let line = source.lines().nth(line - 1)?;
1610        if language != "javascript" {
1611            return column.checked_add(1);
1612        }
1613        if column == 0 {
1614            return None;
1615        }
1616        let mut units = 0;
1617        for (byte, ch) in line.char_indices() {
1618            if units == column - 1 {
1619                return Some(byte + 1);
1620            }
1621            units += ch.len_utf16();
1622        }
1623        (units == column - 1).then_some(line.len() + 1)
1624    }
1625
1626    #[test]
1627    fn a_column_from_line_starts_agrees_with_walking_the_file() {
1628        // JavaScript columns count UTF-16 units, so text whose widths differ --
1629        // accents, and an emoji that is two units -- is where they could part.
1630        for source in [
1631            "",
1632            "a",
1633            "a\n",
1634            "a\r\nb\r\n",
1635            "x\r",
1636            "héllo\nwörld\n",
1637            "😀x\ny😀z\n",
1638            "const a = 1;\n  b();\n",
1639        ] {
1640            let starts = crate::assertion_map::line_starts(source);
1641            for language in ["javascript", "python"] {
1642                for line in 0..source.len() + 3 {
1643                    for column in 0..source.len() + 4 {
1644                        assert_eq!(
1645                            byte_column_in(source, &starts, line, column, language),
1646                            reference_byte_column(source, line, column, language),
1647                            "{language} {line}:{column} in {source:?}"
1648                        );
1649                    }
1650                }
1651            }
1652        }
1653    }
1654
1655    #[test]
1656    fn upgrading_supercov_does_not_make_every_map_stale() {
1657        // The instrumenter digest covers Supercov's own source, so every
1658        // release of it moved. The claims in a map are about the project's
1659        // code; a new Supercov re-derives the evidence they rest on rather than
1660        // making them wrong.
1661        let before = fingerprint();
1662        let mut after = fingerprint();
1663        after.instrumenter = "a new supercov".into();
1664        let delta = context_delta(&before, &after, "context", "context");
1665        assert!(!delta.execution);
1666        assert!(!delta.dependencies);
1667    }
1668
1669    #[test]
1670    fn a_dependency_upgrade_is_reported_without_invalidating_anything() {
1671        // It is separated, not ignored: an upgrade can falsify an authored
1672        // explanation with no project file touched. One assessment answers for
1673        // it, and credit survives in the meantime.
1674        let before = fingerprint();
1675        let mut after = fingerprint();
1676        after.dependencies = "an upgraded lockfile".into();
1677        let delta = context_delta(&before, &after, "context", "context");
1678        assert!(delta.dependencies);
1679        assert!(!delta.execution, "an upgrade must not invalidate flows");
1680    }
1681
1682    #[test]
1683    fn what_actually_executes_still_invalidates_every_flow() {
1684        // tsconfig, Babel and the declared context decide what runs. A flow
1685        // acknowledged under the old one has to be read again.
1686        let before = fingerprint();
1687        let mut after = fingerprint();
1688        after.configuration = "a different tsconfig".into();
1689        assert!(context_delta(&before, &after, "context", "context").execution);
1690        assert!(
1691            context_delta(&before, &fingerprint(), "context", "another context").execution,
1692            "declared context variables still count"
1693        );
1694        assert!(!context_delta(&before, &fingerprint(), "context", "context").execution);
1695    }
1696
1697    #[test]
1698    fn report_cache_requires_exact_revision_and_intact_payload() {
1699        let directory =
1700            std::env::temp_dir().join(format!("supercov-report-cache-{}", std::process::id()));
1701        fs::create_dir_all(&directory).unwrap();
1702        let path = directory.join(REPORT_CACHE_FILE);
1703        let report = json!({"summary":{"statements":{"asserted":4,"percentage":97.02842377260981}},"assertions":[]});
1704        let mut cache = json!({"key":"revision-one","digest":digest(&report),"report":report});
1705        fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1706        assert_eq!(read_report_cache(&path, "revision-one"), Some(report));
1707        assert!(read_report_cache(&path, "revision-two").is_none());
1708        cache["report"]["summary"]["statements"]["asserted"] = json!(100);
1709        fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1710        assert!(read_report_cache(&path, "revision-one").is_none());
1711        fs::write(&path, "interrupted write").unwrap();
1712        assert!(read_report_cache(&path, "revision-one").is_none());
1713        fs::remove_dir_all(directory).unwrap();
1714    }
1715    use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1716
1717    /// Publication writes the summary `runs latest` shows from the coverage it
1718    /// analysed; a query that computes it from the evidence must arrive at the
1719    /// same cache, byte for byte.
1720    #[test]
1721    fn the_summary_written_at_publication_is_the_one_a_query_computes() {
1722        let root = std::env::temp_dir().join(format!(
1723            "supercov-summary-{}-{}",
1724            std::process::id(),
1725            std::time::SystemTime::now()
1726                .duration_since(std::time::UNIX_EPOCH)
1727                .unwrap()
1728                .as_nanos()
1729        ));
1730        fs::create_dir_all(root.join("src")).unwrap();
1731        fs::create_dir_all(root.join("tests")).unwrap();
1732        fs::write(
1733            root.join("src/app.js"),
1734            "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n",
1735        )
1736        .unwrap();
1737        fs::write(
1738            root.join("tests/app.test.js"),
1739            "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n",
1740        )
1741        .unwrap();
1742        let inputs = crate::assertion_inputs::capture(
1743            &root,
1744            "python",
1745            ["src/app.js".into(), "tests/app.test.js".into()],
1746        )
1747        .unwrap();
1748        let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1749        let path = directory.join("evidence.raw.gz");
1750        let entries =
1751            crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1752        let archive = write_archive(entries, &path).unwrap();
1753        let metadata_path = directory.join("run.json");
1754        let mut metadata: RunMetadata =
1755            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1756        metadata.raw_evidence.files = archive.files;
1757        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1758        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1759        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1760        let run = discover_runs(&root).unwrap().runs.remove(0);
1761        let analysed = crate::run_store::analyze_stored_run(&run).unwrap();
1762        // The one analysis publication makes is the one the assertion store
1763        // made for itself, but for the run's integrity record, which neither
1764        // the summary nor the execution record reads.
1765        let mut own = coverage(&run).unwrap();
1766        for (view, published) in [
1767            (&mut own.view, &analysed.view),
1768            (&mut own.filters.passed, &analysed.filters.passed),
1769            (&mut own.filters.failed, &analysed.filters.failed),
1770        ] {
1771            assert!(view.integrity.is_none() && published.integrity.is_some());
1772            view.integrity = published.integrity.clone();
1773        }
1774        assert_eq!(own, analysed);
1775        prepare_publication(&root, &directory, &metadata, Some(&analysed)).unwrap();
1776        // A frontend that archived the inputs hands them over instead of the
1777        // store reading them back; what it writes is the same.
1778        let files = [MAP_FILE, STATE_FILE, SUMMARY_CACHE_FILE];
1779        let from_archive = files.map(|file| fs::read(directory.join(file)).unwrap());
1780        for file in files {
1781            fs::remove_file(directory.join(file)).unwrap();
1782        }
1783        let digest = format!("{:x}", Sha256::digest(fs::read(&path).unwrap()));
1784        prepare_publication_with(
1785            &root,
1786            &directory,
1787            &metadata,
1788            Some(&analysed),
1789            Some(archived_manifest(inputs.manifest(), digest).unwrap()),
1790        )
1791        .unwrap();
1792        for (file, expected) in files.iter().zip(&from_archive) {
1793            assert_eq!(
1794                &fs::read(directory.join(file)).unwrap(),
1795                expected,
1796                "{file} is the one the archive gives"
1797            );
1798        }
1799        let cache = directory.join(SUMMARY_CACHE_FILE);
1800        let written = fs::read(&cache).expect("publication wrote the summary");
1801        let summary = report_summary(&root, &run).unwrap();
1802        fs::remove_file(&cache).unwrap();
1803        let computed = report_summary(&root, &run).unwrap();
1804        assert_eq!(fs::read(&cache).unwrap(), written, "the same cache");
1805        assert_eq!(summary, computed, "and the same summary");
1806        fs::write(root.join("src/app.js"), "function work() { return 3; }\n").unwrap();
1807        assert!(
1808            report_summary(&root, &run).is_err(),
1809            "a warmed summary must not bypass current-source validation"
1810        );
1811        fs::remove_dir_all(root).unwrap();
1812    }
1813
1814    /// Publish one run over a two-function file whose test ran one of them,
1815    /// then ask which tests each edit affects.
1816    #[test]
1817    fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1818        let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1819        let _ = fs::remove_dir_all(&root);
1820        fs::create_dir_all(root.join("src")).unwrap();
1821        fs::create_dir_all(root.join("tests")).unwrap();
1822        // The fixture's one point sits at line 1, column 0, so `work` has to
1823        // start the file: an `export` keyword there would belong to the top
1824        // level, not to the function.
1825        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
1826        let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
1827        fs::write(root.join("src/app.js"), app).unwrap();
1828        fs::write(root.join("tests/app.test.js"), test).unwrap();
1829        // The fixture run's one point sits at line 1, column 0 of src/app.js
1830        // with a zero-based byte column, which is how every non-JavaScript
1831        // frontend reports; the language is named for that.
1832        let inputs = crate::assertion_inputs::capture(
1833            &root,
1834            "python",
1835            ["src/app.js".into(), "tests/app.test.js".into()],
1836        )
1837        .unwrap();
1838        let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1839        let path = directory.join("evidence.raw.gz");
1840        let entries =
1841            crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1842        let archive = write_archive(entries, &path).unwrap();
1843        let metadata_path = directory.join("run.json");
1844        let mut metadata: RunMetadata =
1845            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1846        metadata.raw_evidence.files = archive.files;
1847        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1848        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1849        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1850        prepare_publication(&root, &directory, &metadata, None).unwrap();
1851        let run = discover_runs(&root).unwrap().runs.remove(0);
1852        let stored = load_manifest(&run).unwrap();
1853        let (_, state) = load(&run, &stored).unwrap();
1854        let executions = state.executions.as_ref().expect("a record");
1855        let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1856        let work = code.units.iter().position(|u| u.path == "work").unwrap();
1857        let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1858        assert_eq!(executions.tests.len(), 1);
1859        let record = &executions.tests[0];
1860        assert_eq!(record.test.file, "tests/app.test.js");
1861        assert_eq!(record.test.name, "test");
1862        assert!(record.passed);
1863        assert_eq!(record.files["src/app.js"], vec![work]);
1864        assert_eq!(
1865            executions.probed["src/app.js"],
1866            vec![work],
1867            "idle holds no probe in this run"
1868        );
1869        let _ = idle;
1870
1871        let names = |value: &Value, key: &str| {
1872            value[key]
1873                .as_array()
1874                .unwrap()
1875                .iter()
1876                .map(|t| t["name"].as_str().unwrap().to_owned())
1877                .collect::<Vec<_>>()
1878        };
1879        // Nothing changed.
1880        let report = affected_tests(&root, &run).unwrap();
1881        assert!(names(&report, "affected").is_empty(), "{report}");
1882        assert_eq!(names(&report, "unaffected"), ["test"]);
1883        assert_eq!(report["summary"]["changedFiles"], 0);
1884        // A comment: still nothing.
1885        fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1886        let report = affected_tests(&root, &run).unwrap();
1887        assert!(names(&report, "affected").is_empty(), "{report}");
1888        assert_eq!(report["changedFiles"][0]["change"], "formatting");
1889        // A body the test ran: affected, and it says which.
1890        fs::write(
1891            root.join("src/app.js"),
1892            app.replace("return 1;", "return 1 + 0;"),
1893        )
1894        .unwrap();
1895        let report = affected_tests(&root, &run).unwrap();
1896        assert_eq!(names(&report, "affected"), ["test"]);
1897        assert_eq!(
1898            report["affected"][0]["reasons"][0],
1899            "src/app.js: work (line 1) changed (this test ran it)"
1900        );
1901        // A body the test did not run, once idle has a probe of its own: not
1902        // affected. Here idle holds none, so the change is not one that can
1903        // be said to have missed the test, and it counts.
1904        fs::write(
1905            root.join("src/app.js"),
1906            app.replace("return 2;", "return 2 + 0;"),
1907        )
1908        .unwrap();
1909        let report = affected_tests(&root, &run).unwrap();
1910        assert_eq!(names(&report, "affected"), ["test"], "{report}");
1911        assert!(
1912            report["affected"][0]["reasons"][0]
1913                .as_str()
1914                .unwrap()
1915                .contains("this test ran code in this file"),
1916            "{report}"
1917        );
1918        // A declaration added: affected, the file's shape changed.
1919        fs::write(
1920            root.join("src/app.js"),
1921            format!("{app}function more() {{}}\n"),
1922        )
1923        .unwrap();
1924        let report = affected_tests(&root, &run).unwrap();
1925        assert_eq!(names(&report, "affected"), ["test"]);
1926        assert_eq!(report["changedFiles"][0]["change"], "declarations");
1927        // The test file itself.
1928        fs::write(root.join("src/app.js"), app).unwrap();
1929        fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1930        let report = affected_tests(&root, &run).unwrap();
1931        assert_eq!(names(&report, "affected"), ["test"]);
1932        assert!(
1933            report["affected"][0]["reasons"][0]
1934                .as_str()
1935                .unwrap()
1936                .starts_with("test file changed"),
1937            "{report}"
1938        );
1939        // A source file removed.
1940        fs::write(root.join("tests/app.test.js"), test).unwrap();
1941        fs::remove_file(root.join("src/app.js")).unwrap();
1942        let report = affected_tests(&root, &run).unwrap();
1943        assert_eq!(
1944            report["affected"][0]["reasons"][0],
1945            "src/app.js removed (this test ran code in it)"
1946        );
1947        fs::remove_dir_all(root).unwrap();
1948    }
1949
1950    #[test]
1951    fn a_test_that_ran_beside_others_is_undetermined_rather_than_unaffected() {
1952        // A parallel suite's coverage sits in the record nobody could claim,
1953        // and the tests beside it hold nothing of their own. Reading "this
1954        // test's record does not mention the changed file" as "this test is
1955        // unaffected" would answer `0 of 1 tests affected` for a change to the
1956        // only code the run executed -- and an agent running only the affected
1957        // tests would run nothing. The run's own bound is what stands in.
1958        let root = std::env::temp_dir().join(format!(
1959            "supercov-undetermined-{}-{}",
1960            std::process::id(),
1961            std::time::SystemTime::now()
1962                .duration_since(std::time::UNIX_EPOCH)
1963                .unwrap()
1964                .as_nanos()
1965        ));
1966        let _ = fs::remove_dir_all(&root);
1967        fs::create_dir_all(root.join("src")).unwrap();
1968        fs::create_dir_all(root.join("tests")).unwrap();
1969        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
1970        let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
1971        fs::write(root.join("src/app.js"), app).unwrap();
1972        fs::write(root.join("tests/app.test.js"), test).unwrap();
1973        let inputs = crate::assertion_inputs::capture(
1974            &root,
1975            "python",
1976            ["src/app.js".into(), "tests/app.test.js".into()],
1977        )
1978        .unwrap();
1979        let directory = crate::run_store::create_run_wide_test_run(&root, "parallel");
1980        let path = directory.join("evidence.raw.gz");
1981        let entries =
1982            crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1983        let archive = write_archive(entries, &path).unwrap();
1984        let metadata_path = directory.join("run.json");
1985        let mut metadata: RunMetadata =
1986            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1987        metadata.raw_evidence.files = archive.files;
1988        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1989        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1990        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1991        prepare_publication(&root, &directory, &metadata, None).unwrap();
1992        let run = discover_runs(&root).unwrap().runs.remove(0);
1993
1994        let (_, state) = load(&run, &load_manifest(&run).unwrap()).unwrap();
1995        let executions = state.executions.as_ref().expect("a record");
1996        assert_eq!(executions.tests.len(), 1, "the named test, not the record");
1997        let record = &executions.tests[0];
1998        assert_eq!(
1999            record.attribution,
2000            crate::coverage_report::ATTRIBUTION_RUN_WIDE
2001        );
2002        assert!(
2003            record.files.is_empty(),
2004            "it is credited with nothing: {:?}",
2005            record.files
2006        );
2007        assert!(
2008            !executions.covered["src/app.js"].is_empty(),
2009            "and the run is credited with what it reached"
2010        );
2011
2012        let bucket = |value: &Value, key: &str| {
2013            value[key]
2014                .as_array()
2015                .unwrap()
2016                .iter()
2017                .map(|t| t["name"].as_str().unwrap().to_owned())
2018                .collect::<Vec<_>>()
2019        };
2020
2021        // Nothing changed: nothing to rerun, and the run's bound says nothing
2022        // either.
2023        let report = affected_tests(&root, &run).unwrap();
2024        assert_eq!(bucket(&report, "unaffected"), ["test"], "{report}");
2025        assert!(bucket(&report, "undetermined").is_empty(), "{report}");
2026
2027        // The one function the run executed changed. The test's own record
2028        // cannot say whether it reached it, and the run's can.
2029        fs::write(
2030            root.join("src/app.js"),
2031            app.replace("return 1;", "return 3;"),
2032        )
2033        .unwrap();
2034        let report = affected_tests(&root, &run).unwrap();
2035        assert!(
2036            bucket(&report, "affected").is_empty(),
2037            "nothing proves it ran the change: {report}"
2038        );
2039        assert_eq!(
2040            bucket(&report, "undetermined"),
2041            ["test"],
2042            "the run reached it, so this test might have: {report}"
2043        );
2044        assert_eq!(
2045            report["undetermined"][0]["attribution"],
2046            crate::coverage_report::ATTRIBUTION_RUN_WIDE,
2047            "{report}"
2048        );
2049        fs::remove_dir_all(root).unwrap();
2050    }
2051
2052    #[test]
2053    fn a_lower_bound_still_proves_what_it_recorded() {
2054        // Ruby's is the other incomplete attribution: the test is credited
2055        // with coverage, and that coverage is a floor rather than the whole of
2056        // what it ran. The floor is still evidence -- a test credited with the
2057        // changed line ran the changed line -- so it is affected outright,
2058        // with its own reason, rather than swept into the bucket for tests
2059        // nothing can say anything about.
2060        let root = std::env::temp_dir().join(format!(
2061            "supercov-partial-{}-{}",
2062            std::process::id(),
2063            std::time::SystemTime::now()
2064                .duration_since(std::time::UNIX_EPOCH)
2065                .unwrap()
2066                .as_nanos()
2067        ));
2068        let _ = fs::remove_dir_all(&root);
2069        fs::create_dir_all(root.join("src")).unwrap();
2070        fs::create_dir_all(root.join("tests")).unwrap();
2071        let app = "function work() {\n  return 1;\n}\nfunction idle() {\n  return 2;\n}\n";
2072        let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
2073        fs::write(root.join("src/app.js"), app).unwrap();
2074        fs::write(root.join("tests/app.test.js"), test).unwrap();
2075        let inputs = crate::assertion_inputs::capture(
2076            &root,
2077            "python",
2078            ["src/app.js".into(), "tests/app.test.js".into()],
2079        )
2080        .unwrap();
2081        let directory = crate::run_store::create_partial_test_run(&root, "partial");
2082        let path = directory.join("evidence.raw.gz");
2083        let entries =
2084            crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
2085        let archive = write_archive(entries, &path).unwrap();
2086        let metadata_path = directory.join("run.json");
2087        let mut metadata: RunMetadata =
2088            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
2089        metadata.raw_evidence.files = archive.files;
2090        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
2091        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
2092        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
2093        prepare_publication(&root, &directory, &metadata, None).unwrap();
2094        let run = discover_runs(&root).unwrap().runs.remove(0);
2095
2096        let (_, state) = load(&run, &load_manifest(&run).unwrap()).unwrap();
2097        let record = &state.executions.as_ref().expect("a record").tests[0];
2098        assert_eq!(
2099            record.attribution,
2100            crate::coverage_report::ATTRIBUTION_PARTIAL
2101        );
2102        assert!(
2103            !record.files.is_empty(),
2104            "a lower bound is coverage it was credited with"
2105        );
2106
2107        let names = |value: &Value, key: &str| {
2108            value[key]
2109                .as_array()
2110                .unwrap()
2111                .iter()
2112                .map(|t| t["name"].as_str().unwrap().to_owned())
2113                .collect::<Vec<_>>()
2114        };
2115        fs::write(
2116            root.join("src/app.js"),
2117            app.replace("return 1;", "return 3;"),
2118        )
2119        .unwrap();
2120        let report = affected_tests(&root, &run).unwrap();
2121        assert_eq!(
2122            names(&report, "affected"),
2123            ["test"],
2124            "what its own record proves, it proves: {report}"
2125        );
2126        assert!(names(&report, "undetermined").is_empty(), "{report}");
2127        // And the reason is its own, not the run's stand-in.
2128        let reasons = report["affected"][0]["reasons"].as_array().unwrap();
2129        assert_eq!(reasons.len(), 1, "{report}");
2130        let reason = reasons[0].as_str().unwrap();
2131        assert!(
2132            reason.contains("this test ran it"),
2133            "the reason is its own: {report}"
2134        );
2135        assert!(
2136            !reason.contains("the run covered"),
2137            "and not the run's stand-in: {report}"
2138        );
2139        fs::remove_dir_all(root).unwrap();
2140    }
2141
2142    #[test]
2143    fn legacy_maps_import_without_the_old_checkout_and_require_review() {
2144        let root = std::env::temp_dir().join(format!("supercov-legacy-map-{}", std::process::id()));
2145        fs::create_dir_all(&root).unwrap();
2146        let source = "import assert from 'node:assert/strict'; assert.equal(1, 1);\n";
2147        fs::write(root.join("test.js"), source).unwrap();
2148        let old =
2149            crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
2150        let directory = crate::run_store::create_analyzable_test_run(&root, "legacy");
2151        let path = directory.join("evidence.raw.gz");
2152        let mut entries = read_archive(&path).unwrap();
2153        entries.push(EvidenceArchiveEntry {
2154            path: ARCHIVE_PATH.into(),
2155            contents: serde_json::to_vec(&old).unwrap(),
2156        });
2157        let archive = write_archive(entries, &path).unwrap();
2158        let metadata_path = directory.join("run.json");
2159        let mut metadata: RunMetadata =
2160            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
2161        metadata.raw_evidence.files = archive.files;
2162        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
2163        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
2164        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
2165        let run = discover_runs(&root).unwrap().runs.remove(0);
2166        let stored = load_manifest(&run).unwrap();
2167        let (map, _) = seed(&old, &stored.evidence_digest);
2168        let legacy_map = json!({"schemaVersion":1,"assertions":[{"id":map.assertions[0].id,"at":map.assertions[0].at,"analysis":"mapped","observes":[],"flows":[{
2169            "id":"constant","explanation":"The assertion checks the constant one.","appliesTo":[],"nodes":[],"edges":[],"countsAsAsserted":[],"watch":[{"kind":"span","at":map.assertions[0].at}]
2170        }]}]});
2171        let legacy_state = json!({"schemaVersion":1,"inputsDigest":digest(&old),"evidenceDigest":stored.evidence_digest,"reviews":{},"scopeReview":[]});
2172        write_json(&root, &run, MAP_FILE, &legacy_map).unwrap();
2173        write_json(&root, &run, STATE_FILE, &legacy_state).unwrap();
2174        let map = model::parse_stored(&serde_json::to_vec(&legacy_map).unwrap()).unwrap();
2175        let map_bytes = fs::read(directory.join(MAP_FILE)).unwrap();
2176        let state_bytes = fs::read(directory.join(STATE_FILE)).unwrap();
2177
2178        fs::write(root.join("test.js"), format!("\n{source}")).unwrap();
2179        assert!(load_inputs(&root, &run).is_err());
2180        let (imported, state) = load(&run, &stored).unwrap();
2181        let new =
2182            crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
2183        let (next, state) =
2184            carry(&imported, &state, &stored.manifest, &new, "new-run", false).unwrap();
2185        assert_eq!(next.assertions[0].id, map.assertions[0].id);
2186        assert_eq!(
2187            next.assertions[0].flows[0].explanation,
2188            map.assertions[0].flows[0].explanation
2189        );
2190        assert!(next.assertions[0].flows[0].basis.is_none());
2191        assert!(!next.assertions[0].flows[0].questions.is_empty());
2192        assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
2193        assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
2194
2195        // A checkout edited during a run must not prevent publication or
2196        // discard explanations. The run manifest still identifies its sites.
2197        let next_directory = crate::run_store::create_analyzable_test_run(&root, "next");
2198        let mut entries = read_archive(&path).unwrap();
2199        entries
2200            .iter_mut()
2201            .find(|e| e.path == ARCHIVE_PATH)
2202            .unwrap()
2203            .contents = serde_json::to_vec(&old.manifest()).unwrap();
2204        let raw = write_archive(entries, &next_directory.join("evidence.raw.gz")).unwrap();
2205        let mut next_metadata = metadata.clone();
2206        next_metadata.id = "next".into();
2207        next_metadata.started_at = "next".into();
2208        next_metadata.raw_evidence.compressed_bytes = raw.compressed_bytes;
2209        next_metadata.raw_evidence.uncompressed_bytes = raw.uncompressed_bytes;
2210        fs::write(
2211            next_directory.join("run.json"),
2212            serde_json::to_vec(&next_metadata).unwrap(),
2213        )
2214        .unwrap();
2215        fs::remove_file(root.join("test.js")).unwrap();
2216        prepare_publication(&root, &next_directory, &next_metadata, None).unwrap();
2217        let next_run = discover_runs(&root)
2218            .unwrap()
2219            .runs
2220            .into_iter()
2221            .find(|r| r.id == "next")
2222            .unwrap();
2223        let next_manifest = load_manifest(&next_run).unwrap();
2224        let (pending, pending_state) = load(&next_run, &next_manifest).unwrap();
2225        assert_eq!(pending.assertions[0], map.assertions[0]);
2226        assert!(!pending_state.changes.is_empty());
2227        assert!(
2228            !pending_state.flows
2229                [&flow_key(&pending.assertions[0], &pending.assertions[0].flows[0])]
2230                .reasons
2231                .is_empty()
2232        );
2233        assert!(load_inputs(&root, &next_run).is_err());
2234        assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
2235        assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
2236
2237        let mut corrupt = state.clone();
2238        corrupt.evidence_digest = "wrong".into();
2239        write_json(&root, &run, STATE_FILE, &corrupt).unwrap();
2240        assert!(load(&run, &stored).is_err());
2241        fs::remove_dir_all(root).unwrap();
2242    }
2243}