Skip to main content

supercov_engine/
assertion_map.rs

1//! Agent-authored assertion maps. Edges are explanations, never inferred proofs.
2//! This module owns format validation, text relocation and input acknowledgement bookkeeping.
3
4use crate::source_units::{Code, Diff, named};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::{BTreeMap, BTreeSet};
8
9pub type Files = BTreeMap<String, String>;
10pub fn digest(value: &impl Serialize) -> String {
11    format!(
12        "{:x}",
13        Sha256::digest(serde_json::to_vec(value).expect("serializable map"))
14    )
15}
16fn version() -> u32 {
17    1
18}
19
20/// One-based lines and UTF-8 byte columns, for every language. Text is exact.
21#[derive(
22    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
23)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct Anchor {
26    pub file: String,
27    pub line: usize,
28    pub column: usize,
29    pub text: String,
30}
31
32pub fn local_path(file: &str) -> bool {
33    !file.is_empty()
34        && !file.contains(['\\', ':'])
35        && file.split('/').all(|p| !matches!(p, "" | "." | ".."))
36}
37impl Anchor {
38    pub fn new(file: &str, source: &str, start: usize, end: usize) -> Self {
39        Self {
40            file: file.into(),
41            line: source[..start].bytes().filter(|b| *b == b'\n').count() + 1,
42            column: start - source[..start].rfind('\n').map_or(0, |n| n + 1) + 1,
43            text: source[start..end].into(),
44        }
45    }
46    pub fn offset(&self, files: &Files) -> Option<usize> {
47        if !local_path(&self.file) || self.text.is_empty() || self.line == 0 || self.column == 0 {
48            return None;
49        }
50        let source = files.get(&self.file)?;
51        let start = source
52            .split_inclusive('\n')
53            .take(self.line - 1)
54            .map(str::len)
55            .sum::<usize>();
56        if source[..start].bytes().filter(|b| *b == b'\n').count() != self.line - 1 {
57            return None;
58        }
59        let line = source.get(start..)?.split('\n').next()?;
60        if self.column - 1 > line.len() {
61            return None;
62        }
63        let pos = start.checked_add(self.column - 1)?;
64        source.get(pos..)?.starts_with(&self.text).then_some(pos)
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct InventorySite {
71    pub at: Anchor,
72    pub operation: String,
73}
74
75/// Source text held in memory for capture or a verified current-checkout query.
76/// The serialized form is retained only for reading legacy source archives.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
78#[serde(rename_all = "camelCase", deny_unknown_fields)]
79pub struct Inputs {
80    #[serde(default = "version")]
81    pub schema_version: u32,
82    pub language: String,
83    pub context_digest: String,
84    pub files: Files,
85    pub assertions: Vec<InventorySite>,
86    pub limitations: Vec<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91pub struct FileFingerprint {
92    pub sha256: String,
93    pub bytes: usize,
94    /// The parser's view of the file: what it declares, each declaration
95    /// digested with comments blanked. Absent for a file no parser reads and
96    /// in manifests written before this existed; such a file is compared by
97    /// its bytes, as every file once was.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub code: Option<Code>,
100}
101impl FileFingerprint {
102    pub fn of(source: &str) -> Self {
103        Self {
104            sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
105            bytes: source.len(),
106            code: None,
107        }
108    }
109    /// Bytes and, where Supercov has a parser for the file, its declarations.
110    pub fn read(path: &str, source: &str) -> Self {
111        let mut fingerprint = Self::of(source);
112        fingerprint.code = crate::source_units::code(path, source);
113        fingerprint
114    }
115    pub fn same_bytes(&self, other: &Self) -> bool {
116        self.sha256 == other.sha256
117    }
118}
119pub type FileManifest = BTreeMap<String, FileFingerprint>;
120
121/// The run stores identities and hashes, never complete source files.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124pub struct InputManifest {
125    pub schema_version: u32,
126    pub language: String,
127    pub context_digest: String,
128    pub files: FileManifest,
129    pub assertions: Vec<InventorySite>,
130    pub limitations: Vec<String>,
131}
132impl Inputs {
133    pub fn manifest(&self) -> InputManifest {
134        InputManifest {
135            schema_version: 2,
136            language: self.language.clone(),
137            context_digest: self.context_digest.clone(),
138            files: self
139                .files
140                .iter()
141                .map(|(p, s)| (p.clone(), FileFingerprint::read(p, s)))
142                .collect(),
143            assertions: self.assertions.clone(),
144            limitations: self.limitations.clone(),
145        }
146    }
147    pub fn identity(&self) -> String {
148        digest(&self.manifest())
149    }
150}
151impl InputManifest {
152    pub fn with_sources(&self, files: Files) -> Inputs {
153        Inputs {
154            schema_version: 1,
155            language: self.language.clone(),
156            context_digest: self.context_digest.clone(),
157            files,
158            assertions: self.assertions.clone(),
159            limitations: self.limitations.clone(),
160        }
161    }
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct Node {
167    pub id: String,
168    pub at: Anchor,
169    #[serde(default, skip_serializing_if = "String::is_empty")]
170    pub role: String,
171    #[serde(default, skip_serializing_if = "String::is_empty")]
172    pub meaning: String,
173}
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct Edge {
177    pub from: String,
178    pub to: String,
179    pub kind: String,
180    #[serde(default, skip_serializing_if = "String::is_empty")]
181    pub basis: String,
182}
183#[derive(
184    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
185)]
186#[serde(rename_all = "camelCase", deny_unknown_fields)]
187pub struct TestSelector {
188    pub file: String,
189    pub name: String,
190}
191
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct Flow {
195    pub id: String,
196    #[serde(deserialize_with = "required_basis")]
197    #[schemars(required, schema_with = "basis_schema")]
198    pub basis: Option<String>,
199    pub explanation: String,
200    pub applies_to: Vec<TestSelector>,
201    pub nodes: Vec<Node>,
202    #[serde(default)]
203    pub edges: Vec<Edge>,
204    pub counts_as_asserted: Vec<String>,
205    pub watch: Vec<String>,
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub questions: Vec<String>,
208}
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
210#[serde(rename_all = "camelCase", deny_unknown_fields)]
211pub struct Assertion {
212    pub id: String,
213    pub at: Anchor,
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub questions: Vec<String>,
216    #[serde(default)]
217    pub observes: Vec<String>,
218    #[serde(default)]
219    pub flows: Vec<Flow>,
220}
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
222#[serde(rename_all = "camelCase", deny_unknown_fields)]
223pub struct Retired {
224    pub assertion: Assertion,
225    pub reason: String,
226}
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229pub struct AssertionMap {
230    #[schemars(range(min = 2, max = 2))]
231    pub schema_version: u32,
232    pub assertions: Vec<Assertion>,
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub change_assessments: Vec<ChangeAssessment>,
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub retired_assertions: Vec<Retired>,
237}
238
239// Missing basis is a syntax error; null explicitly means unfinished work.
240fn required_basis<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
241    let value = Option::<String>::deserialize(d)?;
242    if value.as_deref().is_some_and(|s| !valid_basis(s)) {
243        return Err(serde::de::Error::custom(
244            "expected null or scov3:<64 lowercase hex digits>",
245        ));
246    }
247    Ok(value)
248}
249fn basis_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
250    schemars::json_schema!({"type":["string","null"],"pattern":"^scov[23]:[0-9a-f]{64}$"})
251}
252fn valid_basis(s: &str) -> bool {
253    s.strip_prefix("scov3:")
254        .or_else(|| s.strip_prefix("scov2:"))
255        .is_some_and(|h| {
256            h.len() == 64
257                && h.bytes()
258                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
259        })
260}
261/// A token from a release whose basis pinned files rather than the code a
262/// claim rests on. It still parses, so the map stays valid; it can no longer
263/// match, so the claim reads as needing acknowledgement, with this as its
264/// reason rather than a change that never happened.
265pub fn superseded_basis(s: &str) -> bool {
266    s.starts_with("scov2:")
267}
268pub const SUPERSEDED_BASIS: &str = "acknowledged under an earlier Supercov basis format; reread the claim and copy the current expectedBasis";
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
270#[serde(rename_all = "camelCase", deny_unknown_fields)]
271pub struct ChangeAssessment {
272    pub id: String,
273    #[serde(deserialize_with = "required_basis")]
274    #[schemars(required, schema_with = "basis_schema")]
275    pub basis: Option<String>,
276    pub affected_flows: Vec<String>,
277    pub explanation: String,
278}
279
280/// Editor schema generated from the same Rust types used by every map command.
281/// Source existence, links, freshness and semantic meaning are outside JSON Schema.
282pub fn schema() -> serde_json::Value {
283    serde_json::to_value(schemars::schema_for!(AssertionMap)).expect("schema")
284}
285
286#[derive(Debug, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct ParseError {
289    pub pointer: String,
290    pub line: usize,
291    pub column: usize,
292    pub message: String,
293}
294impl std::fmt::Display for ParseError {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        write!(
297            f,
298            "{} at {} (JSON line {}, column {})",
299            self.message, self.pointer, self.line, self.column
300        )
301    }
302}
303pub fn parse(bytes: &[u8]) -> Result<AssertionMap, ParseError> {
304    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
305    let map: AssertionMap = serde_path_to_error::deserialize(&mut deserializer).map_err(|e| {
306        let pointer = e
307            .path()
308            .iter()
309            .map(|segment| {
310                use serde_path_to_error::Segment;
311                let part = match segment {
312                    Segment::Seq { index } => index.to_string(),
313                    Segment::Map { key } => key.clone(),
314                    Segment::Enum { variant } => variant.clone(),
315                    Segment::Unknown => "?".into(),
316                };
317                format!("/{}", part.replace('~', "~0").replace('/', "~1"))
318            })
319            .collect();
320        ParseError {
321            pointer,
322            line: e.inner().line(),
323            column: e.inner().column(),
324            message: e.inner().to_string(),
325        }
326    })?;
327    deserializer.end().map_err(|e| ParseError {
328        pointer: String::new(),
329        line: e.line(),
330        column: e.column(),
331        message: e.to_string(),
332    })?;
333    if map.schema_version != 2 {
334        return Err(ParseError {
335            pointer: "/schemaVersion".into(),
336            line: 0,
337            column: 0,
338            message: "unsupported map schema version; expected 2".into(),
339        });
340    }
341    Ok(map)
342}
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase", deny_unknown_fields)]
345pub struct FlowState {
346    pub generation: String,
347    pub reasons: BTreeSet<String>,
348    /// Changes near this flow that could not have reached it: a file it
349    /// depends on changed only in code its test never ran. Told, not asked.
350    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
351    pub notices: BTreeSet<String>,
352}
353/// What each test of a run executed, in the units of that run's manifest.
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase", deny_unknown_fields)]
356pub struct Executions {
357    pub tests: Vec<Execution>,
358    /// Per file, the units that hold a probe of their own. A change confined
359    /// to these can reach a test only by being run.
360    pub probed: BTreeMap<String, Vec<usize>>,
361}
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase", deny_unknown_fields)]
364pub struct Execution {
365    pub test: TestSelector,
366    pub passed: bool,
367    /// Per file, the innermost unit of every probe this test fired.
368    pub files: BTreeMap<String, Vec<usize>>,
369}
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372pub struct Change {
373    pub id: String,
374    pub file: Option<String>,
375    pub before: Option<String>,
376    pub after: Option<String>,
377    pub reason: String,
378    /// Flows this change has already made stale. An assessment has to name
379    /// them; it may name more.
380    pub known_flows: BTreeSet<String>,
381    /// Flows whose selected tests ran the changed code, or have no execution
382    /// record to say. Not stale for it -- the claim they make does not pass
383    /// through that code -- but these are the ones the assessment is about.
384    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
385    pub exposed: BTreeSet<String>,
386}
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase", deny_unknown_fields)]
389pub struct State {
390    pub schema_version: u32,
391    pub inputs_digest: String,
392    pub evidence_digest: String,
393    pub flows: BTreeMap<String, FlowState>,
394    pub changes: Vec<Change>,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub inheritance: Option<Inheritance>,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub executions: Option<Executions>,
399}
400#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
401#[serde(rename_all = "camelCase", deny_unknown_fields)]
402pub struct Inheritance {
403    pub from: Option<String>,
404    pub skipped: Vec<SkippedMap>,
405}
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
407#[serde(rename_all = "camelCase", deny_unknown_fields)]
408pub struct SkippedMap {
409    pub run: String,
410    pub reason: String,
411}
412pub fn flow_key(a: &Assertion, f: &Flow) -> String {
413    format!("{}/{}", a.id, f.id)
414}
415fn valid_id(id: &str) -> bool {
416    !id.is_empty()
417        && id
418            .bytes()
419            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
420}
421
422pub fn seed(inputs: &Inputs, evidence_digest: &str) -> (AssertionMap, State) {
423    seed_manifest(&inputs.manifest(), evidence_digest)
424}
425pub fn seed_manifest(inputs: &InputManifest, evidence_digest: &str) -> (AssertionMap, State) {
426    (
427        AssertionMap {
428            schema_version: 2,
429            assertions: inputs
430                .assertions
431                .iter()
432                .map(|site| Assertion {
433                    id: format!("a_{}", &digest(&site.at)[..20]),
434                    at: site.at.clone(),
435                    questions: vec![],
436                    observes: vec![],
437                    flows: vec![],
438                })
439                .collect(),
440            change_assessments: vec![],
441            retired_assertions: vec![],
442        },
443        State {
444            schema_version: 3,
445            inputs_digest: digest(inputs),
446            evidence_digest: evidence_digest.into(),
447            flows: BTreeMap::new(),
448            changes: vec![],
449            inheritance: None,
450            executions: None,
451        },
452    )
453}
454
455/// Structural checks only; malformed entries cannot silently earn credit.
456pub fn validate(map: &AssertionMap, inputs: &Inputs) -> Vec<String> {
457    let mut errors = Vec::new();
458    if map.schema_version != 2 || inputs.schema_version != 1 {
459        errors.push("unsupported schema version".into());
460    }
461    let mut ids = BTreeSet::new();
462    let mut sites = BTreeSet::new();
463    for a in &map.assertions {
464        if !valid_id(&a.id) || !ids.insert(&a.id) {
465            errors.push(format!("{}: invalid/duplicate assertion ID", a.id));
466        }
467        if !sites.insert(&a.at) {
468            errors.push(format!("{}: duplicate assertion location", a.id));
469        }
470        if a.at.offset(&inputs.files).is_none() {
471            errors.push(format!("{}: invalid assertion anchor", a.id));
472        }
473        // Agents can register custom assertions absent from the syntax inventory.
474        // They still require exact run evidence to earn execution-backed credit.
475        let mut flows = BTreeSet::new();
476        for f in &a.flows {
477            let key = flow_key(a, f);
478            if !valid_id(&f.id) || !flows.insert(&f.id) {
479                errors.push(format!("{key}: invalid/duplicate flow ID"));
480            }
481            errors.extend(
482                validate_flow(f, &inputs.files)
483                    .into_iter()
484                    .map(|e| format!("{key}: {e}")),
485            );
486        }
487    }
488    let mut changes = BTreeSet::new();
489    for change in &map.change_assessments {
490        if !valid_id(&change.id) || !changes.insert(&change.id) {
491            errors.push("invalid/duplicate change assessment ID".into());
492        }
493    }
494    errors
495}
496/// Things worth telling the author that do not make the map wrong.
497///
498/// A redundant `watch` is the one that matters today. Supercov already marks
499/// every flow dirty when a dependency manifest or the execution configuration
500/// changes, so naming one of those files per flow catches nothing extra. It
501/// does teach a false model -- that per-flow watching is how dependency drift
502/// is caught -- and an author who believes it spends the effort on entries that
503/// change nothing instead of on the helper their claim actually rests on.
504pub fn advisories(map: &AssertionMap) -> Vec<String> {
505    let mut out = Vec::new();
506    for a in &map.assertions {
507        for f in &a.flows {
508            for file in &f.watch {
509                if crate::integrity::globally_tracked(file) {
510                    out.push(format!(
511                        "{}: watch \"{file}\" is redundant; Supercov invalidates every flow when that file changes",
512                        flow_key(a, f)
513                    ));
514                }
515            }
516        }
517    }
518    out
519}
520pub fn validate_flow(flow: &Flow, files: &Files) -> Vec<String> {
521    let mut errors = Vec::new();
522    let mut nodes = BTreeSet::new();
523    for node in &flow.nodes {
524        if !valid_id(&node.id) || !nodes.insert(&node.id) {
525            errors.push("invalid/duplicate node ID".into());
526        }
527        if node.at.offset(files).is_none() {
528            errors.push(format!("node {}: invalid anchor", node.id));
529        }
530    }
531    for edge in &flow.edges {
532        if !nodes.contains(&edge.from) || (!nodes.contains(&edge.to) && edge.to != "$assertion") {
533            errors.push("dangling edge".into());
534        }
535    }
536    if flow.counts_as_asserted.iter().any(|id| !nodes.contains(id)) {
537        errors.push("unknown counted node".into());
538    }
539    // Traverse only the author's graph. Never infer a dependency from source.
540    let mut reaches = BTreeSet::from(["$assertion".to_owned()]);
541    loop {
542        let size = reaches.len();
543        for edge in &flow.edges {
544            if reaches.contains(&edge.to) {
545                reaches.insert(edge.from.clone());
546            }
547        }
548        if reaches.len() == size {
549            break;
550        }
551    }
552    for id in &flow.counts_as_asserted {
553        if !reaches.contains(id) {
554            errors.push(format!(
555                "counted node {id} has no authored path to $assertion"
556            ));
557        }
558    }
559    if flow.edges.iter().any(|e| e.kind.trim().is_empty()) {
560        errors.push("missing edge kind".into());
561    }
562    if flow
563        .applies_to
564        .iter()
565        .any(|t| !local_path(&t.file) || !files.contains_key(&t.file) || t.name.trim().is_empty())
566    {
567        errors.push("invalid test selector file or name".into());
568    }
569    if flow.applies_to.iter().collect::<BTreeSet<_>>().len() != flow.applies_to.len() {
570        errors.push("duplicate test selector".into());
571    }
572    if flow
573        .counts_as_asserted
574        .iter()
575        .collect::<BTreeSet<_>>()
576        .len()
577        != flow.counts_as_asserted.len()
578    {
579        errors.push("duplicate counted node".into());
580    }
581    if flow.explanation.trim().is_empty() {
582        errors.push("missing explanation".into());
583    }
584    for file in &flow.watch {
585        if !local_path(file) || !files.contains_key(file) {
586            errors.push(format!("watched file missing: {file}"));
587        }
588    }
589    errors
590}
591
592/// Whole-file input dependencies, not a mechanically inferred semantic slice.
593pub fn dependencies<'a>(a: &'a Assertion, f: &'a Flow) -> BTreeSet<&'a str> {
594    std::iter::once(a.at.file.as_str())
595        .chain(f.applies_to.iter().map(|t| t.file.as_str()))
596        .chain(f.nodes.iter().map(|n| n.at.file.as_str()))
597        // A watch on a file Supercov already answers for run-wide contributes
598        // nothing here, and hashing its bytes would quietly undo the manifest
599        // rule: a version bump would still make every flow that names
600        // `package.json` stale, which is most of them in a real map. The
601        // run-level signal still fires, as a change to assess.
602        //
603        // Only the watch list is filtered. An anchor or a node in one of those
604        // files is the flow's actual subject -- `setup.py` is a dependency
605        // manifest and measured source at once -- and editing it must still
606        // cost a review.
607        .chain(
608            f.watch
609                .iter()
610                .map(String::as_str)
611                .filter(|path| !crate::integrity::globally_tracked(path)),
612        )
613        .collect()
614}
615fn token(value: &impl Serialize) -> String {
616    format!("scov3:{}", digest(value))
617}
618fn flow_keys(map: &AssertionMap) -> BTreeSet<String> {
619    map.assertions
620        .iter()
621        .flat_map(|a| a.flows.iter().map(move |f| flow_key(a, f)))
622        .collect()
623}
624pub fn change_errors(
625    map: &AssertionMap,
626    change: &Change,
627    response: &ChangeAssessment,
628) -> Vec<String> {
629    change_errors_with(&flow_keys(map), change, response)
630}
631fn change_errors_with(
632    keys: &BTreeSet<String>,
633    change: &Change,
634    response: &ChangeAssessment,
635) -> Vec<String> {
636    let affected = response
637        .affected_flows
638        .iter()
639        .cloned()
640        .collect::<BTreeSet<_>>();
641    let mut errors = Vec::new();
642    if response.explanation.trim().is_empty() {
643        errors.push("missing impact explanation".into());
644    }
645    if affected.len() != response.affected_flows.len() {
646        errors.push("duplicate affected flow".into());
647    }
648    if !affected.is_subset(keys) {
649        errors.push("unknown affected flow".into());
650    }
651    if !change
652        .known_flows
653        .intersection(keys)
654        .all(|k| affected.contains(k))
655    {
656        errors.push("known dependent flows must be included unless removed from the map".into());
657    }
658    errors
659}
660pub fn expected_change_basis(
661    change: &Change,
662    response: &ChangeAssessment,
663    inputs: &InputManifest,
664) -> String {
665    expected_change_basis_with(change, response, &digest(inputs))
666}
667fn expected_change_basis_with(
668    change: &Change,
669    response: &ChangeAssessment,
670    inputs_digest: &str,
671) -> String {
672    token(&(
673        "supercov-change-v2",
674        change,
675        inputs_digest,
676        &response.id,
677        &response.affected_flows,
678        &response.explanation,
679    ))
680}
681pub fn change_current(map: &AssertionMap, change: &Change, inputs: &InputManifest) -> bool {
682    Ledger::with_changes(map, std::slice::from_ref(change), inputs).current(&change.id)
683}
684/// What every token of one run shares, computed once: the manifest's digest,
685/// and each change whose assessment is current with the flows it names.
686///
687/// Computed per flow instead, this serialised the whole manifest once per
688/// flow per pending change -- minutes on a real map with a backlog of
689/// changes, on every carry and every report.
690pub struct Ledger<'a> {
691    pub inputs_digest: String,
692    keys: BTreeSet<String>,
693    /// Current assessments by change id: the basis the author recorded and
694    /// the flows it names.
695    current: BTreeMap<&'a str, (&'a Option<String>, &'a [String])>,
696}
697impl<'a> Ledger<'a> {
698    pub fn new(map: &'a AssertionMap, state: &'a State, inputs: &InputManifest) -> Self {
699        Self::with_changes(map, &state.changes, inputs)
700    }
701    fn with_changes(map: &'a AssertionMap, changes: &'a [Change], inputs: &InputManifest) -> Self {
702        let inputs_digest = digest(inputs);
703        let keys = flow_keys(map);
704        let current = changes
705            .iter()
706            .filter_map(|change| {
707                let responses = map
708                    .change_assessments
709                    .iter()
710                    .filter(|r| r.id == change.id)
711                    .collect::<Vec<_>>();
712                match responses.as_slice() {
713                    [r] if change_errors_with(&keys, change, r).is_empty()
714                        && r.basis.as_deref()
715                            == Some(
716                                expected_change_basis_with(change, r, &inputs_digest).as_str(),
717                            ) =>
718                    {
719                        Some((change.id.as_str(), (&r.basis, r.affected_flows.as_slice())))
720                    }
721                    _ => None,
722                }
723            })
724            .collect();
725        Self {
726            inputs_digest,
727            keys,
728            current,
729        }
730    }
731    /// Whether the change has a valid, current assessment.
732    pub fn current(&self, change: &str) -> bool {
733        self.current.contains_key(change)
734    }
735    pub fn expected_change_basis(&self, change: &Change, response: &ChangeAssessment) -> String {
736        expected_change_basis_with(change, response, &self.inputs_digest)
737    }
738    pub fn change_errors(&self, change: &Change, response: &ChangeAssessment) -> Vec<String> {
739        change_errors_with(&self.keys, change, response)
740    }
741}
742/// Why a file is one of a flow's dependencies, and where the flow sits in it.
743///
744/// "dependency file changed" names a file and leaves the author to work out
745/// what it has to do with this claim. A flow depends on a file for one of four
746/// reasons, and they call for different judgements: a node there is the claim's
747/// subject, a watch is something the author asked to be told about, the test
748/// file is where the claim is exercised, and the assertion's own file is where
749/// it is written. Saying which -- and where the nodes are -- is the difference
750/// between rereading a claim and glancing at a line number.
751fn roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
752    let mut roles: Vec<String> = Vec::new();
753    let lines = f
754        .nodes
755        .iter()
756        .filter(|n| n.at.file == file)
757        .map(|n| format!("{}:{}", n.id, n.at.line))
758        .collect::<Vec<_>>();
759    if !lines.is_empty() {
760        roles.push(format!("holds this flow's {}", lines.join(", ")));
761    }
762    if a.at.file == file {
763        roles.push(format!("holds the assertion, line {}", a.at.line));
764    }
765    if f.applies_to.iter().any(|t| t.file == file) {
766        roles.push("is the test this claim applies to".to_owned());
767    }
768    if f.watch.iter().any(|w| w == file) {
769        roles.push("is watched by this flow".to_owned());
770    }
771    roles
772}
773fn in_role(roles: &[String]) -> String {
774    if roles.is_empty() {
775        // Every path into dependencies() is covered above; say nothing rather
776        // than guess if that ever stops being true.
777        String::new()
778    } else {
779        format!(" ({})", roles.join("; "))
780    }
781}
782/// A file the flow rests on as a whole: the test it applies to, a file it
783/// watches, the file its assertion is written in. Any change there is the
784/// author's to judge; only its comments are not.
785fn whole_file(a: &Assertion, f: &Flow, file: &str) -> bool {
786    a.at.file == file
787        || f.applies_to.iter().any(|t| t.file == file)
788        || f.watch.iter().any(|w| w == file)
789}
790/// A claim's identity is where it points and what it says, not the line it
791/// happens to be on: a file edited above a node moves the node without
792/// touching the claim. Where the file has a parser's view, a node is placed by
793/// the declaration holding it and how many lines of code lie between it and
794/// the nearest boundary in that declaration -- the declaration's start, or the
795/// end of the last nested declaration before it. Growth anywhere else, and
796/// comments or blank lines anywhere, leave it in place; pointing it at another
797/// statement of the same text on another line does not.
798#[derive(Serialize)]
799struct Site<'a> {
800    file: &'a str,
801    text: &'a str,
802    #[serde(skip_serializing_if = "Option::is_none")]
803    unit: Option<&'a str>,
804    line: usize,
805    #[serde(skip_serializing_if = "Option::is_none")]
806    column: Option<usize>,
807}
808fn site<'a>(at: &'a Anchor, inputs: &'a InputManifest) -> Site<'a> {
809    let code = inputs.files.get(&at.file).and_then(|f| f.code.as_ref());
810    let Some(code) = code else {
811        return Site {
812            file: &at.file,
813            text: &at.text,
814            unit: None,
815            line: at.line,
816            column: Some(at.column),
817        };
818    };
819    let holder = code.unit_at(at.line, at.column);
820    let mut boundary = code.units[holder].line;
821    for child in code.units.iter().filter(|u| u.parent == Some(holder)) {
822        if (child.end_line, child.end_column) <= (at.line, at.column) && child.end_line > boundary {
823            boundary = child.end_line;
824        }
825    }
826    Site {
827        file: &at.file,
828        text: &at.text,
829        unit: Some(&code.units[holder].path),
830        line: code
831            .code_line(at.line)
832            .saturating_sub(code.code_line(boundary)),
833        column: None,
834    }
835}
836#[derive(Serialize)]
837#[serde(rename_all = "camelCase")]
838struct NodeClaim<'a> {
839    id: &'a str,
840    at: Site<'a>,
841    role: &'a str,
842    meaning: &'a str,
843}
844#[derive(Serialize)]
845#[serde(rename_all = "camelCase")]
846struct Claim<'a> {
847    id: &'a str,
848    explanation: &'a str,
849    applies_to: &'a [TestSelector],
850    nodes: Vec<NodeClaim<'a>>,
851    edges: &'a [Edge],
852    counts_as_asserted: &'a [String],
853    watch: &'a [String],
854    questions: &'a [String],
855}
856fn claim<'a>(f: &'a Flow, inputs: &'a InputManifest) -> Claim<'a> {
857    Claim {
858        id: &f.id,
859        explanation: &f.explanation,
860        applies_to: &f.applies_to,
861        nodes: f
862            .nodes
863            .iter()
864            .map(|n| NodeClaim {
865                id: &n.id,
866                at: site(&n.at, inputs),
867                role: &n.role,
868                meaning: &n.meaning,
869            })
870            .collect(),
871        edges: &f.edges,
872        counts_as_asserted: &f.counts_as_asserted,
873        watch: &f.watch,
874        questions: &f.questions,
875    }
876}
877/// What an acknowledgement rests on in one dependency file.
878#[derive(Serialize)]
879#[serde(rename_all = "camelCase")]
880enum Footing<'a> {
881    /// Named by the flow but not among the run's inputs.
882    Absent,
883    /// No parser reads this file; its bytes are the claim's ground.
884    Bytes(&'a str),
885    /// Everything the file does, comments aside.
886    Semantic(&'a str),
887    /// The declarations holding this flow's nodes, and the file's set of
888    /// declarations. Code elsewhere in the file is answered for by what the
889    /// flow's test executed, which `carry` judges.
890    Units {
891        structure: &'a str,
892        units: BTreeMap<&'a str, &'a str>,
893    },
894}
895fn footing<'a>(
896    a: &'a Assertion,
897    f: &'a Flow,
898    inputs: &'a InputManifest,
899) -> BTreeMap<&'a str, Footing<'a>> {
900    dependencies(a, f)
901        .into_iter()
902        .map(|file| {
903            let Some(fingerprint) = inputs.files.get(file) else {
904                return (file, Footing::Absent);
905            };
906            let Some(code) = &fingerprint.code else {
907                return (file, Footing::Bytes(&fingerprint.sha256));
908            };
909            if whole_file(a, f, file) {
910                return (file, Footing::Semantic(&code.semantic));
911            }
912            let units = f
913                .nodes
914                .iter()
915                .filter(|n| n.at.file == file)
916                .flat_map(|n| code.ancestors(code.unit_at(n.at.line, n.at.column)))
917                .map(|i| (code.units[i].path.as_str(), code.units[i].digest.as_str()))
918                .collect();
919            (
920                file,
921                Footing::Units {
922                    structure: &code.structure,
923                    units,
924                },
925            )
926        })
927        .collect()
928}
929
930fn generation(a: &Assertion, f: &Flow, state: &State, ledger: &Ledger<'_>) -> String {
931    let key = flow_key(a, f);
932    let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
933    let impacts = ledger
934        .current
935        .iter()
936        .filter(|(_, (_, affected))| affected.contains(&key))
937        .map(|(id, (basis, _))| (*id, *basis))
938        .collect::<BTreeMap<_, _>>();
939    if impacts.is_empty() {
940        base.into()
941    } else {
942        digest(&("supercov-generation-v2", base, impacts))
943    }
944}
945pub fn expected_basis(
946    a: &Assertion,
947    f: &Flow,
948    map: &AssertionMap,
949    state: &State,
950    inputs: &InputManifest,
951) -> String {
952    expected_basis_with(a, f, state, inputs, &Ledger::new(map, state, inputs))
953}
954/// The token with the run-wide facts already in hand; what every caller with
955/// more than one flow to judge should use.
956pub fn expected_basis_with(
957    a: &Assertion,
958    f: &Flow,
959    state: &State,
960    inputs: &InputManifest,
961    ledger: &Ledger<'_>,
962) -> String {
963    token(&(
964        "supercov-flow-v3",
965        &inputs.context_digest,
966        &a.id,
967        site(&a.at, inputs),
968        &a.observes,
969        claim(f, inputs),
970        footing(a, f, inputs),
971        generation(a, f, state, ledger),
972    ))
973}
974pub fn reasons(
975    a: &Assertion,
976    f: &Flow,
977    map: &AssertionMap,
978    state: &State,
979    inputs: &Inputs,
980) -> BTreeSet<String> {
981    reasons_for_manifest(a, f, map, state, inputs, &inputs.manifest())
982}
983pub fn reasons_for_manifest(
984    a: &Assertion,
985    f: &Flow,
986    map: &AssertionMap,
987    state: &State,
988    inputs: &Inputs,
989    manifest: &InputManifest,
990) -> BTreeSet<String> {
991    reasons_with(
992        a,
993        f,
994        state,
995        inputs,
996        manifest,
997        &Ledger::new(map, state, manifest),
998    )
999}
1000pub fn reasons_with(
1001    a: &Assertion,
1002    f: &Flow,
1003    state: &State,
1004    inputs: &Inputs,
1005    manifest: &InputManifest,
1006    ledger: &Ledger<'_>,
1007) -> BTreeSet<String> {
1008    let mut reasons = BTreeSet::new();
1009    if state.schema_version != 3 || state.inputs_digest != ledger.inputs_digest {
1010        reasons.insert("state does not match run inputs".into());
1011    }
1012    if f.basis.as_deref() != Some(expected_basis_with(a, f, state, manifest, ledger).as_str()) {
1013        reasons.insert(
1014            match f.basis.as_deref() {
1015                None => "draft: input acknowledgement not recorded",
1016                Some(basis) if superseded_basis(basis) => SUPERSEDED_BASIS,
1017                Some(_) => "claim or inputs changed; needs rechecking",
1018            }
1019            .into(),
1020        );
1021        if let Some(s) = state.flows.get(&flow_key(a, f)) {
1022            reasons.extend(s.reasons.iter().cloned());
1023        }
1024    }
1025    reasons.extend(validate_flow(f, &inputs.files));
1026    if a.at.offset(&inputs.files).is_none() {
1027        reasons.insert("invalid assertion anchor".into());
1028    }
1029    if !f.questions.is_empty() {
1030        reasons.insert("flow has unresolved questions".into());
1031    }
1032    reasons
1033}
1034/// Read-only validation. Tokens acknowledge authored claims, never prove them.
1035pub fn validation(map: &AssertionMap, state: &State, inputs: &Inputs) -> serde_json::Value {
1036    use serde_json::json;
1037    let manifest = inputs.manifest();
1038    let ledger = Ledger::new(map, state, &manifest);
1039    let mut errors = validate(map, inputs);
1040    for r in &map.change_assessments {
1041        if !state.changes.iter().any(|c| c.id == r.id) {
1042            errors.push(format!("{}: unknown change assessment", r.id));
1043        }
1044    }
1045    // Exposure is kept per flow but read per test: hundreds of flow keys say
1046    // less than the dozen tests they apply to, and cost more to page.
1047    let selectors = map
1048        .assertions
1049        .iter()
1050        .flat_map(|a| a.flows.iter().map(move |f| (flow_key(a, f), &f.applies_to)))
1051        .collect::<BTreeMap<_, _>>();
1052    let changes = state.changes.iter().map(|c| {
1053        let response = map.change_assessments.iter().find(|r| r.id == c.id);
1054        let faults = response.map(|r| ledger.change_errors(c, r)).unwrap_or_default();
1055        errors.extend(faults.iter().map(|e| format!("{}: {e}", c.id)));
1056        let tests = c.exposed.iter().filter_map(|k| selectors.get(k)).flat_map(|t| t.iter()).collect::<BTreeSet<_>>();
1057        json!({"id":c.id,"file":c.file,"before":c.before,"after":c.after,"reason":c.reason,"knownFlows":c.known_flows,
1058            "exposed":{"flows":c.exposed.len(),"tests":tests,"sample":c.exposed.iter().take(8).collect::<Vec<_>>()},
1059            "current":ledger.current(&c.id),"assessment":response,"errors":faults,
1060            "expectedBasis":response.map(|r| ledger.expected_change_basis(c,r))})
1061    }).collect::<Vec<_>>();
1062    let flows = map.assertions.iter().flat_map(|a| a.flows.iter().map(move |f| (a,f))).map(|(a,f)| {
1063        json!({"id":flow_key(a,f),"expectedBasis":expected_basis_with(a,f,state,&manifest,&ledger),"reasons":reasons_with(a,f,state,inputs,&manifest,&ledger)})
1064    }).collect::<Vec<_>>();
1065    json!({"valid":errors.is_empty(),"stage":"references","errors":errors,"flows":flows,"changes":changes,
1066        "meaning":"Authored graph references and input acknowledgements only; no semantic proof or completeness claim"})
1067}
1068pub fn invalidate(state: &mut State, map: &AssertionMap, reason: &str) {
1069    for a in &map.assertions {
1070        for f in &a.flows {
1071            let key = flow_key(a, f);
1072            let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
1073            state.flows.insert(
1074                key,
1075                FlowState {
1076                    generation: digest(&(base, reason, &state.inputs_digest)),
1077                    reasons: BTreeSet::from([reason.into()]),
1078                    notices: BTreeSet::new(),
1079                },
1080            );
1081        }
1082    }
1083}
1084pub fn add_change(
1085    state: &mut State,
1086    file: Option<String>,
1087    before: Option<String>,
1088    after: Option<String>,
1089    reason: String,
1090    known_flows: BTreeSet<String>,
1091    exposed: BTreeSet<String>,
1092) {
1093    // Include pending history so edit/revert/edit cannot alias a still-pending event.
1094    let id = format!(
1095        "c_{}",
1096        &digest(&(
1097            "supercov-change-id-v2",
1098            &state.changes,
1099            &file,
1100            &before,
1101            &after,
1102            &reason
1103        ))[..24]
1104    );
1105    state.changes.push(Change {
1106        id,
1107        file,
1108        before,
1109        after,
1110        reason,
1111        known_flows,
1112        exposed,
1113    });
1114}
1115
1116fn unique_occurrence(text: &str, snippet: &str) -> Option<usize> {
1117    if snippet.is_empty() {
1118        return None;
1119    }
1120    let first = text.find(snippet)?;
1121    // Include overlapping occurrences; match_indices skips them.
1122    let next = first + text[first..].chars().next()?.len_utf8();
1123    text[next..]
1124        .contains(snippet)
1125        .then_some(())
1126        .map_or(Some(first), |_| None)
1127}
1128fn target_file(file: &str, old: &FileManifest, new: &Files) -> Option<String> {
1129    if new.contains_key(file) {
1130        return Some(file.into());
1131    }
1132    let hash = old.get(file)?;
1133    let mut matches = new
1134        .iter()
1135        .filter(|(_, s)| FileFingerprint::of(s).same_bytes(hash));
1136    let first = matches.next()?.0;
1137    matches.next().is_none().then(|| first.clone())
1138}
1139pub fn relocate(at: &Anchor, old: &FileManifest, new: &Files) -> Option<Anchor> {
1140    let before = old.get(&at.file)?;
1141    let target = target_file(&at.file, old, new)?;
1142    let after = &new[&target];
1143    let mut candidate = at.clone();
1144    candidate.file.clone_from(&target);
1145    if FileFingerprint::of(after).same_bytes(before) && candidate.offset(new).is_some() {
1146        return Some(candidate);
1147    }
1148    // The file changed somewhere. That says nothing about this anchor: read the
1149    // recorded position in the new file and see whether it still holds the same
1150    // text. If it does, the anchor did not move and there is nothing to find.
1151    //
1152    // Without this, every anchor in a changed file is re-found by searching the
1153    // whole file, and that search insists the text be unique -- so a statement
1154    // that appears twice is reported "changed or ambiguous" while sitting
1155    // untouched at the line it was recorded at. That is a false statement about
1156    // a specific node, and it is most of the staleness in a real map.
1157    if let Some(start) = candidate.offset(new)
1158        && after.get(start..start + at.text.len()) == Some(at.text.as_str())
1159    {
1160        return Some(candidate);
1161    }
1162    let position = unique_occurrence(after, &at.text)?;
1163    Some(Anchor::new(
1164        &target,
1165        after,
1166        position,
1167        position + at.text.len(),
1168    ))
1169}
1170
1171/// How one captured file moved between two runs, judged once and read for
1172/// every flow.
1173pub enum FileChange<'a> {
1174    Same,
1175    /// Only comments changed: no program can tell.
1176    CommentsOnly,
1177    /// Not among the previous run's inputs.
1178    Added,
1179    Removed,
1180    /// No parser reads the file on one side or the other; its bytes moved.
1181    Bytes,
1182    Code {
1183        before: &'a Code,
1184        after: &'a Code,
1185        diff: Diff,
1186        /// The change is confined to declaration bodies that only run: it
1187        /// reaches a test only if the test ran one of them.
1188        narrow: bool,
1189    },
1190}
1191pub fn file_change<'a>(
1192    before: Option<&'a FileFingerprint>,
1193    after: Option<&'a FileFingerprint>,
1194    probed: Option<&[usize]>,
1195) -> Option<FileChange<'a>> {
1196    let Some(before) = before else {
1197        return after.map(|_| FileChange::Added);
1198    };
1199    let Some(after) = after else {
1200        return Some(FileChange::Removed);
1201    };
1202    if before.same_bytes(after) {
1203        return Some(FileChange::Same);
1204    }
1205    let (Some(old), Some(new)) = (&before.code, &after.code) else {
1206        return Some(FileChange::Bytes);
1207    };
1208    if old.semantic == new.semantic {
1209        return Some(FileChange::CommentsOnly);
1210    }
1211    let diff = old.diff(new);
1212    let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
1213    Some(FileChange::Code {
1214        before: old,
1215        after: new,
1216        diff,
1217        narrow,
1218    })
1219}
1220/// Every unit that moved, by name: what changed, what arrived, what went.
1221pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
1222    let mut parts = Vec::new();
1223    if !diff.changed.is_empty() {
1224        parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
1225    }
1226    if !diff.added.is_empty() {
1227        parts.push(format!(
1228            "added {}",
1229            named(diff.added.iter().map(|i| &after.units[*i]))
1230        ));
1231    }
1232    if !diff.removed.is_empty() {
1233        parts.push(format!(
1234            "removed {}",
1235            named(diff.removed.iter().map(|i| &before.units[*i]))
1236        ));
1237    }
1238    if parts.is_empty() {
1239        "declarations".to_owned()
1240    } else {
1241        parts.join("; ")
1242    }
1243}
1244/// The units a flow's tests executed, per file, each with what it sits
1245/// inside; `None` when a selected test has no execution record in this state,
1246/// in which case nothing about execution can be assumed.
1247fn executed<'s>(
1248    records: &BTreeMap<&TestSelector, &'s Execution>,
1249    f: &Flow,
1250    manifest: &InputManifest,
1251) -> Option<BTreeMap<&'s str, BTreeSet<usize>>> {
1252    let mut out: BTreeMap<&str, BTreeSet<usize>> = BTreeMap::new();
1253    for selector in &f.applies_to {
1254        let record = records.get(selector)?;
1255        for (file, units) in &record.files {
1256            let code = manifest.files.get(file).and_then(|fp| fp.code.as_ref());
1257            let set = out.entry(file.as_str()).or_default();
1258            for &unit in units {
1259                match code {
1260                    Some(code) if unit < code.units.len() => set.extend(code.ancestors(unit)),
1261                    _ => {
1262                        set.insert(unit);
1263                    }
1264                }
1265            }
1266        }
1267    }
1268    Some(out)
1269}
1270
1271/// Carries explanations, never execution events. Uncertain matches are retained
1272/// as retired suggestions; no nearest-line heuristic assigns semantic meaning.
1273///
1274/// A flow goes stale for a change to what its claim rests on and for nothing
1275/// else: the declarations holding its nodes and the top level of their files,
1276/// the test it applies to, a file it watches, its assertion, the run's
1277/// context. A change elsewhere in a node's file is a notice. A change to
1278/// comments or blank lines is nothing.
1279///
1280/// What each flow's test executed does not make the flow stale -- a claim
1281/// does not pass through every function its test happened to run, and an
1282/// acknowledgement demanded for all of them at once stops being read. It goes
1283/// on the change record instead: a changed file names the flows whose tests
1284/// ran the changed code, so the one assessment the change asks for is asked
1285/// of the right people, and a change nobody ran asks for none.
1286pub fn carry(
1287    map: &AssertionMap,
1288    state: &State,
1289    old: &InputManifest,
1290    new: &Inputs,
1291    evidence_digest: &str,
1292    context_changed: bool,
1293) -> Result<(AssertionMap, State), String> {
1294    if map.schema_version != 2 || old.schema_version != 2 || new.schema_version != 1 {
1295        return Err("unsupported map/input schema version".into());
1296    }
1297    if state.inputs_digest != digest(old) || state.schema_version != 3 {
1298        return Err("old map state does not match its run inputs".into());
1299    }
1300    let ledger = Ledger::new(map, state, old);
1301    let new_manifest = new.manifest();
1302    let (mut next, mut next_state) = seed_manifest(&new_manifest, evidence_digest);
1303    next.assertions.clear();
1304    next.retired_assertions = map.retired_assertions.clone();
1305    next_state.changes = state
1306        .changes
1307        .iter()
1308        .filter(|c| !ledger.current(&c.id))
1309        .cloned()
1310        .collect();
1311    next.change_assessments = map
1312        .change_assessments
1313        .iter()
1314        .filter(|r| next_state.changes.iter().any(|c| c.id == r.id))
1315        .cloned()
1316        .collect();
1317    let records = state
1318        .executions
1319        .iter()
1320        .flat_map(|e| e.tests.iter().map(|t| (&t.test, t)))
1321        .collect::<BTreeMap<_, _>>();
1322    let probed = |file: &str| {
1323        state
1324            .executions
1325            .as_ref()
1326            .and_then(|e| e.probed.get(file))
1327            .map(Vec::as_slice)
1328    };
1329    let changes = old
1330        .files
1331        .keys()
1332        .chain(new_manifest.files.keys())
1333        .collect::<BTreeSet<_>>()
1334        .into_iter()
1335        .filter_map(|file| {
1336            file_change(
1337                old.files.get(file),
1338                new_manifest.files.get(file),
1339                probed(file),
1340            )
1341            .map(|change| (file.as_str(), change))
1342        })
1343        .collect::<BTreeMap<_, _>>();
1344    // Per changed file: the flows it made stale, and the flows whose tests ran
1345    // the changed code or have no record to say -- what the change record
1346    // names as known and as exposed.
1347    let mut marked: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1348    let mut exposed: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1349    let mut consumed = BTreeSet::new();
1350    let exact = map
1351        .assertions
1352        .iter()
1353        .map(|a| {
1354            relocate(&a.at, &old.files, &new.files).filter(|at| {
1355                new.assertions.iter().any(|s| &s.at == at)
1356                    || !old.assertions.iter().any(|s| s.at == a.at)
1357            })
1358        })
1359        .collect::<Vec<_>>();
1360    let reserved = exact.iter().flatten().collect::<BTreeSet<_>>();
1361    for (index, a) in map.assertions.iter().enumerate() {
1362        // A sole old/new unmatched site in the same file is a review
1363        // suggestion. Preserve its explanation but never its reviewed status.
1364        let candidates = new
1365            .assertions
1366            .iter()
1367            .filter(|s| s.at.file == a.at.file && !reserved.contains(&s.at))
1368            .collect::<Vec<_>>();
1369        let unmatched = map
1370            .assertions
1371            .iter()
1372            .zip(&exact)
1373            .filter(|(other, at)| other.at.file == a.at.file && at.is_none())
1374            .count();
1375        let replacement = if exact[index].is_none() && unmatched == 1 && candidates.len() == 1 {
1376            Some(&candidates[0].at)
1377        } else {
1378            None
1379        };
1380        let matched = exact[index]
1381            .as_ref()
1382            .or(replacement)
1383            .filter(|at| !consumed.contains(*at));
1384        let Some(at) = matched else {
1385            next.retired_assertions.push(Retired {
1386                assertion: a.clone(),
1387                reason:
1388                    "assertion removed, changed or ambiguous; reuse its explanation after review"
1389                        .into(),
1390            });
1391            continue;
1392        };
1393        consumed.insert(at.clone());
1394        let mut updated = a.clone();
1395        updated.at = at.clone();
1396        for (prior, f) in a.flows.iter().zip(&mut updated.flows) {
1397            let key = flow_key(a, f);
1398            let base = generation(a, prior, state, &ledger);
1399            let mut dirty = BTreeSet::new();
1400            let mut notices = BTreeSet::new();
1401            match prior.basis.as_deref() {
1402                Some(basis) if superseded_basis(basis) => {
1403                    dirty.insert(SUPERSEDED_BASIS.into());
1404                }
1405                Some(basis) if basis != expected_basis_with(a, prior, state, old, &ledger) => {
1406                    dirty.insert("inherited claim still needs rechecking".into());
1407                }
1408                _ => {}
1409            }
1410            // What the flow's test ran, for the change record: a changed file
1411            // is assessed by whoever ran the change, and a change nobody ran
1412            // is not assessed at all.
1413            match executed(&records, prior, old) {
1414                Some(ran) => {
1415                    for (file, units) in &ran {
1416                        let reached = match changes.get(file) {
1417                            None
1418                            | Some(
1419                                FileChange::Same | FileChange::CommentsOnly | FileChange::Added,
1420                            ) => false,
1421                            Some(FileChange::Removed | FileChange::Bytes) => true,
1422                            Some(FileChange::Code { diff, narrow, .. }) => {
1423                                !*narrow || diff.changed.iter().any(|i| units.contains(i))
1424                            }
1425                        };
1426                        if reached {
1427                            exposed.entry(file).or_default().insert(key.clone());
1428                        }
1429                    }
1430                }
1431                None => {
1432                    for (file, change) in &changes {
1433                        if !matches!(
1434                            change,
1435                            FileChange::Same | FileChange::CommentsOnly | FileChange::Added
1436                        ) {
1437                            exposed.entry(file).or_default().insert(key.clone());
1438                        }
1439                    }
1440                }
1441            }
1442            // What the flow names: its test, its watch list and its assertion's
1443            // file as a whole; the file of a node for the declarations that
1444            // hold the node, its top level and its set of declarations.
1445            for file in dependencies(a, prior) {
1446                let roles = roles(a, prior, file);
1447                let verdict = match changes.get(file) {
1448                    None => Some(format!(
1449                        "{file} is not among the run's inputs{}",
1450                        in_role(&roles)
1451                    )),
1452                    Some(FileChange::Added) => Some(format!(
1453                        "{file} is new since the previous run{}",
1454                        in_role(&roles)
1455                    )),
1456                    Some(FileChange::Same | FileChange::CommentsOnly) => None,
1457                    Some(FileChange::Removed) => Some(format!("{file} removed{}", in_role(&roles))),
1458                    Some(FileChange::Bytes) => Some(format!("{file} changed{}", in_role(&roles))),
1459                    Some(FileChange::Code {
1460                        before,
1461                        after,
1462                        diff,
1463                        ..
1464                    }) => {
1465                        if whole_file(a, prior, file) {
1466                            Some(format!(
1467                                "{file}: {} changed{}",
1468                                describe(before, after, diff),
1469                                in_role(&roles)
1470                            ))
1471                        } else {
1472                            let holders = prior
1473                                .nodes
1474                                .iter()
1475                                .filter(|n| n.at.file == file)
1476                                .flat_map(|n| {
1477                                    before.ancestors(before.unit_at(n.at.line, n.at.column))
1478                                })
1479                                .collect::<BTreeSet<_>>();
1480                            let moved = holders
1481                                .iter()
1482                                .filter(|i| diff.changed.contains(i) || diff.removed.contains(i))
1483                                .map(|i| &before.units[*i])
1484                                .collect::<Vec<_>>();
1485                            if !moved.is_empty() {
1486                                Some(format!(
1487                                    "{file}: {} changed{}",
1488                                    named(moved),
1489                                    in_role(&roles)
1490                                ))
1491                            } else if diff.structural {
1492                                Some(format!(
1493                                    "{file}: declarations changed, {}{}",
1494                                    describe(before, after, diff),
1495                                    in_role(&roles)
1496                                ))
1497                            } else {
1498                                notices.insert(format!(
1499                                    "{file} changed outside this flow's nodes: {}",
1500                                    describe(before, after, diff)
1501                                ));
1502                                None
1503                            }
1504                        }
1505                    }
1506                };
1507                if let Some(reason) = verdict {
1508                    dirty.insert(reason);
1509                    marked.entry(file).or_default().insert(key.clone());
1510                }
1511            }
1512            if replacement.is_some() {
1513                dirty.insert("assertion changed or replaced; confirm identity and meaning".into());
1514            }
1515            for node in &mut f.nodes {
1516                if let Some(at) = relocate(&node.at, &old.files, &new.files) {
1517                    node.at = at;
1518                } else {
1519                    dirty.insert(format!("node {} changed or ambiguous", node.id));
1520                }
1521            }
1522            for file in f
1523                .watch
1524                .iter_mut()
1525                .chain(f.applies_to.iter_mut().map(|t| &mut t.file))
1526            {
1527                if let Some(target) = target_file(file, &old.files, &new.files) {
1528                    *file = target;
1529                } else {
1530                    dirty.insert(format!("dependency file removed: {file}"));
1531                }
1532            }
1533            if context_changed {
1534                dirty.insert("run configuration, dependencies or execution context changed".into());
1535            }
1536            next_state.flows.insert(
1537                key,
1538                FlowState {
1539                    generation: if dirty.is_empty() {
1540                        base
1541                    } else {
1542                        digest(&("supercov-carry-v3", base, &new_manifest, &dirty))
1543                    },
1544                    reasons: dirty,
1545                    notices,
1546                },
1547            );
1548        }
1549        next.assertions.push(updated);
1550    }
1551    let mut ids = map
1552        .assertions
1553        .iter()
1554        .map(|a| a.id.clone())
1555        .chain(
1556            map.retired_assertions
1557                .iter()
1558                .map(|r| r.assertion.id.clone()),
1559        )
1560        .collect::<BTreeSet<_>>();
1561    for a in seed(new, evidence_digest).0.assertions {
1562        if !consumed.contains(&a.at) {
1563            let mut a = a;
1564            while !ids.insert(a.id.clone()) {
1565                a.id.push('_');
1566            }
1567            next.assertions.push(a);
1568        }
1569    }
1570    for file in old
1571        .files
1572        .keys()
1573        .chain(new_manifest.files.keys())
1574        .collect::<BTreeSet<_>>()
1575    {
1576        // A manifest is answered for by the run's dependency fingerprint, which
1577        // reads what it declares. Reporting its bytes here as well would make
1578        // cutting a release look like a change to assess when nothing about the
1579        // project moved.
1580        if crate::integrity::tracked_manifest(file) {
1581            continue;
1582        }
1583        let exposed_to = exposed.get(file.as_str()).cloned().unwrap_or_default();
1584        match changes.get(file.as_str()) {
1585            // A comment is not a change to assess.
1586            Some(FileChange::Same | FileChange::CommentsOnly) => continue,
1587            // A change confined to code that only runs, which no selected test
1588            // ran, cannot have reached any claim; the flow claiming that code
1589            // is already stale for it. Nothing to ask.
1590            Some(FileChange::Code { narrow: true, .. }) if exposed_to.is_empty() => continue,
1591            _ => {}
1592        }
1593        add_change(
1594            &mut next_state,
1595            Some(file.clone()),
1596            old.files.get(file).map(|f| f.sha256.clone()),
1597            new_manifest.files.get(file).map(|f| f.sha256.clone()),
1598            "captured source file changed".into(),
1599            marked.get(file.as_str()).cloned().unwrap_or_default(),
1600            exposed_to,
1601        );
1602    }
1603    next.assertions.sort_by(|a, b| a.at.cmp(&b.at));
1604    Ok((next, next_state))
1605}
1606
1607#[path = "assertion_legacy.rs"]
1608mod legacy;
1609pub fn parse_stored(bytes: &[u8]) -> Result<AssertionMap, String> {
1610    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1611    match value
1612        .get("schemaVersion")
1613        .and_then(serde_json::Value::as_u64)
1614    {
1615        None | Some(1) => legacy::import(bytes),
1616        _ => parse(bytes).map_err(|e| e.to_string()),
1617    }
1618}
1619pub fn parse_state(
1620    bytes: &[u8],
1621    map: &AssertionMap,
1622    inputs: &InputManifest,
1623    evidence: &str,
1624    legacy_digest: Option<&str>,
1625) -> Result<State, String> {
1626    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1627    if value["schemaVersion"] == 3 {
1628        let state: State = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1629        if state.inputs_digest != digest(inputs) || state.evidence_digest != evidence {
1630            return Err("Assertion state belongs to different run evidence; rerun tests".into());
1631        }
1632        Ok(state)
1633    } else {
1634        legacy::state(bytes, map, inputs, evidence, legacy_digest)
1635    }
1636}