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};
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            let delta = context_delta(
181                a,
182                b,
183                &old.manifest.context_digest,
184                &input.manifest.context_digest,
185            );
186            let context_changed = delta.execution;
187            let dependencies_changed = delta.dependencies;
188            let current = match &current {
189                Ok(current) => current,
190                Err(reason) => {
191                    // Publish the run even if files were edited during testing.
192                    // Preserve authored work as suggestions instead of guessing
193                    // locations in a checkout that no longer matches this run.
194                    let (mut next, mut next_state) =
195                        seed_manifest(&input.manifest, &input.evidence_digest);
196                    next.retired_assertions = map.retired_assertions;
197                    for assertion in map.assertions {
198                        if let Some(site) =
199                            next.assertions.iter_mut().find(|a| a.at == assertion.at)
200                        {
201                            *site = assertion;
202                        } else {
203                            next.retired_assertions.push(Retired {
204                                assertion,
205                                reason: reason.clone(),
206                            });
207                        }
208                    }
209                    let mut reserved = next
210                        .retired_assertions
211                        .iter()
212                        .map(|r| r.assertion.id.clone())
213                        .chain(
214                            next.assertions
215                                .iter()
216                                .filter(|a| !a.flows.is_empty())
217                                .map(|a| a.id.clone()),
218                        )
219                        .collect::<BTreeSet<_>>();
220                    for assertion in next.assertions.iter_mut().filter(|a| a.flows.is_empty()) {
221                        while !reserved.insert(assertion.id.clone()) {
222                            assertion.id.push('_');
223                        }
224                    }
225                    invalidate(&mut next_state, &next, reason);
226                    add_change(
227                        &mut next_state,
228                        None,
229                        None,
230                        None,
231                        reason.clone(),
232                        BTreeSet::new(),
233                    );
234                    return Ok(Some((next, next_state)));
235                }
236            };
237            model::carry(
238                &map,
239                &state,
240                &old.manifest,
241                current,
242                &input.evidence_digest,
243                context_changed,
244            )
245            .map(|(next, mut next_state)| {
246                if dependencies_changed {
247                    add_change(
248                        &mut next_state,
249                        None,
250                        Some(a.dependencies.clone()),
251                        Some(b.dependencies.clone()),
252                        "installed dependencies changed".into(),
253                        BTreeSet::new(),
254                    );
255                }
256                Some((next, next_state))
257            })
258        })();
259        match attempt {
260            Ok(Some(pair)) => {
261                inheritance.from = Some(previous.id.clone());
262                inherited = Some(pair);
263                break;
264            }
265            Ok(None) => (),
266            Err(reason) => inheritance.skipped.push(SkippedMap {
267                run: previous.id.clone(),
268                reason,
269            }),
270        }
271    }
272    let (map, mut state) =
273        inherited.unwrap_or_else(|| seed_manifest(&input.manifest, &input.evidence_digest));
274    // A malformed newer map might contain changed claims. An older fallback
275    // preserves work, but must not silently restore its previous credit.
276    if !inheritance.skipped.is_empty() {
277        invalidate(
278            &mut state,
279            &map,
280            "newer assertion map could not be reused; inspect inherited claims",
281        );
282    }
283    if let Err(reason) = current {
284        invalidate(&mut state, &map, &reason);
285        add_change(&mut state, None, None, None, reason, BTreeSet::new());
286    }
287    state.inheritance = Some(inheritance);
288    write_json(root, &run, MAP_FILE, &map)?;
289    write_json(root, &run, STATE_FILE, &state)?;
290    Ok(())
291}
292/// How a difference between two runs reaches the flows inherited across it.
293struct ContextDelta {
294    /// What actually executes moved, so every inherited flow has to be read
295    /// again before it can be trusted.
296    execution: bool,
297    /// The installed dependency set moved. Recorded as one change to assess
298    /// rather than as staleness on every flow.
299    dependencies: bool,
300}
301
302/// Source hashes are checked through anchors and watches by `carry`; this is
303/// only about what surrounds them.
304///
305/// The instrumenter is deliberately absent. It is Supercov's own version, so
306/// including it made every release mark every map in the world stale -- for
307/// claims that are about the project's code, not about Supercov. When the
308/// meaning of credit itself changes, the basis domain is the thing that moves.
309///
310/// Dependencies are separated rather than dropped. An upgrade can falsify an
311/// authored explanation without touching a single project file, so it cannot
312/// pass unsaid; but it usually falsifies nothing, and making every flow stale
313/// for it spends the attention the author needs for the changes that do
314/// matter. An acknowledgement demanded six hundred times at once stops being
315/// read, which is the opposite of what it is for.
316fn context_delta(
317    a: &RunFingerprint,
318    b: &RunFingerprint,
319    old_context: &str,
320    new_context: &str,
321) -> ContextDelta {
322    ContextDelta {
323        execution: a.configuration != b.configuration || old_context != new_context,
324        dependencies: a.dependencies != b.dependencies,
325    }
326}
327
328pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
329    analyze_coverage_archive(&ArchiveReportRequest {
330        archive_path: run.evidence_path.clone(),
331        run_id: run.id.clone(),
332        generated_at: run.metadata.started_at.clone(),
333        integrity: None,
334        test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
335    })
336    .map_err(|e| format!("{e:?}"))
337}
338fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
339    if line == 0 {
340        return None;
341    }
342    let line = source.lines().nth(line - 1)?;
343    if language != "javascript" {
344        // Native Rust, Python and Ruby manifests use zero-based byte columns.
345        return column.checked_add(1);
346    }
347    if column == 0 {
348        return None;
349    }
350    let mut units = 0;
351    for (byte, ch) in line.char_indices() {
352        if units == column - 1 {
353            return Some(byte + 1);
354        }
355        units += ch.len_utf16();
356    }
357    (units == column - 1).then_some(line.len() + 1)
358}
359fn phase_location<'a>(
360    phase: &'a crate::coverage_report::CoveragePhase,
361    inputs: &Inputs,
362) -> Option<(&'a str, usize, usize)> {
363    if phase.kind != "assertion" || phase.status.as_deref() != Some("passed") {
364        return None;
365    }
366    let source = phase
367        .operation
368        .strip_prefix("Rust assertion at ")
369        .or(phase.source.as_deref());
370    let location = source?;
371    let mut parts = location.rsplitn(3, ':');
372    let column = parts.next()?.parse::<usize>().ok()?;
373    let line = parts.next()?.parse::<usize>().ok()?;
374    let file = parts.next()?;
375    let column = byte_column(inputs.files.get(file)?, line, column, &inputs.language)?;
376    Some((file, line, column))
377}
378
379/// Cache derived assessments separately from immutable coverage evidence.
380/// Every query still verifies current source, map and managed-state identities.
381pub fn report(root: &Path, run: &StoredRun) -> Result<Value, String> {
382    report_with_detail(root, run, None)
383}
384/// Read authored flows and their assessment from the same map snapshot.
385pub fn assertion(root: &Path, run: &StoredRun, id: &str) -> Result<Value, String> {
386    report_with_detail(root, run, Some(id))
387}
388fn report_with_detail(root: &Path, run: &StoredRun, id: Option<&str>) -> Result<Value, String> {
389    let input = load_inputs(root, run)?;
390    let (map, state) = load(run, &input)?;
391    let cache_key = digest(&(
392        env!("SUPERCOV_ENGINE_SOURCE_SHA256"),
393        &run.id,
394        run.metadata.test_exit_code,
395        &map,
396        &state,
397        &input.evidence_digest,
398    ));
399    let cache_path = run.directory.join(REPORT_CACHE_FILE);
400    let mut report = read_report_cache(&cache_path, &cache_key).unwrap_or_else(|| Value::Null);
401    if report.is_null() {
402        let coverage = coverage(run)?;
403        report = assess(
404            &map,
405            &state,
406            &input.inputs,
407            &coverage,
408            run.metadata.test_exit_code == Some(0),
409        );
410        report["excludedStatements"] = json!(input.statement_exclusions);
411        report["summary"]["excludedStatements"] = json!(input.statement_exclusions.len());
412        // This is disposable acceleration. A read-only directory or corrupt
413        // cache must never prevent a freshly computed report from working.
414        let cached = json!({"key":cache_key,"digest":digest(&report),"report":report});
415        let _ = write_json(root, run, REPORT_CACHE_FILE, &cached);
416    }
417    if let Some(id) = id {
418        let matches = report["assertions"]
419            .as_array()
420            .unwrap()
421            .iter()
422            .filter(|a| a["id"] == id)
423            .collect::<Vec<_>>();
424        let mut row = match matches.as_slice() {
425            [row] => (*row).clone(),
426            [] => {
427                return Err(format!(
428                    "Unknown assertion ID: {id}; use runs {} assertions to list IDs",
429                    run.id
430                ));
431            }
432            _ => {
433                return Err(format!(
434                    "Ambiguous assertion ID: {id}; repair duplicate IDs in assertions.json"
435                ));
436            }
437        };
438        if let Some(authored) = map.assertions.iter().find(|a| a.id == id) {
439            for (flow, authored) in row["flows"]
440                .as_array_mut()
441                .unwrap()
442                .iter_mut()
443                .zip(&authored.flows)
444            {
445                let assessment = flow.as_object().unwrap().clone();
446                *flow = serde_json::to_value(authored).map_err(|e| e.to_string())?;
447                flow.as_object_mut().unwrap().extend(assessment);
448            }
449        }
450        report["assertion"] = row;
451    }
452    report["inheritance"] = json!(state.inheritance);
453    report["revision"] = json!(digest(&(&map, &state, &input.evidence_digest)));
454    Ok(report)
455}
456
457fn read_report_cache(path: &Path, key: &str) -> Option<Value> {
458    if fs::metadata(path).ok()?.len() > 256 * 1024 * 1024 {
459        return None;
460    }
461    let cached: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
462    let report = &cached["report"];
463    (cached["key"] == key
464        && cached["digest"] == digest(report)
465        && report["summary"].is_object()
466        && report["assertions"].is_array())
467    .then(|| report.clone())
468}
469
470pub fn assess(
471    map: &AssertionMap,
472    state: &State,
473    inputs: &Inputs,
474    coverage: &CoverageReport,
475    passed: bool,
476) -> Value {
477    let manifest = inputs.manifest();
478    let validation = model::validation(map, state, inputs);
479    let errors = validation["errors"]
480        .as_array()
481        .unwrap()
482        .iter()
483        .map(|e| e.as_str().unwrap().to_owned())
484        .collect::<Vec<_>>();
485    let pending_changes = validation["changes"]
486        .as_array()
487        .unwrap()
488        .iter()
489        .filter(|c| c["current"] != true)
490        .count();
491    let view = &coverage.filters.passed;
492    let tests = view
493        .tests
494        .iter()
495        .filter(|t| t.role == "test")
496        .map(|t| (&t.id, t))
497        .collect::<BTreeMap<_, _>>();
498    let measured_statements = view
499        .points
500        .iter()
501        .filter(|p| p.measured && p.meta.kind == crate::coverage_analysis::PointKind::Statement)
502        .collect::<Vec<_>>();
503    let all_points = coverage
504        .view
505        .points
506        .iter()
507        .map(|p| (&p.meta.id, p))
508        .collect::<BTreeMap<_, _>>();
509    let all_tests = coverage
510        .view
511        .tests
512        .iter()
513        .map(|t| (&t.id, t))
514        .collect::<BTreeMap<_, _>>();
515    let mut by_file = BTreeMap::<&str, Vec<(usize, &crate::coverage_report::PointResult)>>::new();
516    let mut by_line = BTreeMap::<(String, usize), Vec<&crate::coverage_report::PointResult>>::new();
517    for point in &measured_statements {
518        by_line
519            .entry((point.meta.file.clone(), point.meta.line))
520            .or_default()
521            .push(point);
522        if let Some(text) = inputs.files.get(&point.meta.file)
523            && let Some(column) =
524                byte_column(text, point.meta.line, point.meta.column, &inputs.language)
525        {
526            let at = Anchor {
527                file: point.meta.file.clone(),
528                line: point.meta.line,
529                column,
530                text: point.meta.source.clone(),
531            };
532            if let Some(start) = at.offset(&inputs.files) {
533                by_file
534                    .entry(&point.meta.file)
535                    .or_default()
536                    .push((start, point));
537            }
538        }
539    }
540    for points in by_file.values_mut() {
541        points.sort_by_key(|(pos, _)| *pos);
542    }
543    let mut rows = Vec::new();
544    let mut claimed_points = BTreeSet::new();
545    let mut credited_points = BTreeSet::new();
546    let mut point_flows = BTreeMap::<String, BTreeSet<String>>::new();
547    let mut draft_flows = 0;
548    let mut stale_flows = 0;
549    let mut invalid_flows = 0;
550    let mut current_flows = 0;
551    let mut credit_flows = 0;
552    let mut line_assertions = BTreeMap::<(String, usize), BTreeSet<String>>::new();
553    // Duplicate identities invalidate all credit; stale anchors only invalidate
554    // their own flow, so an agent can repair a large map incrementally.
555    let identities_valid = state.inputs_digest == inputs.identity()
556        && errors.iter().all(|e| {
557            !e.contains("duplicate") && !e.contains("schema") && !e.contains("assertion ID")
558        });
559    // Join exact identities once, rather than rescanning the complete inventory
560    // for every assertion/phase pair. This is evidence lookup, not inference.
561    let mut phases_by_location = BTreeMap::new();
562    for p in &view.phases {
563        if !tests.contains_key(&p.test) {
564            continue;
565        }
566        if let Some(location) = phase_location(&p.phase, inputs) {
567            phases_by_location
568                .entry(location)
569                .or_insert_with(Vec::new)
570                .push(p);
571        }
572    }
573    let mut inventory = BTreeMap::<&Anchor, BTreeSet<&str>>::new();
574    for site in &inputs.assertions {
575        inventory
576            .entry(&site.at)
577            .or_default()
578            .insert(&site.operation);
579    }
580    let mapped_anchors = map
581        .assertions
582        .iter()
583        .map(|a| &a.at)
584        .collect::<BTreeSet<_>>();
585    let mut used_ids = map
586        .assertions
587        .iter()
588        .map(|a| a.id.clone())
589        .chain(
590            map.retired_assertions
591                .iter()
592                .map(|r| r.assertion.id.clone()),
593        )
594        .collect::<BTreeSet<_>>();
595    let missing = seed(inputs, "")
596        .0
597        .assertions
598        .into_iter()
599        .filter(|a| !mapped_anchors.contains(&a.at))
600        .map(|mut a| {
601            while !used_ids.insert(a.id.clone()) {
602                a.id.push('_');
603            }
604            a
605        })
606        .collect::<Vec<_>>();
607    for (a, in_map) in map
608        .assertions
609        .iter()
610        .map(|a| (a, true))
611        .chain(missing.iter().map(|a| (a, false)))
612    {
613        let witnesses = phases_by_location
614            .get(&(a.at.file.as_str(), a.at.line, a.at.column))
615            .into_iter()
616            .flatten()
617            // Coordinates alone cannot identify a JS assertion: retain the
618            // complete inventoried expression and operation requirement.
619            .filter(|p| {
620                inputs.language != "javascript"
621                    || inventory
622                        .get(&a.at)
623                        .is_some_and(|operations| operations.contains(p.phase.operation.as_str()))
624            })
625            .map(|p| p.test.clone())
626            .collect::<BTreeSet<_>>();
627        let mut flows = Vec::new();
628        for f in &a.flows {
629            let dirty = model::reasons_for_manifest(a, f, map, state, inputs, &manifest);
630            let valid = model::validate_flow(f, &inputs.files).is_empty()
631                && a.at.offset(&inputs.files).is_some();
632            let freshness = if f.basis.is_none() {
633                draft_flows += 1;
634                "draft"
635            } else if f.basis.as_deref()
636                != Some(model::expected_basis(a, f, map, state, &manifest).as_str())
637            {
638                stale_flows += 1;
639                "stale"
640            } else {
641                "current"
642            };
643            if !valid {
644                invalid_flows += 1;
645            }
646            if dirty.is_empty() {
647                current_flows += 1;
648            }
649            // A file + exact displayed name must resolve to one logical test.
650            // Retry attempts remain under that identity; duplicate names do not.
651            let mut resolved = BTreeSet::new();
652            let mut selectors = Vec::new();
653            for selector in &f.applies_to {
654                let matches = coverage
655                    .view
656                    .tests
657                    .iter()
658                    .filter(|t| {
659                        t.file.as_deref() == Some(selector.file.as_str()) && t.name == selector.name
660                    })
661                    .collect::<Vec<_>>();
662                let status = match matches.as_slice() {
663                    [test] if witnesses.contains(&test.id) && tests.contains_key(&test.id) => {
664                        resolved.insert(test.id.clone());
665                        "observed"
666                    }
667                    [] => "missing",
668                    [_] => "unobserved",
669                    _ => "ambiguous",
670                };
671                selectors.push(json!({"file":selector.file,"name":selector.name,"status":status,
672                    "outcomes":matches.iter().map(|t| &t.outcome).collect::<Vec<_>>(),
673                    "reason":match matches.as_slice() {
674                        [] => "No test execution record matches this selector. The test may be disabled or outside this run.",
675                        [test] if test.outcome != "passed" => "The selected test has no passing outcome.",
676                        [_] if status == "unobserved" => "The test passed but this assertion occurrence was not recorded. Its branch may not have run, or attribution may be missing.",
677                        [_] => "A passing assertion occurrence matches this test.",
678                        _ => "Multiple test identities match this selector."
679                    }}));
680            }
681            let applicable = resolved;
682            let eligible = passed && identities_valid && dirty.is_empty() && !applicable.is_empty();
683            let mut blockers = Vec::new();
684            if !passed {
685                blockers.push("run did not pass");
686            }
687            if !identities_valid {
688                blockers.push("invalid map identities");
689            }
690            if !dirty.is_empty() {
691                blockers.push("flow requires review or reference repair");
692            }
693            if applicable.is_empty() {
694                blockers.push("no matching passing assertion occurrence for appliesTo");
695            }
696            if eligible {
697                credit_flows += 1;
698            }
699            let mut lines = BTreeSet::new();
700            let mut node_credit = Vec::new();
701            for node in &f.nodes {
702                let claimed = f.counts_as_asserted.contains(&node.id);
703                let start = node.at.offset(&inputs.files);
704                let mut matched = Vec::new();
705                if let Some(start) = start
706                    && let Some(points) = by_file.get(node.at.file.as_str())
707                {
708                    let first = points.partition_point(|(pos, _)| *pos < start);
709                    for (_, point) in points[first..].iter().take_while(|(pos, _)| *pos == start) {
710                        // Exact statement identity only: a guard/block does not
711                        // include its nested statements in the score or diagnostic.
712                        if point.meta.source == node.at.text {
713                            matched.push(*point);
714                        }
715                    }
716                }
717                let matching_tests = matched
718                    .iter()
719                    .filter(|p| p.covered)
720                    .flat_map(|p| p.tests.iter())
721                    .filter(|test| applicable.contains(*test))
722                    .collect::<BTreeSet<_>>();
723                let credited = claimed && eligible && !matching_tests.is_empty();
724                let mut reasons = Vec::new();
725                let mut reason = |code: &str, message: String| {
726                    reasons.push(json!({"code":code,"message":message}));
727                };
728                if !claimed {
729                    reason(
730                        "context_only",
731                        "The agent included this node as context, not in countsAsAsserted.".into(),
732                    );
733                } else if credited {
734                    reason("same_test_execution", "Current agent claim, passing assertion, and statement execution in the same selected test.".into());
735                } else {
736                    if start.is_none() {
737                        reason(
738                            "invalid_source_anchor",
739                            "The node's source anchor does not match the run's current source."
740                                .into(),
741                        );
742                    } else if matched.is_empty() {
743                        reason("no_measured_statement", "The node does not exactly identify a measured production statement in this run.".into());
744                    }
745                    if !passed {
746                        reason("run_failed", "The test run did not pass.".into());
747                    }
748                    if !identities_valid {
749                        reason("invalid_map_identity", "Map identities or managed input state are invalid; see validation errors.".into());
750                    }
751                    if !dirty.is_empty() {
752                        reason(
753                            "flow_needs_attention",
754                            format!(
755                                "Flow needs investigation: {}",
756                                dirty.iter().cloned().collect::<Vec<_>>().join("; ")
757                            ),
758                        );
759                    }
760                    if applicable.is_empty() {
761                        reason("no_passing_assertion", "No selected test has a matching passing occurrence of this assertion; see flow selectors.".into());
762                    }
763                    if !matched.is_empty() {
764                        if !matched.iter().any(|p| p.covered) {
765                            let executed = matched
766                                .iter()
767                                .filter_map(|p| all_points.get(&p.meta.id))
768                                .filter(|p| p.covered)
769                                .collect::<Vec<_>>();
770                            if !executed.is_empty() {
771                                let setup = executed
772                                    .iter()
773                                    .flat_map(|p| &p.tests)
774                                    .any(|id| all_tests.get(id).is_some_and(|t| t.role == "setup"));
775                                reason(if setup {"shared_setup_execution"} else {"execution_outside_passing_tests"},
776                                    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());
777                            }
778                            reason(
779                                "no_passing_execution",
780                                "No execution evidence from passing tests for this statement."
781                                    .into(),
782                            );
783                        } else if !applicable.is_empty() && matching_tests.is_empty() {
784                            if matched
785                                .iter()
786                                .flat_map(|p| &p.tests)
787                                .any(|id| all_tests.get(id).is_some_and(|t| t.role == "setup"))
788                            {
789                                reason("shared_setup_execution", "Execution was recorded in a separate setup scope. Shared setup is not automatically credited to consuming tests.".into());
790                            }
791                            reason("no_same_test_execution", "No execution evidence attributed to a selected passing test for this assertion.".into());
792                        }
793                    }
794                }
795                node_credit.push(json!({
796                    "nodeId":node.id,
797                    "location":{"file":node.at.file,"line":node.at.line,"column":node.at.column},
798                    "status":if !claimed {"context"} else if credited {"credited"} else {"notCredited"},
799                    "statementIds":matched.iter().map(|p| &p.meta.id).collect::<Vec<_>>(),
800                    "matchingTests":matching_tests,
801                    "reasons":reasons,
802                }));
803                for point in matched.into_iter().filter(|_| claimed) {
804                    claimed_points.insert(point.meta.id.clone());
805                    if eligible
806                        && point.covered
807                        && point.tests.iter().any(|t| applicable.contains(t))
808                    {
809                        credited_points.insert(point.meta.id.clone());
810                        point_flows
811                            .entry(point.meta.id.clone())
812                            .or_default()
813                            .insert(flow_key(a, f));
814                        lines.insert((node.at.file.clone(), point.meta.line));
815                        line_assertions
816                            .entry((node.at.file.clone(), point.meta.line))
817                            .or_default()
818                            .insert(a.id.clone());
819                    }
820                }
821            }
822            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}));
823        }
824        let observation = if !witnesses.is_empty() {
825            "Passing assertion occurrence recorded."
826        } else if flows
827            .iter()
828            .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
829            .any(|s| s["status"] == "missing")
830        {
831            "No passing occurrence. Some selected tests have no execution record (for example disabled tests or tests outside this run)."
832        } else if flows
833            .iter()
834            .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
835            .any(|s| {
836                s["outcomes"]
837                    .as_array()
838                    .is_some_and(|outcomes| outcomes.iter().any(|o| o == "skipped"))
839            })
840        {
841            "No passing occurrence. Selected tests include skipped/TODO executions."
842        } else {
843            "No passing occurrence recorded. The assertion may be in an untaken branch or its execution attribution may be missing; inspect the selected tests."
844        };
845        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}));
846    }
847    // Only lines carrying a measured statement can ever be asserted: credit is
848    // claimed per statement, and statements are keyed by their anchor line. A
849    // measured line without one — a continuation line of a multi-line
850    // statement, or a nested function/arrow body with no statement of its own —
851    // is unclaimable by construction, so counting it here would cap the
852    // percentage below 100% for structural reasons and list it under
853    // `unassertedLines` as if an agent could act on it.
854    let denominator = coverage
855        .view
856        .lines
857        .iter()
858        .filter(|l| l.measured)
859        .map(|l| (l.file.clone(), l.line))
860        .filter(|location| by_line.contains_key(location))
861        .collect::<BTreeSet<_>>();
862    let mut credited = BTreeSet::new();
863    let mut declared = BTreeSet::new();
864    for location in &denominator {
865        let statements = by_line.get(location).map(Vec::as_slice).unwrap_or(&[]);
866        if !statements.is_empty()
867            && statements
868                .iter()
869                .all(|p| claimed_points.contains(&p.meta.id))
870        {
871            declared.insert(location.clone());
872        }
873        if !statements.is_empty()
874            && statements
875                .iter()
876                .all(|p| credited_points.contains(&p.meta.id))
877        {
878            credited.insert(location.clone());
879        }
880    }
881    let missing_inventory = inputs
882        .assertions
883        .iter()
884        .filter(|s| !map.assertions.iter().any(|a| a.at == s.at))
885        .count();
886    let (status, reason) = if !passed || !identities_valid {
887        ("unavailable", "Run failed or map identities are invalid")
888    } else if pending_changes > 0 {
889        ("pending", "Source changes need impact assessment")
890    } else if measured_statements.is_empty() {
891        ("notApplicable", "No measured statements")
892    } else if credit_flows > 0 {
893        (
894            "available",
895            "Agent-assessed statements; mapping completeness is unknown",
896        )
897    } else if map.assertions.iter().all(|a| a.flows.is_empty()) {
898        ("notAssessed", "No recorded flow explanations")
899    } else {
900        (
901            "pending",
902            "No current flow with matching passing assertion evidence",
903        )
904    };
905    let assertions_without_current_explanation = rows
906        .iter()
907        .filter(|a| {
908            inventory.keys().any(|at| json!(at) == a["at"])
909                && a["observedPassingTests"]
910                    .as_array()
911                    .is_some_and(|v| !v.is_empty())
912                && a["flows"]
913                    .as_array()
914                    .is_none_or(|v| !v.iter().any(|f| f["eligible"] == true))
915        })
916        .count();
917    let total = denominator.len();
918    let statements = measured_statements.iter().map(|p| {
919        let at = inputs.files.get(&p.meta.file)
920            .and_then(|text| byte_column(text, p.meta.line, p.meta.column, &inputs.language))
921            .map(|column| Anchor { file: p.meta.file.clone(), line: p.meta.line, column, text: p.meta.source.clone() })
922            .filter(|at| at.offset(&inputs.files).is_some());
923        let all = all_points.get(&p.meta.id);
924        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(),
925            "executionEvidence":{"anyExecution":all.is_some_and(|p| p.covered),"passingTests":p.tests.iter().filter(|id| tests.contains_key(id)).collect::<Vec<_>>(),
926                "outsidePassingTests":all.into_iter().flat_map(|p| &p.tests).filter(|id| !tests.contains_key(id)).collect::<Vec<_>>()}})
927    }).collect::<Vec<_>>();
928    json!({"basis":"agent-assessed; passing assertion identity and same-test execution required; not mutation resistance",
929        "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,
930            "assertionsWithFlows":rows.iter().filter(|a| a["flows"].as_array().is_some_and(|f| !f.is_empty())).count(),
931            "assertionsWithoutFlows":rows.iter().filter(|a| a["flows"].as_array().is_none_or(Vec::is_empty)).count(),
932            "observedAssertionsWithoutCurrentExplanation":assertions_without_current_explanation,
933            "questions":map.assertions.iter().map(|a| a.questions.len()+a.flows.iter().map(|f| f.questions.len()).sum::<usize>()).sum::<usize>(),
934            "currentFlows":current_flows,"draftFlows":draft_flows,"staleFlows":stale_flows,"invalidFlows":invalid_flows,"eligibleFlows":credit_flows,"retiredAssertions":map.retired_assertions.len(),
935            "unobservedAssertions":rows.iter().filter(|a| a["observedPassingTests"].as_array().is_none_or(Vec::is_empty)).count(),
936            "inventoryFailures":inputs.limitations.iter().filter(|s| s.starts_with("Inventory unavailable for ")).count(),
937            "unanchoredStatements":statements.iter().filter(|s| s["at"].is_null()).count(),
938            "runPassed":passed,
939            "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)}}},
940        "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,
941            "hasExecutionEvidence":!t.hits.is_empty() || !t.decisions.is_empty() || !t.lines.is_empty()})).collect::<Vec<_>>(),
942        "creditedLines":credited.iter().map(|loc| json!({"file":loc.0,"line":loc.1,"assertions":line_assertions.get(loc)})).collect::<Vec<_>>(),
943        "unassertedLines":denominator.difference(&credited).map(|(f,l)| json!({"file":f,"line":l})).collect::<Vec<_>>(),
944        "changes":validation["changes"],"validationErrors":errors,"advisories":model::advisories(map),"limitations":inputs.limitations})
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    fn fingerprint() -> RunFingerprint {
951        RunFingerprint {
952            algorithm: "sha256".into(),
953            source: "source".into(),
954            tests: "tests".into(),
955            dependencies: "dependencies".into(),
956            configuration: "configuration".into(),
957            instrumenter: "instrumenter".into(),
958            execution: "execution".into(),
959            combined: "combined".into(),
960            source_files: 1,
961            test_files: 1,
962        }
963    }
964
965    #[test]
966    fn upgrading_supercov_does_not_make_every_map_stale() {
967        // The instrumenter digest covers Supercov's own source, so every
968        // release of it moved. The claims in a map are about the project's
969        // code; a new Supercov re-derives the evidence they rest on rather than
970        // making them wrong.
971        let before = fingerprint();
972        let mut after = fingerprint();
973        after.instrumenter = "a new supercov".into();
974        let delta = context_delta(&before, &after, "context", "context");
975        assert!(!delta.execution);
976        assert!(!delta.dependencies);
977    }
978
979    #[test]
980    fn a_dependency_upgrade_is_reported_without_invalidating_anything() {
981        // It is separated, not ignored: an upgrade can falsify an authored
982        // explanation with no project file touched. One assessment answers for
983        // it, and credit survives in the meantime.
984        let before = fingerprint();
985        let mut after = fingerprint();
986        after.dependencies = "an upgraded lockfile".into();
987        let delta = context_delta(&before, &after, "context", "context");
988        assert!(delta.dependencies);
989        assert!(!delta.execution, "an upgrade must not invalidate flows");
990    }
991
992    #[test]
993    fn what_actually_executes_still_invalidates_every_flow() {
994        // tsconfig, Babel and the declared context decide what runs. A flow
995        // acknowledged under the old one has to be read again.
996        let before = fingerprint();
997        let mut after = fingerprint();
998        after.configuration = "a different tsconfig".into();
999        assert!(context_delta(&before, &after, "context", "context").execution);
1000        assert!(
1001            context_delta(&before, &fingerprint(), "context", "another context").execution,
1002            "declared context variables still count"
1003        );
1004        assert!(!context_delta(&before, &fingerprint(), "context", "context").execution);
1005    }
1006
1007    #[test]
1008    fn report_cache_requires_exact_revision_and_intact_payload() {
1009        let directory =
1010            std::env::temp_dir().join(format!("supercov-report-cache-{}", std::process::id()));
1011        fs::create_dir_all(&directory).unwrap();
1012        let path = directory.join(REPORT_CACHE_FILE);
1013        let report = json!({"summary":{"statements":{"asserted":4,"percentage":97.02842377260981}},"assertions":[]});
1014        let mut cache = json!({"key":"revision-one","digest":digest(&report),"report":report});
1015        fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1016        assert_eq!(read_report_cache(&path, "revision-one"), Some(report));
1017        assert!(read_report_cache(&path, "revision-two").is_none());
1018        cache["report"]["summary"]["statements"]["asserted"] = json!(100);
1019        fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1020        assert!(read_report_cache(&path, "revision-one").is_none());
1021        fs::write(&path, "interrupted write").unwrap();
1022        assert!(read_report_cache(&path, "revision-one").is_none());
1023        fs::remove_dir_all(directory).unwrap();
1024    }
1025    use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1026
1027    #[test]
1028    fn legacy_maps_import_without_the_old_checkout_and_require_review() {
1029        let root = std::env::temp_dir().join(format!("supercov-legacy-map-{}", std::process::id()));
1030        fs::create_dir_all(&root).unwrap();
1031        let source = "import assert from 'node:assert/strict'; assert.equal(1, 1);\n";
1032        fs::write(root.join("test.js"), source).unwrap();
1033        let old =
1034            crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
1035        let directory = crate::run_store::create_analyzable_test_run(&root, "legacy");
1036        let path = directory.join("evidence.raw.gz");
1037        let mut entries = read_archive(&path).unwrap();
1038        entries.push(EvidenceArchiveEntry {
1039            path: ARCHIVE_PATH.into(),
1040            contents: serde_json::to_vec(&old).unwrap(),
1041        });
1042        let archive = write_archive(entries, &path).unwrap();
1043        let metadata_path = directory.join("run.json");
1044        let mut metadata: RunMetadata =
1045            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1046        metadata.raw_evidence.files = archive.files;
1047        metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1048        metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1049        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1050        let run = discover_runs(&root).unwrap().runs.remove(0);
1051        let stored = load_manifest(&run).unwrap();
1052        let (map, _) = seed(&old, &stored.evidence_digest);
1053        let legacy_map = json!({"schemaVersion":1,"assertions":[{"id":map.assertions[0].id,"at":map.assertions[0].at,"analysis":"mapped","observes":[],"flows":[{
1054            "id":"constant","explanation":"The assertion checks the constant one.","appliesTo":[],"nodes":[],"edges":[],"countsAsAsserted":[],"watch":[{"kind":"span","at":map.assertions[0].at}]
1055        }]}]});
1056        let legacy_state = json!({"schemaVersion":1,"inputsDigest":digest(&old),"evidenceDigest":stored.evidence_digest,"reviews":{},"scopeReview":[]});
1057        write_json(&root, &run, MAP_FILE, &legacy_map).unwrap();
1058        write_json(&root, &run, STATE_FILE, &legacy_state).unwrap();
1059        let map = model::parse_stored(&serde_json::to_vec(&legacy_map).unwrap()).unwrap();
1060        let map_bytes = fs::read(directory.join(MAP_FILE)).unwrap();
1061        let state_bytes = fs::read(directory.join(STATE_FILE)).unwrap();
1062
1063        fs::write(root.join("test.js"), format!("\n{source}")).unwrap();
1064        assert!(load_inputs(&root, &run).is_err());
1065        let (imported, state) = load(&run, &stored).unwrap();
1066        let new =
1067            crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
1068        let (next, state) =
1069            carry(&imported, &state, &stored.manifest, &new, "new-run", false).unwrap();
1070        assert_eq!(next.assertions[0].id, map.assertions[0].id);
1071        assert_eq!(
1072            next.assertions[0].flows[0].explanation,
1073            map.assertions[0].flows[0].explanation
1074        );
1075        assert!(next.assertions[0].flows[0].basis.is_none());
1076        assert!(!next.assertions[0].flows[0].questions.is_empty());
1077        assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
1078        assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
1079
1080        // A checkout edited during a run must not prevent publication or
1081        // discard explanations. The run manifest still identifies its sites.
1082        let next_directory = crate::run_store::create_analyzable_test_run(&root, "next");
1083        let mut entries = read_archive(&path).unwrap();
1084        entries
1085            .iter_mut()
1086            .find(|e| e.path == ARCHIVE_PATH)
1087            .unwrap()
1088            .contents = serde_json::to_vec(&old.manifest()).unwrap();
1089        let raw = write_archive(entries, &next_directory.join("evidence.raw.gz")).unwrap();
1090        let mut next_metadata = metadata.clone();
1091        next_metadata.id = "next".into();
1092        next_metadata.started_at = "next".into();
1093        next_metadata.raw_evidence.compressed_bytes = raw.compressed_bytes;
1094        next_metadata.raw_evidence.uncompressed_bytes = raw.uncompressed_bytes;
1095        fs::write(
1096            next_directory.join("run.json"),
1097            serde_json::to_vec(&next_metadata).unwrap(),
1098        )
1099        .unwrap();
1100        fs::remove_file(root.join("test.js")).unwrap();
1101        prepare_publication(&root, &next_directory, &next_metadata).unwrap();
1102        let next_run = discover_runs(&root)
1103            .unwrap()
1104            .runs
1105            .into_iter()
1106            .find(|r| r.id == "next")
1107            .unwrap();
1108        let next_manifest = load_manifest(&next_run).unwrap();
1109        let (pending, pending_state) = load(&next_run, &next_manifest).unwrap();
1110        assert_eq!(pending.assertions[0], map.assertions[0]);
1111        assert!(!pending_state.changes.is_empty());
1112        assert!(
1113            !pending_state.flows
1114                [&flow_key(&pending.assertions[0], &pending.assertions[0].flows[0])]
1115                .reasons
1116                .is_empty()
1117        );
1118        assert!(load_inputs(&root, &next_run).is_err());
1119        assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
1120        assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
1121
1122        let mut corrupt = state.clone();
1123        corrupt.evidence_digest = "wrong".into();
1124        write_json(&root, &run, STATE_FILE, &corrupt).unwrap();
1125        assert!(load(&run, &stored).is_err());
1126        fs::remove_dir_all(root).unwrap();
1127    }
1128}