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