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