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::{RunMetadata, StoredRun, discover_runs},
11};
12use serde_json::{Value, json};
13use sha2::{Digest, Sha256};
14use std::{
15    collections::{BTreeMap, BTreeSet},
16    fs,
17    path::Path,
18};
19
20pub const MAP_FILE: &str = "assertions.json";
21pub const STATE_FILE: &str = "assertions.state.json";
22pub struct RunManifest {
23    pub manifest: InputManifest,
24    pub evidence_digest: String,
25    legacy_digest: Option<String>,
26}
27pub struct RunInputs {
28    pub inputs: Inputs,
29    pub stored: RunManifest,
30}
31impl std::ops::Deref for RunInputs {
32    type Target = RunManifest;
33    fn deref(&self) -> &RunManifest {
34        &self.stored
35    }
36}
37pub fn load_inputs(root: &Path, run: &StoredRun) -> Result<RunInputs, String> {
38    let stored = load_manifest(run)?;
39    let inputs = crate::assertion_inputs::current_sources(root, &stored.manifest)?;
40    Ok(RunInputs { inputs, stored })
41}
42pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
43    load_optional_manifest(run)?.ok_or_else(|| {
44        "This older run has no assertion manifest. Run tests once with this version of Supercov"
45            .into()
46    })
47}
48fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
49    if run.metadata.merged == Some(true) {
50        return Err(
51            "Use a single run for assertion maps; merged runs have multiple input manifests".into(),
52        );
53    }
54    let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
55    let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
56    let entries = read_archive(&run.evidence_path).map_err(|e| e.to_string())?;
57    let Some(input) = entries.iter().find(|e| e.path == ARCHIVE_PATH) else {
58        return Ok(None);
59    };
60    let value: Value = serde_json::from_slice(&input.contents).map_err(|e| e.to_string())?;
61    let (manifest, legacy_digest) = match value["schemaVersion"].as_u64() {
62        Some(2) => (
63            serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?,
64            None,
65        ),
66        Some(1) => {
67            // Import old maps without requiring their former checkout. Legacy
68            // sources are used only to obtain hashes, never as today's source.
69            let legacy: Inputs = serde_json::from_value(value).map_err(|e| e.to_string())?;
70            if legacy
71                .assertions
72                .iter()
73                .any(|s| s.at.offset(&legacy.files).is_none())
74            {
75                return Err("Invalid legacy assertion inputs".into());
76            }
77            (legacy.manifest(), Some(digest(&legacy)))
78        }
79        _ => return Err("Unsupported assertion input schema".into()),
80    };
81    if manifest.files.iter().any(|(p, f)| {
82        !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
83    }) || manifest.assertions.iter().any(|s| {
84        !manifest.files.contains_key(&s.at.file)
85            || s.at.line == 0
86            || s.at.column == 0
87            || s.at.text.is_empty()
88    }) {
89        return Err("Invalid assertion input manifest".into());
90    }
91    if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
92        return Err("Run archive changed during read".into());
93    }
94    Ok(Some(RunManifest {
95        manifest,
96        evidence_digest,
97        legacy_digest,
98    }))
99}
100pub fn load(run: &StoredRun, input: &RunManifest) -> Result<(AssertionMap, State), String> {
101    let read = |file: &str| {
102        fs::read(run.directory.join(file))
103            .map_err(|e| format!("{file}: {e}; new test runs create assertion maps automatically"))
104    };
105    let map = model::parse_stored(&read(MAP_FILE)?).map_err(|e| format!("{MAP_FILE}: {e}"))?;
106    let state = model::parse_state(
107        &read(STATE_FILE)?,
108        &map,
109        &input.manifest,
110        &input.evidence_digest,
111        input.legacy_digest.as_deref(),
112    )?;
113    Ok((map, state))
114}
115fn write_json(
116    root: &Path,
117    run: &StoredRun,
118    name: &str,
119    value: &impl serde::Serialize,
120) -> Result<(), String> {
121    let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
122    bytes.push(b'\n');
123    atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
124}
125/// Create the map inside the unpublished run directory. The lifecycle publishes
126/// evidence, map and review state together with one directory rename. Older
127/// archives without assertion manifests and merged runs retain their existing behavior.
128pub(crate) fn prepare_publication(
129    root: &Path,
130    directory: &Path,
131    metadata: &RunMetadata,
132) -> Result<(), String> {
133    if metadata.merged == Some(true) {
134        return Ok(());
135    }
136    let run = StoredRun {
137        id: metadata.id.clone(),
138        directory: directory.into(),
139        evidence_path: directory.join("evidence.raw.gz"),
140        metadata_path: directory.join("run.json"),
141        query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
142        metadata: metadata.clone(),
143    };
144    let Some(input) = load_optional_manifest(&run)? else {
145        return Ok(());
146    };
147    // Refuse replacement even if this helper is accidentally called twice.
148    if directory.join(MAP_FILE).exists() || directory.join(STATE_FILE).exists() {
149        return Err("Refusing to replace an existing assertion map or review state".into());
150    }
151    let current = crate::assertion_inputs::current_sources(root, &input.manifest);
152    let inventory = discover_runs(root).map_err(|e| e.to_string())?;
153    let mut inheritance = Inheritance::default();
154    let mut inherited = None;
155    for previous in &inventory.runs {
156        if previous.id == run.id
157            || previous.metadata.merged == Some(true)
158            || previous.metadata.command != metadata.command
159            || (!previous.directory.join(MAP_FILE).exists()
160                && !previous.directory.join(STATE_FILE).exists())
161        {
162            continue;
163        }
164        let attempt = (|| {
165            let old = load_manifest(previous)?;
166            if old.manifest.language != input.manifest.language {
167                return Ok(None);
168            }
169            let (map, state) = load(previous, &old)?;
170            let a = &previous.metadata.integrity.fingerprint;
171            let b = &metadata.integrity.fingerprint;
172            // Source hashes are checked through anchors/watches by carry. Only
173            // execution context changes invalidate every inherited flow.
174            let context_changed = a.configuration != b.configuration
175                || a.dependencies != b.dependencies
176                || a.instrumenter != b.instrumenter
177                || old.manifest.context_digest != input.manifest.context_digest;
178            let current = match &current {
179                Ok(current) => current,
180                Err(reason) => {
181                    // Publish the run even if files were edited during testing.
182                    // Preserve authored work as suggestions instead of guessing
183                    // locations in a checkout that no longer matches this run.
184                    let (mut next, mut next_state) =
185                        seed_manifest(&input.manifest, &input.evidence_digest);
186                    next.retired_assertions = map.retired_assertions;
187                    for assertion in map.assertions {
188                        if let Some(site) =
189                            next.assertions.iter_mut().find(|a| a.at == assertion.at)
190                        {
191                            *site = assertion;
192                        } else {
193                            next.retired_assertions.push(Retired {
194                                assertion,
195                                reason: reason.clone(),
196                            });
197                        }
198                    }
199                    let mut reserved = next
200                        .retired_assertions
201                        .iter()
202                        .map(|r| r.assertion.id.clone())
203                        .chain(
204                            next.assertions
205                                .iter()
206                                .filter(|a| !a.flows.is_empty())
207                                .map(|a| a.id.clone()),
208                        )
209                        .collect::<BTreeSet<_>>();
210                    for assertion in next.assertions.iter_mut().filter(|a| a.flows.is_empty()) {
211                        while !reserved.insert(assertion.id.clone()) {
212                            assertion.id.push('_');
213                        }
214                    }
215                    invalidate(&mut next_state, &next, reason);
216                    add_change(
217                        &mut next_state,
218                        None,
219                        None,
220                        None,
221                        reason.clone(),
222                        BTreeSet::new(),
223                    );
224                    return Ok(Some((next, next_state)));
225                }
226            };
227            model::carry(
228                &map,
229                &state,
230                &old.manifest,
231                current,
232                &input.evidence_digest,
233                context_changed,
234            )
235            .map(Some)
236        })();
237        match attempt {
238            Ok(Some(pair)) => {
239                inheritance.from = Some(previous.id.clone());
240                inherited = Some(pair);
241                break;
242            }
243            Ok(None) => (),
244            Err(reason) => inheritance.skipped.push(SkippedMap {
245                run: previous.id.clone(),
246                reason,
247            }),
248        }
249    }
250    let (map, mut state) =
251        inherited.unwrap_or_else(|| seed_manifest(&input.manifest, &input.evidence_digest));
252    // A malformed newer map might contain changed claims. An older fallback
253    // preserves work, but must not silently restore its previous credit.
254    if !inheritance.skipped.is_empty() {
255        invalidate(
256            &mut state,
257            &map,
258            "newer assertion map could not be reused; inspect inherited claims",
259        );
260    }
261    if let Err(reason) = current {
262        invalidate(&mut state, &map, &reason);
263        add_change(&mut state, None, None, None, reason, BTreeSet::new());
264    }
265    state.inheritance = Some(inheritance);
266    write_json(root, &run, MAP_FILE, &map)?;
267    write_json(root, &run, STATE_FILE, &state)?;
268    Ok(())
269}
270pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
271    analyze_coverage_archive(&ArchiveReportRequest {
272        archive_path: run.evidence_path.clone(),
273        run_id: run.id.clone(),
274        generated_at: run.metadata.started_at.clone(),
275        integrity: None,
276        test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
277    })
278    .map_err(|e| format!("{e:?}"))
279}
280fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
281    if line == 0 {
282        return None;
283    }
284    let line = source.lines().nth(line - 1)?;
285    if language != "javascript" {
286        // Native Rust, Python and Ruby manifests use zero-based byte columns.
287        return column.checked_add(1);
288    }
289    if column == 0 {
290        return None;
291    }
292    let mut units = 0;
293    for (byte, ch) in line.char_indices() {
294        if units == column - 1 {
295            return Some(byte + 1);
296        }
297        units += ch.len_utf16();
298    }
299    (units == column - 1).then_some(line.len() + 1)
300}
301fn phase_location<'a>(
302    phase: &'a crate::coverage_report::CoveragePhase,
303    inputs: &Inputs,
304) -> Option<(&'a str, usize, usize)> {
305    if phase.kind != "assertion" || phase.status.as_deref() != Some("passed") {
306        return None;
307    }
308    let source = phase
309        .operation
310        .strip_prefix("Rust assertion at ")
311        .or(phase.source.as_deref());
312    let location = source?;
313    let mut parts = location.rsplitn(3, ':');
314    let column = parts.next()?.parse::<usize>().ok()?;
315    let line = parts.next()?.parse::<usize>().ok()?;
316    let file = parts.next()?;
317    let column = byte_column(inputs.files.get(file)?, line, column, &inputs.language)?;
318    Some((file, line, column))
319}
320
321/// Recompute from the mutable map on every query; never cache it into the
322/// immutable structural coverage index. Current source must match the run.
323pub fn report(root: &Path, run: &StoredRun) -> Result<Value, String> {
324    report_with_detail(root, run, None)
325}
326/// Read authored flows and their assessment from the same map snapshot.
327pub fn assertion(root: &Path, run: &StoredRun, id: &str) -> Result<Value, String> {
328    report_with_detail(root, run, Some(id))
329}
330fn report_with_detail(root: &Path, run: &StoredRun, id: Option<&str>) -> Result<Value, String> {
331    let input = load_inputs(root, run)?;
332    let (map, state) = load(run, &input)?;
333    let coverage = coverage(run)?;
334    let mut report = assess(
335        &map,
336        &state,
337        &input.inputs,
338        &coverage,
339        run.metadata.test_exit_code == Some(0),
340    );
341    if let Some(id) = id {
342        let matches = report["assertions"]
343            .as_array()
344            .unwrap()
345            .iter()
346            .filter(|a| a["id"] == id)
347            .collect::<Vec<_>>();
348        let mut row = match matches.as_slice() {
349            [row] => (*row).clone(),
350            [] => {
351                return Err(format!(
352                    "Unknown assertion ID: {id}; use runs {} assertions to list IDs",
353                    run.id
354                ));
355            }
356            _ => {
357                return Err(format!(
358                    "Ambiguous assertion ID: {id}; repair duplicate IDs in assertions.json"
359                ));
360            }
361        };
362        if let Some(authored) = map.assertions.iter().find(|a| a.id == id) {
363            for (flow, authored) in row["flows"]
364                .as_array_mut()
365                .unwrap()
366                .iter_mut()
367                .zip(&authored.flows)
368            {
369                let assessment = flow.as_object().unwrap().clone();
370                *flow = serde_json::to_value(authored).map_err(|e| e.to_string())?;
371                flow.as_object_mut().unwrap().extend(assessment);
372            }
373        }
374        report["assertion"] = row;
375    }
376    report["inheritance"] = json!(state.inheritance);
377    report["revision"] = json!(digest(&(&map, &state, &input.evidence_digest)));
378    Ok(report)
379}
380
381pub fn assess(
382    map: &AssertionMap,
383    state: &State,
384    inputs: &Inputs,
385    coverage: &CoverageReport,
386    passed: bool,
387) -> Value {
388    let manifest = inputs.manifest();
389    let validation = model::validation(map, state, inputs);
390    let errors = validation["errors"]
391        .as_array()
392        .unwrap()
393        .iter()
394        .map(|e| e.as_str().unwrap().to_owned())
395        .collect::<Vec<_>>();
396    let pending_changes = validation["changes"]
397        .as_array()
398        .unwrap()
399        .iter()
400        .filter(|c| c["current"] != true)
401        .count();
402    let view = &coverage.filters.passed;
403    let tests = view
404        .tests
405        .iter()
406        .map(|t| (&t.id, t))
407        .collect::<BTreeMap<_, _>>();
408    let measured_statements = view
409        .points
410        .iter()
411        .filter(|p| p.measured && p.meta.kind == crate::coverage_analysis::PointKind::Statement)
412        .collect::<Vec<_>>();
413    let mut by_file = BTreeMap::<&str, Vec<(usize, &crate::coverage_report::PointResult)>>::new();
414    let mut by_line = BTreeMap::<(String, usize), Vec<&crate::coverage_report::PointResult>>::new();
415    for point in &measured_statements {
416        by_line
417            .entry((point.meta.file.clone(), point.meta.line))
418            .or_default()
419            .push(point);
420        if let Some(text) = inputs.files.get(&point.meta.file)
421            && let Some(column) =
422                byte_column(text, point.meta.line, point.meta.column, &inputs.language)
423        {
424            let at = Anchor {
425                file: point.meta.file.clone(),
426                line: point.meta.line,
427                column,
428                text: point.meta.source.clone(),
429            };
430            if let Some(start) = at.offset(&inputs.files) {
431                by_file
432                    .entry(&point.meta.file)
433                    .or_default()
434                    .push((start, point));
435            }
436        }
437    }
438    for points in by_file.values_mut() {
439        points.sort_by_key(|(pos, _)| *pos);
440    }
441    let mut rows = Vec::new();
442    let mut claimed_points = BTreeSet::new();
443    let mut credited_points = BTreeSet::new();
444    let mut point_flows = BTreeMap::<String, BTreeSet<String>>::new();
445    let mut draft_flows = 0;
446    let mut stale_flows = 0;
447    let mut invalid_flows = 0;
448    let mut current_flows = 0;
449    let mut credit_flows = 0;
450    let mut line_assertions = BTreeMap::<(String, usize), BTreeSet<String>>::new();
451    // Duplicate identities invalidate all credit; stale anchors only invalidate
452    // their own flow, so an agent can repair a large map incrementally.
453    let identities_valid = state.inputs_digest == inputs.identity()
454        && errors.iter().all(|e| {
455            !e.contains("duplicate") && !e.contains("schema") && !e.contains("assertion ID")
456        });
457    // Join exact identities once, rather than rescanning the complete inventory
458    // for every assertion/phase pair. This is evidence lookup, not inference.
459    let mut phases_by_location = BTreeMap::new();
460    for p in &view.phases {
461        if let Some(location) = phase_location(&p.phase, inputs) {
462            phases_by_location
463                .entry(location)
464                .or_insert_with(Vec::new)
465                .push(p);
466        }
467    }
468    let mut inventory = BTreeMap::<&Anchor, BTreeSet<&str>>::new();
469    for site in &inputs.assertions {
470        inventory
471            .entry(&site.at)
472            .or_default()
473            .insert(&site.operation);
474    }
475    let mapped_anchors = map
476        .assertions
477        .iter()
478        .map(|a| &a.at)
479        .collect::<BTreeSet<_>>();
480    let mut used_ids = map
481        .assertions
482        .iter()
483        .map(|a| a.id.clone())
484        .chain(
485            map.retired_assertions
486                .iter()
487                .map(|r| r.assertion.id.clone()),
488        )
489        .collect::<BTreeSet<_>>();
490    let missing = seed(inputs, "")
491        .0
492        .assertions
493        .into_iter()
494        .filter(|a| !mapped_anchors.contains(&a.at))
495        .map(|mut a| {
496            while !used_ids.insert(a.id.clone()) {
497                a.id.push('_');
498            }
499            a
500        })
501        .collect::<Vec<_>>();
502    for (a, in_map) in map
503        .assertions
504        .iter()
505        .map(|a| (a, true))
506        .chain(missing.iter().map(|a| (a, false)))
507    {
508        let witnesses = phases_by_location
509            .get(&(a.at.file.as_str(), a.at.line, a.at.column))
510            .into_iter()
511            .flatten()
512            // Coordinates alone cannot identify a JS assertion: retain the
513            // complete inventoried expression and operation requirement.
514            .filter(|p| {
515                inputs.language != "javascript"
516                    || inventory
517                        .get(&a.at)
518                        .is_some_and(|operations| operations.contains(p.phase.operation.as_str()))
519            })
520            .map(|p| p.test.clone())
521            .collect::<BTreeSet<_>>();
522        let mut flows = Vec::new();
523        for f in &a.flows {
524            let dirty = model::reasons_for_manifest(a, f, map, state, inputs, &manifest);
525            let valid = model::validate_flow(f, &inputs.files).is_empty()
526                && a.at.offset(&inputs.files).is_some();
527            let freshness = if f.basis.is_none() {
528                draft_flows += 1;
529                "draft"
530            } else if f.basis.as_deref()
531                != Some(model::expected_basis(a, f, map, state, &manifest).as_str())
532            {
533                stale_flows += 1;
534                "stale"
535            } else {
536                "current"
537            };
538            if !valid {
539                invalid_flows += 1;
540            }
541            if dirty.is_empty() {
542                current_flows += 1;
543            }
544            // A file + exact displayed name must resolve to one logical test.
545            // Retry attempts remain under that identity; duplicate names do not.
546            let mut resolved = BTreeSet::new();
547            let mut selectors = Vec::new();
548            for selector in &f.applies_to {
549                let matches = coverage
550                    .view
551                    .tests
552                    .iter()
553                    .filter(|t| {
554                        t.file.as_deref() == Some(selector.file.as_str()) && t.name == selector.name
555                    })
556                    .collect::<Vec<_>>();
557                let status = match matches.as_slice() {
558                    [test] if witnesses.contains(&test.id) && tests.contains_key(&test.id) => {
559                        resolved.insert(test.id.clone());
560                        "observed"
561                    }
562                    [] => "missing",
563                    [_] => "unobserved",
564                    _ => "ambiguous",
565                };
566                selectors.push(json!({"file":selector.file,"name":selector.name,"status":status}));
567            }
568            let applicable = resolved;
569            let eligible = passed && identities_valid && dirty.is_empty() && !applicable.is_empty();
570            let mut blockers = Vec::new();
571            if !passed {
572                blockers.push("run did not pass");
573            }
574            if !identities_valid {
575                blockers.push("invalid map identities");
576            }
577            if !dirty.is_empty() {
578                blockers.push("flow requires review or reference repair");
579            }
580            if applicable.is_empty() {
581                blockers.push("no matching passing assertion occurrence for appliesTo");
582            }
583            if eligible {
584                credit_flows += 1;
585            }
586            let mut lines = BTreeSet::new();
587            for node in f
588                .nodes
589                .iter()
590                .filter(|n| f.counts_as_asserted.contains(&n.id))
591            {
592                let Some(start) = node.at.offset(&inputs.files) else {
593                    continue;
594                };
595                let Some(points) = by_file.get(node.at.file.as_str()) else {
596                    continue;
597                };
598                let first = points.partition_point(|(pos, _)| *pos < start);
599                for (pos, point) in points[first..].iter().take_while(|(pos, _)| *pos == start) {
600                    // One explicit node credits one exact measured statement.
601                    // A guard/class/block claim must not silently credit every
602                    // nested statement just because its source span contains it.
603                    if *pos != start || point.meta.source != node.at.text {
604                        continue;
605                    }
606                    claimed_points.insert(point.meta.id.clone());
607                    if eligible
608                        && point.covered
609                        && point.tests.iter().any(|t| applicable.contains(t))
610                    {
611                        credited_points.insert(point.meta.id.clone());
612                        point_flows
613                            .entry(point.meta.id.clone())
614                            .or_default()
615                            .insert(flow_key(a, f));
616                        lines.insert((node.at.file.clone(), point.meta.line));
617                        line_assertions
618                            .entry((node.at.file.clone(), point.meta.line))
619                            .or_default()
620                            .insert(a.id.clone());
621                    }
622                }
623            }
624            flows.push(json!({"id":f.id,"freshness":freshness,"valid":valid,"current":dirty.is_empty(),"expectedBasis":model::expected_basis(a,f,map,state,&manifest),"selectors":selectors,"questions":f.questions,"reasons":dirty,"eligible":eligible,"blockers":blockers,"matchingTests":applicable,"creditedStatementLines":lines}));
625        }
626        rows.push(json!({"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}));
627    }
628    let denominator = coverage
629        .view
630        .lines
631        .iter()
632        .filter(|l| l.measured)
633        .map(|l| (l.file.clone(), l.line))
634        .collect::<BTreeSet<_>>();
635    let mut credited = BTreeSet::new();
636    let mut declared = BTreeSet::new();
637    for location in &denominator {
638        let statements = by_line.get(location).map(Vec::as_slice).unwrap_or(&[]);
639        if !statements.is_empty()
640            && statements
641                .iter()
642                .all(|p| claimed_points.contains(&p.meta.id))
643        {
644            declared.insert(location.clone());
645        }
646        if !statements.is_empty()
647            && statements
648                .iter()
649                .all(|p| credited_points.contains(&p.meta.id))
650        {
651            credited.insert(location.clone());
652        }
653    }
654    let missing_inventory = inputs
655        .assertions
656        .iter()
657        .filter(|s| !map.assertions.iter().any(|a| a.at == s.at))
658        .count();
659    let (status, reason) = if !passed || !identities_valid {
660        ("unavailable", "Run failed or map identities are invalid")
661    } else if pending_changes > 0 {
662        ("pending", "Source changes need impact assessment")
663    } else if measured_statements.is_empty() {
664        ("notApplicable", "No measured statements")
665    } else if credit_flows > 0 {
666        (
667            "available",
668            "Agent-assessed statements; mapping completeness is unknown",
669        )
670    } else if map.assertions.iter().all(|a| a.flows.is_empty()) {
671        ("notAssessed", "No recorded flow explanations")
672    } else {
673        (
674            "pending",
675            "No current flow with matching passing assertion evidence",
676        )
677    };
678    let assertions_without_current_explanation = rows
679        .iter()
680        .filter(|a| {
681            inventory.keys().any(|at| json!(at) == a["at"])
682                && a["observedPassingTests"]
683                    .as_array()
684                    .is_some_and(|v| !v.is_empty())
685                && a["flows"]
686                    .as_array()
687                    .is_none_or(|v| !v.iter().any(|f| f["eligible"] == true))
688        })
689        .count();
690    let total = denominator.len();
691    let statements = measured_statements.iter().map(|p| {
692        let at = inputs.files.get(&p.meta.file)
693            .and_then(|text| byte_column(text, p.meta.line, p.meta.column, &inputs.language))
694            .map(|column| Anchor { file: p.meta.file.clone(), line: p.meta.line, column, text: p.meta.source.clone() })
695            .filter(|at| at.offset(&inputs.files).is_some());
696        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()})
697    }).collect::<Vec<_>>();
698    json!({"basis":"agent-assessed; passing assertion identity and same-test execution required; not mutation resistance",
699        "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,
700            "assertionsWithFlows":rows.iter().filter(|a| a["flows"].as_array().is_some_and(|f| !f.is_empty())).count(),
701            "assertionsWithoutFlows":rows.iter().filter(|a| a["flows"].as_array().is_none_or(Vec::is_empty)).count(),
702            "observedAssertionsWithoutCurrentExplanation":assertions_without_current_explanation,
703            "questions":map.assertions.iter().map(|a| a.questions.len()+a.flows.iter().map(|f| f.questions.len()).sum::<usize>()).sum::<usize>(),
704            "currentFlows":current_flows,"draftFlows":draft_flows,"staleFlows":stale_flows,"invalidFlows":invalid_flows,"eligibleFlows":credit_flows,"retiredAssertions":map.retired_assertions.len(),
705            "unobservedAssertions":rows.iter().filter(|a| a["observedPassingTests"].as_array().is_none_or(Vec::is_empty)).count(),
706            "inventoryFailures":inputs.limitations.iter().filter(|s| s.starts_with("Inventory unavailable for ")).count(),
707            "unanchoredStatements":statements.iter().filter(|s| s["at"].is_null()).count(),
708            "runPassed":passed,
709            "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)}}},
710        "assertions":rows,"statements":statements,"tests":tests.values().map(|t| json!({"id":t.id,"file":t.file,"name":t.name})).collect::<Vec<_>>(),
711        "creditedLines":credited.iter().map(|loc| json!({"file":loc.0,"line":loc.1,"assertions":line_assertions.get(loc)})).collect::<Vec<_>>(),
712        "unassertedLines":denominator.difference(&credited).map(|(f,l)| json!({"file":f,"line":l})).collect::<Vec<_>>(),
713        "changes":validation["changes"],"validationErrors":errors,"limitations":inputs.limitations})
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
720
721    #[test]
722    fn legacy_maps_import_without_the_old_checkout_and_require_review() {
723        let root = std::env::temp_dir().join(format!("supercov-legacy-map-{}", std::process::id()));
724        fs::create_dir_all(&root).unwrap();
725        let source = "import assert from 'node:assert/strict'; assert.equal(1, 1);\n";
726        fs::write(root.join("test.js"), source).unwrap();
727        let old =
728            crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
729        let directory = crate::run_store::create_analyzable_test_run(&root, "legacy");
730        let path = directory.join("evidence.raw.gz");
731        let mut entries = read_archive(&path).unwrap();
732        entries.push(EvidenceArchiveEntry {
733            path: ARCHIVE_PATH.into(),
734            contents: serde_json::to_vec(&old).unwrap(),
735        });
736        let archive = write_archive(entries, &path).unwrap();
737        let metadata_path = directory.join("run.json");
738        let mut metadata: RunMetadata =
739            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
740        metadata.raw_evidence.files = archive.files;
741        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
742        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
743        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
744        let run = discover_runs(&root).unwrap().runs.remove(0);
745        let stored = load_manifest(&run).unwrap();
746        let (map, _) = seed(&old, &stored.evidence_digest);
747        let legacy_map = json!({"schemaVersion":1,"assertions":[{"id":map.assertions[0].id,"at":map.assertions[0].at,"analysis":"mapped","observes":[],"flows":[{
748            "id":"constant","explanation":"The assertion checks the constant one.","appliesTo":[],"nodes":[],"edges":[],"countsAsAsserted":[],"watch":[{"kind":"span","at":map.assertions[0].at}]
749        }]}]});
750        let legacy_state = json!({"schemaVersion":1,"inputsDigest":digest(&old),"evidenceDigest":stored.evidence_digest,"reviews":{},"scopeReview":[]});
751        write_json(&root, &run, MAP_FILE, &legacy_map).unwrap();
752        write_json(&root, &run, STATE_FILE, &legacy_state).unwrap();
753        let map = model::parse_stored(&serde_json::to_vec(&legacy_map).unwrap()).unwrap();
754        let map_bytes = fs::read(directory.join(MAP_FILE)).unwrap();
755        let state_bytes = fs::read(directory.join(STATE_FILE)).unwrap();
756
757        fs::write(root.join("test.js"), format!("\n{source}")).unwrap();
758        assert!(load_inputs(&root, &run).is_err());
759        let (imported, state) = load(&run, &stored).unwrap();
760        let new =
761            crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
762        let (next, state) =
763            carry(&imported, &state, &stored.manifest, &new, "new-run", false).unwrap();
764        assert_eq!(next.assertions[0].id, map.assertions[0].id);
765        assert_eq!(
766            next.assertions[0].flows[0].explanation,
767            map.assertions[0].flows[0].explanation
768        );
769        assert!(next.assertions[0].flows[0].basis.is_none());
770        assert!(!next.assertions[0].flows[0].questions.is_empty());
771        assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
772        assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
773
774        // A checkout edited during a run must not prevent publication or
775        // discard explanations. The run manifest still identifies its sites.
776        let next_directory = crate::run_store::create_analyzable_test_run(&root, "next");
777        let mut entries = read_archive(&path).unwrap();
778        entries
779            .iter_mut()
780            .find(|e| e.path == ARCHIVE_PATH)
781            .unwrap()
782            .contents = serde_json::to_vec(&old.manifest()).unwrap();
783        let raw = write_archive(entries, &next_directory.join("evidence.raw.gz")).unwrap();
784        let mut next_metadata = metadata.clone();
785        next_metadata.id = "next".into();
786        next_metadata.started_at = "next".into();
787        next_metadata.raw_evidence.compressed_bytes = raw.compressed_bytes;
788        next_metadata.raw_evidence.uncompressed_bytes = raw.uncompressed_bytes;
789        fs::write(
790            next_directory.join("run.json"),
791            serde_json::to_vec(&next_metadata).unwrap(),
792        )
793        .unwrap();
794        fs::remove_file(root.join("test.js")).unwrap();
795        prepare_publication(&root, &next_directory, &next_metadata).unwrap();
796        let next_run = discover_runs(&root)
797            .unwrap()
798            .runs
799            .into_iter()
800            .find(|r| r.id == "next")
801            .unwrap();
802        let next_manifest = load_manifest(&next_run).unwrap();
803        let (pending, pending_state) = load(&next_run, &next_manifest).unwrap();
804        assert_eq!(pending.assertions[0], map.assertions[0]);
805        assert!(!pending_state.changes.is_empty());
806        assert!(
807            !pending_state.flows
808                [&flow_key(&pending.assertions[0], &pending.assertions[0].flows[0])]
809                .reasons
810                .is_empty()
811        );
812        assert!(load_inputs(&root, &next_run).is_err());
813        assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
814        assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
815
816        let mut corrupt = state.clone();
817        corrupt.evidence_digest = "wrong".into();
818        write_json(&root, &run, STATE_FILE, &corrupt).unwrap();
819        assert!(load(&run, &stored).is_err());
820        fs::remove_dir_all(root).unwrap();
821    }
822}