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