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    /// What the flow rested on when this state was written, part by part.
353    ///
354    /// `basis` is one hash of everything, so a mismatch can say that
355    /// *something* moved and no more. Recording the parts separately lets a
356    /// later mismatch name the one that moved -- an author who deleted a watch
357    /// entry and one who rewrote an explanation were given the same sentence.
358    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
359    pub footprint: BTreeMap<String, String>,
360}
361/// What each test of a run executed, in the units of that run's manifest.
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase", deny_unknown_fields)]
364pub struct Executions {
365    pub tests: Vec<Execution>,
366    /// Per file, the units that hold a probe of their own. A change confined
367    /// to these can reach a test only by being run.
368    pub probed: BTreeMap<String, Vec<usize>>,
369}
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372pub struct Execution {
373    pub test: TestSelector,
374    pub passed: bool,
375    /// Per file, the innermost unit of every probe this test fired.
376    pub files: BTreeMap<String, Vec<usize>>,
377}
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase", deny_unknown_fields)]
380pub struct Change {
381    pub id: String,
382    pub file: Option<String>,
383    pub before: Option<String>,
384    pub after: Option<String>,
385    pub reason: String,
386    /// Flows this change has already made stale. An assessment has to name
387    /// them; it may name more.
388    pub known_flows: BTreeSet<String>,
389    /// Flows whose selected tests ran the changed code, or have no execution
390    /// record to say. Not stale for it -- the claim they make does not pass
391    /// through that code -- but these are the ones the assessment is about.
392    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
393    pub exposed: BTreeSet<String>,
394}
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396#[serde(rename_all = "camelCase", deny_unknown_fields)]
397pub struct State {
398    pub schema_version: u32,
399    pub inputs_digest: String,
400    pub evidence_digest: String,
401    pub flows: BTreeMap<String, FlowState>,
402    pub changes: Vec<Change>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub inheritance: Option<Inheritance>,
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub executions: Option<Executions>,
407}
408#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
409#[serde(rename_all = "camelCase", deny_unknown_fields)]
410pub struct Inheritance {
411    pub from: Option<String>,
412    pub skipped: Vec<SkippedMap>,
413}
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
415#[serde(rename_all = "camelCase", deny_unknown_fields)]
416pub struct SkippedMap {
417    pub run: String,
418    pub reason: String,
419}
420pub fn flow_key(a: &Assertion, f: &Flow) -> String {
421    format!("{}/{}", a.id, f.id)
422}
423fn valid_id(id: &str) -> bool {
424    !id.is_empty()
425        && id
426            .bytes()
427            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
428}
429
430pub fn seed(inputs: &Inputs, evidence_digest: &str) -> (AssertionMap, State) {
431    seed_manifest(&inputs.manifest(), evidence_digest)
432}
433pub fn seed_manifest(inputs: &InputManifest, evidence_digest: &str) -> (AssertionMap, State) {
434    (
435        AssertionMap {
436            schema_version: 2,
437            assertions: inputs
438                .assertions
439                .iter()
440                .map(|site| Assertion {
441                    id: format!("a_{}", &digest(&site.at)[..20]),
442                    at: site.at.clone(),
443                    questions: vec![],
444                    observes: vec![],
445                    flows: vec![],
446                })
447                .collect(),
448            change_assessments: vec![],
449            retired_assertions: vec![],
450        },
451        State {
452            schema_version: 3,
453            inputs_digest: digest(inputs),
454            evidence_digest: evidence_digest.into(),
455            flows: BTreeMap::new(),
456            changes: vec![],
457            inheritance: None,
458            executions: None,
459        },
460    )
461}
462
463/// Structural checks only; malformed entries cannot silently earn credit.
464pub fn validate(map: &AssertionMap, inputs: &Inputs) -> Vec<String> {
465    let mut errors = Vec::new();
466    if map.schema_version != 2 || inputs.schema_version != 1 {
467        errors.push("unsupported schema version".into());
468    }
469    let mut ids = BTreeSet::new();
470    let mut sites = BTreeSet::new();
471    for a in &map.assertions {
472        if !valid_id(&a.id) || !ids.insert(&a.id) {
473            errors.push(format!("{}: invalid/duplicate assertion ID", a.id));
474        }
475        if !sites.insert(&a.at) {
476            errors.push(format!("{}: duplicate assertion location", a.id));
477        }
478        if a.at.offset(&inputs.files).is_none() {
479            errors.push(format!("{}: invalid assertion anchor", a.id));
480        }
481        // Agents can register custom assertions absent from the syntax inventory.
482        // They still require exact run evidence to earn execution-backed credit.
483        let mut flows = BTreeSet::new();
484        for f in &a.flows {
485            let key = flow_key(a, f);
486            if !valid_id(&f.id) || !flows.insert(&f.id) {
487                errors.push(format!("{key}: invalid/duplicate flow ID"));
488            }
489            errors.extend(
490                validate_flow(f, &inputs.files)
491                    .into_iter()
492                    .map(|e| format!("{key}: {e}")),
493            );
494        }
495    }
496    let mut changes = BTreeSet::new();
497    for change in &map.change_assessments {
498        if !valid_id(&change.id) || !changes.insert(&change.id) {
499            errors.push("invalid/duplicate change assessment ID".into());
500        }
501    }
502    errors
503}
504/// Things worth telling the author that do not make the map wrong.
505///
506/// A redundant `watch` is the one that matters today. Supercov already marks
507/// every flow dirty when a dependency manifest or the execution configuration
508/// changes, so naming one of those files per flow catches nothing extra. It
509/// does teach a false model -- that per-flow watching is how dependency drift
510/// is caught -- and an author who believes it spends the effort on entries that
511/// change nothing instead of on the helper their claim actually rests on.
512pub fn advisories(map: &AssertionMap) -> Vec<String> {
513    let mut out = Vec::new();
514    for a in &map.assertions {
515        for f in &a.flows {
516            for file in &f.watch {
517                let key = flow_key(a, f);
518                if crate::integrity::globally_tracked(file) {
519                    out.push(format!(
520                        "{key}: watch \"{file}\" is redundant; Supercov invalidates every flow when that file changes"
521                    ));
522                    continue;
523                }
524                if a.at.file == *file || f.applies_to.iter().any(|t| t.file == *file) {
525                    out.push(format!(
526                        "{key}: watch \"{file}\" is redundant; this flow already depends on that file as a whole"
527                    ));
528                    continue;
529                }
530                // Not redundant -- it changes what the flow rests on, which is
531                // the part an author cannot see. Naming a file that holds this
532                // flow's nodes takes the file out of declaration-level footing
533                // and puts the whole file back in, so a neighbouring function's
534                // body becomes a review again. That can be exactly what the
535                // author means -- "no other handler in here registers /admin"
536                // is a claim about the file's shape -- so it is said, not
537                // refused.
538                let here = f
539                    .nodes
540                    .iter()
541                    .filter(|n| n.at.file == *file)
542                    .map(|n| format!("{}:{}", n.id, n.at.line))
543                    .collect::<Vec<_>>();
544                if !here.is_empty() {
545                    out.push(format!(
546                        "{key}: watch \"{file}\" widens this flow to the whole file; without it only the declarations holding its nodes ({}) and the file's set of declarations would count. Remove it unless a change anywhere in that file should be a review.",
547                        here.join(", ")
548                    ));
549                }
550            }
551        }
552    }
553    out
554}
555pub fn validate_flow(flow: &Flow, files: &Files) -> Vec<String> {
556    let mut errors = Vec::new();
557    let mut nodes = BTreeSet::new();
558    for node in &flow.nodes {
559        if !valid_id(&node.id) || !nodes.insert(&node.id) {
560            errors.push("invalid/duplicate node ID".into());
561        }
562        if node.at.offset(files).is_none() {
563            errors.push(format!("node {}: invalid anchor", node.id));
564        }
565    }
566    for edge in &flow.edges {
567        if !nodes.contains(&edge.from) || (!nodes.contains(&edge.to) && edge.to != "$assertion") {
568            errors.push("dangling edge".into());
569        }
570    }
571    if flow.counts_as_asserted.iter().any(|id| !nodes.contains(id)) {
572        errors.push("unknown counted node".into());
573    }
574    // Traverse only the author's graph. Never infer a dependency from source.
575    let mut reaches = BTreeSet::from(["$assertion".to_owned()]);
576    loop {
577        let size = reaches.len();
578        for edge in &flow.edges {
579            if reaches.contains(&edge.to) {
580                reaches.insert(edge.from.clone());
581            }
582        }
583        if reaches.len() == size {
584            break;
585        }
586    }
587    for id in &flow.counts_as_asserted {
588        if !reaches.contains(id) {
589            errors.push(format!(
590                "counted node {id} has no authored path to $assertion"
591            ));
592        }
593    }
594    if flow.edges.iter().any(|e| e.kind.trim().is_empty()) {
595        errors.push("missing edge kind".into());
596    }
597    if flow
598        .applies_to
599        .iter()
600        .any(|t| !local_path(&t.file) || !files.contains_key(&t.file) || t.name.trim().is_empty())
601    {
602        errors.push("invalid test selector file or name".into());
603    }
604    if flow.applies_to.iter().collect::<BTreeSet<_>>().len() != flow.applies_to.len() {
605        errors.push("duplicate test selector".into());
606    }
607    if flow
608        .counts_as_asserted
609        .iter()
610        .collect::<BTreeSet<_>>()
611        .len()
612        != flow.counts_as_asserted.len()
613    {
614        errors.push("duplicate counted node".into());
615    }
616    if flow.explanation.trim().is_empty() {
617        errors.push("missing explanation".into());
618    }
619    for file in &flow.watch {
620        if !local_path(file) || !files.contains_key(file) {
621            errors.push(format!("watched file missing: {file}"));
622        }
623    }
624    errors
625}
626
627/// The watch entries that are the flow's own.
628///
629/// A manifest, lockfile or runner configuration is tracked for the whole run,
630/// so naming one here catches nothing -- `advisories()` tells the author so.
631/// An entry that catches nothing must also cost nothing, in both directions:
632/// it is not a dependency, and taking it back out is not a new claim. Those
633/// are two different code paths -- `dependencies()` and `claim()` -- and when
634/// only the first filtered, Supercov advised authors to delete an entry it
635/// then charged a full re-acknowledgement for.
636///
637/// Only the watch list is filtered. An anchor or a node in one of those files
638/// is the flow's actual subject -- `setup.py` is a dependency manifest and
639/// measured source at once -- and editing it must still cost a review.
640fn watched(f: &Flow) -> impl Iterator<Item = &str> {
641    f.watch
642        .iter()
643        .map(String::as_str)
644        .filter(|path| !crate::integrity::globally_tracked(path))
645}
646/// Whole-file input dependencies, not a mechanically inferred semantic slice.
647pub fn dependencies<'a>(a: &'a Assertion, f: &'a Flow) -> BTreeSet<&'a str> {
648    std::iter::once(a.at.file.as_str())
649        .chain(f.applies_to.iter().map(|t| t.file.as_str()))
650        .chain(f.nodes.iter().map(|n| n.at.file.as_str()))
651        // Hashing a manifest's bytes here would quietly undo the manifest
652        // rule: a version bump would make every flow that names
653        // `package.json` stale, which is most of them in a real map. The
654        // run-level signal still fires, as a change to assess.
655        .chain(watched(f))
656        .collect()
657}
658fn token(value: &impl Serialize) -> String {
659    format!("scov3:{}", digest(value))
660}
661fn flow_keys(map: &AssertionMap) -> BTreeSet<String> {
662    map.assertions
663        .iter()
664        .flat_map(|a| a.flows.iter().map(move |f| flow_key(a, f)))
665        .collect()
666}
667/// An assessment is the author's judgement, so it is validated against the
668/// map it names flows in -- not against the change. The change no longer
669/// determines any part of a well-formed response.
670pub fn change_errors(map: &AssertionMap, response: &ChangeAssessment) -> Vec<String> {
671    change_errors_with(&flow_keys(map), response)
672}
673fn change_errors_with(keys: &BTreeSet<String>, response: &ChangeAssessment) -> Vec<String> {
674    let affected = response
675        .affected_flows
676        .iter()
677        .cloned()
678        .collect::<BTreeSet<_>>();
679    let mut errors = Vec::new();
680    if response.explanation.trim().is_empty() {
681        errors.push("missing impact explanation".into());
682    }
683    if affected.len() != response.affected_flows.len() {
684        errors.push("duplicate affected flow".into());
685    }
686    if !affected.is_subset(keys) {
687        errors.push("unknown affected flow".into());
688    }
689    // `affectedFlows` is the author's judgement -- the dependents this change
690    // actually invalidates -- not a restatement of `knownFlows`.
691    //
692    // Requiring the exhaustive set made the field carry no judgement at all: it
693    // was fully determined by data Supercov already holds, and every flow it
694    // named lost its acknowledgement. On a real map that meant one no-op
695    // manifest edit took 657 flows to zero, so the only ways forward were to
696    // copy 653 basis tokens for claims nobody had read, or leave the change
697    // pending and keep no percentage. That is the rubber-stamping the whole
698    // invalidation rule exists to prevent, one step further down.
699    //
700    // There is deliberately no floor -- not even "name everything whose test
701    // ran the change". Under an integration suite every test runs everything,
702    // so that floor is the same cascade wearing a different hat. Exposure is
703    // reported so the author can judge; it does not judge for them.
704    errors
705}
706pub fn expected_change_basis(
707    change: &Change,
708    response: &ChangeAssessment,
709    inputs: &InputManifest,
710) -> String {
711    expected_change_basis_with(change, response, &digest(inputs))
712}
713fn expected_change_basis_with(
714    change: &Change,
715    response: &ChangeAssessment,
716    inputs_digest: &str,
717) -> String {
718    token(&(
719        "supercov-change-v2",
720        change,
721        inputs_digest,
722        &response.id,
723        &response.affected_flows,
724        &response.explanation,
725    ))
726}
727pub fn change_current(map: &AssertionMap, change: &Change, inputs: &InputManifest) -> bool {
728    Ledger::with_changes(map, std::slice::from_ref(change), inputs).current(&change.id)
729}
730/// What every token of one run shares, computed once: the manifest's digest,
731/// and each change whose assessment is current with the flows it names.
732///
733/// Computed per flow instead, this serialised the whole manifest once per
734/// flow per pending change -- minutes on a real map with a backlog of
735/// changes, on every carry and every report.
736pub struct Ledger<'a> {
737    pub inputs_digest: String,
738    keys: BTreeSet<String>,
739    /// Current assessments by change id: the basis the author recorded and
740    /// the flows it names.
741    current: BTreeMap<&'a str, (&'a Option<String>, &'a [String])>,
742}
743impl<'a> Ledger<'a> {
744    pub fn new(map: &'a AssertionMap, state: &'a State, inputs: &InputManifest) -> Self {
745        Self::with_changes(map, &state.changes, inputs)
746    }
747    fn with_changes(map: &'a AssertionMap, changes: &'a [Change], inputs: &InputManifest) -> Self {
748        let inputs_digest = digest(inputs);
749        let keys = flow_keys(map);
750        let current = changes
751            .iter()
752            .filter_map(|change| {
753                let responses = map
754                    .change_assessments
755                    .iter()
756                    .filter(|r| r.id == change.id)
757                    .collect::<Vec<_>>();
758                match responses.as_slice() {
759                    [r] if change_errors_with(&keys, r).is_empty()
760                        && r.basis.as_deref()
761                            == Some(
762                                expected_change_basis_with(change, r, &inputs_digest).as_str(),
763                            ) =>
764                    {
765                        Some((change.id.as_str(), (&r.basis, r.affected_flows.as_slice())))
766                    }
767                    _ => None,
768                }
769            })
770            .collect();
771        Self {
772            inputs_digest,
773            keys,
774            current,
775        }
776    }
777    /// Whether the change has a valid, current assessment.
778    pub fn current(&self, change: &str) -> bool {
779        self.current.contains_key(change)
780    }
781    pub fn expected_change_basis(&self, change: &Change, response: &ChangeAssessment) -> String {
782        expected_change_basis_with(change, response, &self.inputs_digest)
783    }
784    pub fn change_errors(&self, response: &ChangeAssessment) -> Vec<String> {
785        change_errors_with(&self.keys, response)
786    }
787}
788/// Why a file is one of a flow's dependencies, and where the flow sits in it.
789///
790/// "dependency file changed" names a file and leaves the author to work out
791/// what it has to do with this claim. A flow depends on a file for one of four
792/// reasons, and they call for different judgements: a node there is the claim's
793/// subject, a watch is something the author asked to be told about, the test
794/// file is where the claim is exercised, and the assertion's own file is where
795/// it is written. Saying which -- and where the nodes are -- is the difference
796/// between rereading a claim and glancing at a line number.
797fn roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
798    let mut roles: Vec<String> = Vec::new();
799    let lines = node_sites(f, file);
800    if !lines.is_empty() {
801        roles.push(format!("holds this flow's {}", lines.join(", ")));
802    }
803    roles.extend(whole_file_roles(a, f, file));
804    roles
805}
806fn node_sites(f: &Flow, file: &str) -> Vec<String> {
807    f.nodes
808        .iter()
809        .filter(|n| n.at.file == file)
810        .map(|n| format!("{}:{}", n.id, n.at.line))
811        .collect()
812}
813/// Why the flow depends on the file *as a whole*, which is a different claim
814/// from where its nodes sit in it.
815fn whole_file_roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
816    let mut roles: Vec<String> = Vec::new();
817    if a.at.file == file {
818        roles.push(format!("holds the assertion, line {}", a.at.line));
819    }
820    if f.applies_to.iter().any(|t| t.file == file) {
821        roles.push("is the test this claim applies to".to_owned());
822    }
823    if f.watch.iter().any(|w| w == file) {
824        roles.push("is watched by this flow".to_owned());
825    }
826    roles
827}
828fn in_role(roles: &[String]) -> String {
829    if roles.is_empty() {
830        // Every path into dependencies() is covered above; say nothing rather
831        // than guess if that ever stops being true.
832        String::new()
833    } else {
834        format!(" ({})", roles.join("; "))
835    }
836}
837/// A file the flow rests on as a whole: the test it applies to, a file it
838/// watches, the file its assertion is written in. Any change there is the
839/// author's to judge; only its comments are not.
840fn whole_file(a: &Assertion, f: &Flow, file: &str) -> bool {
841    a.at.file == file
842        || f.applies_to.iter().any(|t| t.file == file)
843        || f.watch.iter().any(|w| w == file)
844}
845/// A claim's identity is where it points and what it says, not the line it
846/// happens to be on: a file edited above a node moves the node without
847/// touching the claim. Where the file has a parser's view, a node is placed by
848/// the declaration holding it and how many lines of code lie between it and
849/// the nearest boundary in that declaration -- the declaration's start, or the
850/// end of the last nested declaration before it. Growth anywhere else, and
851/// comments or blank lines anywhere, leave it in place; pointing it at another
852/// statement of the same text on another line does not.
853#[derive(Serialize)]
854struct Site<'a> {
855    file: &'a str,
856    text: &'a str,
857    #[serde(skip_serializing_if = "Option::is_none")]
858    unit: Option<&'a str>,
859    line: usize,
860    #[serde(skip_serializing_if = "Option::is_none")]
861    column: Option<usize>,
862}
863fn site<'a>(at: &'a Anchor, inputs: &'a InputManifest) -> Site<'a> {
864    let code = inputs.files.get(&at.file).and_then(|f| f.code.as_ref());
865    let Some(code) = code else {
866        return Site {
867            file: &at.file,
868            text: &at.text,
869            unit: None,
870            line: at.line,
871            column: Some(at.column),
872        };
873    };
874    let holder = code.unit_at(at.line, at.column);
875    let mut boundary = code.units[holder].line;
876    for child in code.units.iter().filter(|u| u.parent == Some(holder)) {
877        if (child.end_line, child.end_column) <= (at.line, at.column) && child.end_line > boundary {
878            boundary = child.end_line;
879        }
880    }
881    Site {
882        file: &at.file,
883        text: &at.text,
884        unit: Some(&code.units[holder].path),
885        line: code
886            .code_line(at.line)
887            .saturating_sub(code.code_line(boundary)),
888        column: None,
889    }
890}
891#[derive(Serialize)]
892#[serde(rename_all = "camelCase")]
893struct NodeClaim<'a> {
894    id: &'a str,
895    at: Site<'a>,
896    role: &'a str,
897    meaning: &'a str,
898}
899#[derive(Serialize)]
900#[serde(rename_all = "camelCase")]
901struct Claim<'a> {
902    id: &'a str,
903    explanation: &'a str,
904    applies_to: &'a [TestSelector],
905    nodes: Vec<NodeClaim<'a>>,
906    edges: &'a [Edge],
907    counts_as_asserted: &'a [String],
908    /// The flow's own watches. A redundant manifest entry is left out so that
909    /// writing one and removing it are both free.
910    watch: Vec<&'a str>,
911    questions: &'a [String],
912}
913fn claim<'a>(f: &'a Flow, inputs: &'a InputManifest) -> Claim<'a> {
914    Claim {
915        id: &f.id,
916        explanation: &f.explanation,
917        applies_to: &f.applies_to,
918        nodes: f
919            .nodes
920            .iter()
921            .map(|n| NodeClaim {
922                id: &n.id,
923                at: site(&n.at, inputs),
924                role: &n.role,
925                meaning: &n.meaning,
926            })
927            .collect(),
928        edges: &f.edges,
929        counts_as_asserted: &f.counts_as_asserted,
930        watch: watched(f).collect(),
931        questions: &f.questions,
932    }
933}
934/// What an acknowledgement rests on in one dependency file.
935#[derive(Serialize)]
936#[serde(rename_all = "camelCase")]
937enum Footing<'a> {
938    /// Named by the flow but not among the run's inputs.
939    Absent,
940    /// No parser reads this file; its bytes are the claim's ground.
941    Bytes(&'a str),
942    /// Everything the file does, comments aside.
943    Semantic(&'a str),
944    /// The declarations holding this flow's nodes, and the file's set of
945    /// declarations. Code elsewhere in the file is answered for by what the
946    /// flow's test executed, which `carry` judges.
947    Units {
948        structure: &'a str,
949        units: BTreeMap<&'a str, &'a str>,
950    },
951}
952fn footing<'a>(
953    a: &'a Assertion,
954    f: &'a Flow,
955    inputs: &'a InputManifest,
956) -> BTreeMap<&'a str, Footing<'a>> {
957    dependencies(a, f)
958        .into_iter()
959        .map(|file| {
960            let Some(fingerprint) = inputs.files.get(file) else {
961                return (file, Footing::Absent);
962            };
963            let Some(code) = &fingerprint.code else {
964                return (file, Footing::Bytes(&fingerprint.sha256));
965            };
966            if whole_file(a, f, file) {
967                return (file, Footing::Semantic(&code.semantic));
968            }
969            let units = f
970                .nodes
971                .iter()
972                .filter(|n| n.at.file == file)
973                .flat_map(|n| code.ancestors(code.unit_at(n.at.line, n.at.column)))
974                .map(|i| (code.units[i].path.as_str(), code.units[i].digest.as_str()))
975                .collect();
976            (
977                file,
978                Footing::Units {
979                    structure: &code.structure,
980                    units,
981                },
982            )
983        })
984        .collect()
985}
986
987fn generation(a: &Assertion, f: &Flow, state: &State, ledger: &Ledger<'_>) -> String {
988    let key = flow_key(a, f);
989    let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
990    let impacts = ledger
991        .current
992        .iter()
993        .filter(|(_, (_, affected))| affected.contains(&key))
994        .map(|(id, (basis, _))| (*id, *basis))
995        .collect::<BTreeMap<_, _>>();
996    if impacts.is_empty() {
997        base.into()
998    } else {
999        digest(&("supercov-generation-v2", base, impacts))
1000    }
1001}
1002/// The parts of `expected_basis` that depend only on the map and the run's
1003/// inputs, each digested on its own.
1004///
1005/// `generation` is deliberately absent: it is a function of the state being
1006/// written, and a change assessment naming this flow already explains itself
1007/// through the change channel.
1008fn footprint(a: &Assertion, f: &Flow, inputs: &InputManifest) -> BTreeMap<String, String> {
1009    let claim = claim(f, inputs);
1010    BTreeMap::from([
1011        (
1012            "the run's context".to_owned(),
1013            digest(&inputs.context_digest),
1014        ),
1015        (
1016            "the assertion's site".to_owned(),
1017            digest(&site(&a.at, inputs)),
1018        ),
1019        (
1020            "what the assertion observes".to_owned(),
1021            digest(&a.observes),
1022        ),
1023        (
1024            "the flow's explanation".to_owned(),
1025            digest(&claim.explanation),
1026        ),
1027        (
1028            "the test this flow applies to".to_owned(),
1029            digest(&claim.applies_to),
1030        ),
1031        ("the flow's nodes".to_owned(), digest(&claim.nodes)),
1032        ("the flow's edges".to_owned(), digest(&claim.edges)),
1033        (
1034            "the flow's counted nodes".to_owned(),
1035            digest(&claim.counts_as_asserted),
1036        ),
1037        ("the flow's watch list".to_owned(), digest(&claim.watch)),
1038        ("the flow's questions".to_owned(), digest(&claim.questions)),
1039        (
1040            "the files this flow rests on".to_owned(),
1041            digest(&footing(a, f, inputs)),
1042        ),
1043    ])
1044}
1045/// Which recorded parts no longer match, in the order they are listed above.
1046fn moved(before: &BTreeMap<String, String>, after: &BTreeMap<String, String>) -> Vec<String> {
1047    after
1048        .iter()
1049        .filter(|(part, now)| before.get(*part).is_some_and(|then| then != *now))
1050        .map(|(part, _)| format!("{part} changed"))
1051        .collect()
1052}
1053pub fn expected_basis(
1054    a: &Assertion,
1055    f: &Flow,
1056    map: &AssertionMap,
1057    state: &State,
1058    inputs: &InputManifest,
1059) -> String {
1060    expected_basis_with(a, f, state, inputs, &Ledger::new(map, state, inputs))
1061}
1062/// The token with the run-wide facts already in hand; what every caller with
1063/// more than one flow to judge should use.
1064pub fn expected_basis_with(
1065    a: &Assertion,
1066    f: &Flow,
1067    state: &State,
1068    inputs: &InputManifest,
1069    ledger: &Ledger<'_>,
1070) -> String {
1071    token(&(
1072        "supercov-flow-v3",
1073        &inputs.context_digest,
1074        &a.id,
1075        site(&a.at, inputs),
1076        &a.observes,
1077        claim(f, inputs),
1078        footing(a, f, inputs),
1079        generation(a, f, state, ledger),
1080    ))
1081}
1082pub fn reasons(
1083    a: &Assertion,
1084    f: &Flow,
1085    map: &AssertionMap,
1086    state: &State,
1087    inputs: &Inputs,
1088) -> BTreeSet<String> {
1089    reasons_for_manifest(a, f, map, state, inputs, &inputs.manifest())
1090}
1091pub fn reasons_for_manifest(
1092    a: &Assertion,
1093    f: &Flow,
1094    map: &AssertionMap,
1095    state: &State,
1096    inputs: &Inputs,
1097    manifest: &InputManifest,
1098) -> BTreeSet<String> {
1099    reasons_with(
1100        a,
1101        f,
1102        state,
1103        inputs,
1104        manifest,
1105        &Ledger::new(map, state, manifest),
1106    )
1107}
1108pub fn reasons_with(
1109    a: &Assertion,
1110    f: &Flow,
1111    state: &State,
1112    inputs: &Inputs,
1113    manifest: &InputManifest,
1114    ledger: &Ledger<'_>,
1115) -> BTreeSet<String> {
1116    let mut reasons = BTreeSet::new();
1117    if state.schema_version != 3 || state.inputs_digest != ledger.inputs_digest {
1118        reasons.insert("state does not match run inputs".into());
1119    }
1120    if f.basis.as_deref() != Some(expected_basis_with(a, f, state, manifest, ledger).as_str()) {
1121        let recorded = state.flows.get(&flow_key(a, f));
1122        match f.basis.as_deref() {
1123            None => {
1124                reasons.insert("draft: input acknowledgement not recorded".into());
1125            }
1126            Some(basis) if superseded_basis(basis) => {
1127                reasons.insert(SUPERSEDED_BASIS.into());
1128            }
1129            Some(_) => {
1130                // Say which part moved. One hash over everything can only
1131                // report that something did, which gave an author who deleted
1132                // a watch entry the same sentence as one who rewrote a claim.
1133                let parts = recorded
1134                    .map(|s| moved(&s.footprint, &footprint(a, f, manifest)))
1135                    .unwrap_or_default();
1136                if parts.is_empty() {
1137                    reasons.insert("claim or inputs changed; needs rechecking".into());
1138                } else {
1139                    reasons.extend(parts);
1140                }
1141            }
1142        }
1143        if let Some(s) = recorded {
1144            reasons.extend(s.reasons.iter().cloned());
1145        }
1146    }
1147    reasons.extend(validate_flow(f, &inputs.files));
1148    if a.at.offset(&inputs.files).is_none() {
1149        reasons.insert("invalid assertion anchor".into());
1150    }
1151    if !f.questions.is_empty() {
1152        reasons.insert("flow has unresolved questions".into());
1153    }
1154    reasons
1155}
1156/// Read-only validation. Tokens acknowledge authored claims, never prove them.
1157pub fn validation(map: &AssertionMap, state: &State, inputs: &Inputs) -> serde_json::Value {
1158    use serde_json::json;
1159    let manifest = inputs.manifest();
1160    let ledger = Ledger::new(map, state, &manifest);
1161    let mut errors = validate(map, inputs);
1162    for r in &map.change_assessments {
1163        if !state.changes.iter().any(|c| c.id == r.id) {
1164            errors.push(format!("{}: unknown change assessment", r.id));
1165        }
1166    }
1167    // Exposure is kept per flow but read per test: hundreds of flow keys say
1168    // less than the dozen tests they apply to, and cost more to page.
1169    let selectors = map
1170        .assertions
1171        .iter()
1172        .flat_map(|a| a.flows.iter().map(move |f| (flow_key(a, f), &f.applies_to)))
1173        .collect::<BTreeMap<_, _>>();
1174    let changes = state.changes.iter().map(|c| {
1175        let response = map.change_assessments.iter().find(|r| r.id == c.id);
1176        let faults = response.map(|r| ledger.change_errors(r)).unwrap_or_default();
1177        errors.extend(faults.iter().map(|e| format!("{}: {e}", c.id)));
1178        // Every list a change carries is bounded, or one item outgrows any
1179        // page and cannot be fetched at all -- B17. `knownFlows` was the first
1180        // to do it; `exposed` inherited the defect the moment it was added,
1181        // because its test list grows with the suite, not with the change.
1182        let tests = c.exposed.iter().filter_map(|k| selectors.get(k)).flat_map(|t| t.iter()).collect::<BTreeSet<_>>();
1183        let shown = tests.iter().take(20).collect::<Vec<_>>();
1184        json!({"id":c.id,"file":c.file,"before":c.before,"after":c.after,"reason":c.reason,
1185            "knownFlows":{"flows":c.known_flows.len(),"sample":c.known_flows.iter().take(8).collect::<Vec<_>>()},
1186            "exposed":{"flows":c.exposed.len(),"tests":shown,"testCount":tests.len(),"sample":c.exposed.iter().take(8).collect::<Vec<_>>()},
1187            "current":ledger.current(&c.id),"assessment":response,"errors":faults,
1188            "expectedBasis":response.map(|r| ledger.expected_change_basis(c,r))})
1189    }).collect::<Vec<_>>();
1190    let flows = map.assertions.iter().flat_map(|a| a.flows.iter().map(move |f| (a,f))).map(|(a,f)| {
1191        json!({"id":flow_key(a,f),"expectedBasis":expected_basis_with(a,f,state,&manifest,&ledger),"reasons":reasons_with(a,f,state,inputs,&manifest,&ledger)})
1192    }).collect::<Vec<_>>();
1193    json!({"valid":errors.is_empty(),"stage":"references","errors":errors,"flows":flows,"changes":changes,
1194        "meaning":"Authored graph references and input acknowledgements only; no semantic proof or completeness claim"})
1195}
1196pub fn invalidate(state: &mut State, map: &AssertionMap, reason: &str) {
1197    for a in &map.assertions {
1198        for f in &a.flows {
1199            let key = flow_key(a, f);
1200            let previous = state.flows.get(&key);
1201            let base = previous.map_or("0", |s| s.generation.as_str());
1202            // Whole-map invalidation says nothing about the parts, so keep the
1203            // record rather than erasing what it knew.
1204            let footprint = previous.map(|s| s.footprint.clone()).unwrap_or_default();
1205            let generation = digest(&(base, reason, &state.inputs_digest));
1206            state.flows.insert(
1207                key,
1208                FlowState {
1209                    generation,
1210                    reasons: BTreeSet::from([reason.into()]),
1211                    notices: BTreeSet::new(),
1212                    footprint,
1213                },
1214            );
1215        }
1216    }
1217}
1218pub fn add_change(
1219    state: &mut State,
1220    file: Option<String>,
1221    before: Option<String>,
1222    after: Option<String>,
1223    reason: String,
1224    known_flows: BTreeSet<String>,
1225    exposed: BTreeSet<String>,
1226) {
1227    // Include pending history so edit/revert/edit cannot alias a still-pending event.
1228    let id = format!(
1229        "c_{}",
1230        &digest(&(
1231            "supercov-change-id-v2",
1232            &state.changes,
1233            &file,
1234            &before,
1235            &after,
1236            &reason
1237        ))[..24]
1238    );
1239    state.changes.push(Change {
1240        id,
1241        file,
1242        before,
1243        after,
1244        reason,
1245        known_flows,
1246        exposed,
1247    });
1248}
1249
1250fn unique_occurrence(text: &str, snippet: &str) -> Option<usize> {
1251    if snippet.is_empty() {
1252        return None;
1253    }
1254    let first = text.find(snippet)?;
1255    // Include overlapping occurrences; match_indices skips them.
1256    let next = first + text[first..].chars().next()?.len_utf8();
1257    text[next..]
1258        .contains(snippet)
1259        .then_some(())
1260        .map_or(Some(first), |_| None)
1261}
1262fn target_file(file: &str, old: &FileManifest, new: &Files) -> Option<String> {
1263    if new.contains_key(file) {
1264        return Some(file.into());
1265    }
1266    let hash = old.get(file)?;
1267    let mut matches = new
1268        .iter()
1269        .filter(|(_, s)| FileFingerprint::of(s).same_bytes(hash));
1270    let first = matches.next()?.0;
1271    matches.next().is_none().then(|| first.clone())
1272}
1273pub fn relocate(at: &Anchor, old: &FileManifest, new: &Files) -> Option<Anchor> {
1274    let before = old.get(&at.file)?;
1275    let target = target_file(&at.file, old, new)?;
1276    let after = &new[&target];
1277    let mut candidate = at.clone();
1278    candidate.file.clone_from(&target);
1279    if FileFingerprint::of(after).same_bytes(before) && candidate.offset(new).is_some() {
1280        return Some(candidate);
1281    }
1282    // The file changed somewhere. That says nothing about this anchor: read the
1283    // recorded position in the new file and see whether it still holds the same
1284    // text. If it does, the anchor did not move and there is nothing to find.
1285    //
1286    // Without this, every anchor in a changed file is re-found by searching the
1287    // whole file, and that search insists the text be unique -- so a statement
1288    // that appears twice is reported "changed or ambiguous" while sitting
1289    // untouched at the line it was recorded at. That is a false statement about
1290    // a specific node, and it is most of the staleness in a real map.
1291    if let Some(start) = candidate.offset(new)
1292        && after.get(start..start + at.text.len()) == Some(at.text.as_str())
1293    {
1294        return Some(candidate);
1295    }
1296    let position = unique_occurrence(after, &at.text)?;
1297    Some(Anchor::new(
1298        &target,
1299        after,
1300        position,
1301        position + at.text.len(),
1302    ))
1303}
1304
1305/// How one captured file moved between two runs, judged once and read for
1306/// every flow.
1307pub enum FileChange<'a> {
1308    Same,
1309    /// Only comments changed: no program can tell.
1310    CommentsOnly,
1311    /// Not among the previous run's inputs.
1312    Added,
1313    Removed,
1314    /// No parser reads the file on one side or the other; its bytes moved.
1315    Bytes,
1316    Code {
1317        before: &'a Code,
1318        after: &'a Code,
1319        diff: Diff,
1320        /// The change is confined to declaration bodies that only run: it
1321        /// reaches a test only if the test ran one of them.
1322        narrow: bool,
1323    },
1324}
1325pub fn file_change<'a>(
1326    before: Option<&'a FileFingerprint>,
1327    after: Option<&'a FileFingerprint>,
1328    probed: Option<&[usize]>,
1329) -> Option<FileChange<'a>> {
1330    let Some(before) = before else {
1331        return after.map(|_| FileChange::Added);
1332    };
1333    let Some(after) = after else {
1334        return Some(FileChange::Removed);
1335    };
1336    if before.same_bytes(after) {
1337        return Some(FileChange::Same);
1338    }
1339    let (Some(old), Some(new)) = (&before.code, &after.code) else {
1340        return Some(FileChange::Bytes);
1341    };
1342    if old.semantic == new.semantic {
1343        return Some(FileChange::CommentsOnly);
1344    }
1345    let diff = old.diff(new);
1346    let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
1347    Some(FileChange::Code {
1348        before: old,
1349        after: new,
1350        diff,
1351        narrow,
1352    })
1353}
1354/// Every unit that moved, by name: what changed, what arrived, what went.
1355pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
1356    let mut parts = Vec::new();
1357    if !diff.changed.is_empty() {
1358        parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
1359    }
1360    if !diff.added.is_empty() {
1361        parts.push(format!(
1362            "added {}",
1363            named(diff.added.iter().map(|i| &after.units[*i]))
1364        ));
1365    }
1366    if !diff.removed.is_empty() {
1367        parts.push(format!(
1368            "removed {}",
1369            named(diff.removed.iter().map(|i| &before.units[*i]))
1370        ));
1371    }
1372    if parts.is_empty() {
1373        "declarations".to_owned()
1374    } else {
1375        parts.join("; ")
1376    }
1377}
1378/// The units a flow's tests executed, per file, each with what it sits
1379/// inside; `None` when a selected test has no execution record in this state,
1380/// in which case nothing about execution can be assumed.
1381fn executed<'s>(
1382    records: &BTreeMap<&TestSelector, &'s Execution>,
1383    f: &Flow,
1384    manifest: &InputManifest,
1385) -> Option<BTreeMap<&'s str, BTreeSet<usize>>> {
1386    let mut out: BTreeMap<&str, BTreeSet<usize>> = BTreeMap::new();
1387    for selector in &f.applies_to {
1388        let record = records.get(selector)?;
1389        for (file, units) in &record.files {
1390            let code = manifest.files.get(file).and_then(|fp| fp.code.as_ref());
1391            let set = out.entry(file.as_str()).or_default();
1392            for &unit in units {
1393                match code {
1394                    Some(code) if unit < code.units.len() => set.extend(code.ancestors(unit)),
1395                    _ => {
1396                        set.insert(unit);
1397                    }
1398                }
1399            }
1400        }
1401    }
1402    Some(out)
1403}
1404
1405/// Carries explanations, never execution events. Uncertain matches are retained
1406/// as retired suggestions; no nearest-line heuristic assigns semantic meaning.
1407///
1408/// A flow goes stale for a change to what its claim rests on and for nothing
1409/// else: the declarations holding its nodes and the top level of their files,
1410/// the test it applies to, a file it watches, its assertion, the run's
1411/// context. A change elsewhere in a node's file is a notice. A change to
1412/// comments or blank lines is nothing.
1413///
1414/// What each flow's test executed does not make the flow stale -- a claim
1415/// does not pass through every function its test happened to run, and an
1416/// acknowledgement demanded for all of them at once stops being read. It goes
1417/// on the change record instead: a changed file names the flows whose tests
1418/// ran the changed code, so the one assessment the change asks for is asked
1419/// of the right people, and a change nobody ran asks for none.
1420pub fn carry(
1421    map: &AssertionMap,
1422    state: &State,
1423    old: &InputManifest,
1424    new: &Inputs,
1425    evidence_digest: &str,
1426    context_changed: bool,
1427) -> Result<(AssertionMap, State), String> {
1428    if map.schema_version != 2 || old.schema_version != 2 || new.schema_version != 1 {
1429        return Err("unsupported map/input schema version".into());
1430    }
1431    if state.inputs_digest != digest(old) || state.schema_version != 3 {
1432        return Err("old map state does not match its run inputs".into());
1433    }
1434    let ledger = Ledger::new(map, state, old);
1435    let new_manifest = new.manifest();
1436    let (mut next, mut next_state) = seed_manifest(&new_manifest, evidence_digest);
1437    next.assertions.clear();
1438    next.retired_assertions = map.retired_assertions.clone();
1439    next_state.changes = state
1440        .changes
1441        .iter()
1442        .filter(|c| !ledger.current(&c.id))
1443        .cloned()
1444        .collect();
1445    next.change_assessments = map
1446        .change_assessments
1447        .iter()
1448        .filter(|r| next_state.changes.iter().any(|c| c.id == r.id))
1449        .cloned()
1450        .collect();
1451    let records = state
1452        .executions
1453        .iter()
1454        .flat_map(|e| e.tests.iter().map(|t| (&t.test, t)))
1455        .collect::<BTreeMap<_, _>>();
1456    let probed = |file: &str| {
1457        state
1458            .executions
1459            .as_ref()
1460            .and_then(|e| e.probed.get(file))
1461            .map(Vec::as_slice)
1462    };
1463    let changes = old
1464        .files
1465        .keys()
1466        .chain(new_manifest.files.keys())
1467        .collect::<BTreeSet<_>>()
1468        .into_iter()
1469        .filter_map(|file| {
1470            file_change(
1471                old.files.get(file),
1472                new_manifest.files.get(file),
1473                probed(file),
1474            )
1475            .map(|change| (file.as_str(), change))
1476        })
1477        .collect::<BTreeMap<_, _>>();
1478    // Per changed file: the flows it made stale, and the flows whose tests ran
1479    // the changed code or have no record to say -- what the change record
1480    // names as known and as exposed.
1481    let mut marked: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1482    let mut exposed: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1483    let mut consumed = BTreeSet::new();
1484    let exact = map
1485        .assertions
1486        .iter()
1487        .map(|a| {
1488            relocate(&a.at, &old.files, &new.files).filter(|at| {
1489                new.assertions.iter().any(|s| &s.at == at)
1490                    || !old.assertions.iter().any(|s| s.at == a.at)
1491            })
1492        })
1493        .collect::<Vec<_>>();
1494    let reserved = exact.iter().flatten().collect::<BTreeSet<_>>();
1495    for (index, a) in map.assertions.iter().enumerate() {
1496        // A sole old/new unmatched site in the same file is a review
1497        // suggestion. Preserve its explanation but never its reviewed status.
1498        let candidates = new
1499            .assertions
1500            .iter()
1501            .filter(|s| s.at.file == a.at.file && !reserved.contains(&s.at))
1502            .collect::<Vec<_>>();
1503        let unmatched = map
1504            .assertions
1505            .iter()
1506            .zip(&exact)
1507            .filter(|(other, at)| other.at.file == a.at.file && at.is_none())
1508            .count();
1509        let replacement = if exact[index].is_none() && unmatched == 1 && candidates.len() == 1 {
1510            Some(&candidates[0].at)
1511        } else {
1512            None
1513        };
1514        let matched = exact[index]
1515            .as_ref()
1516            .or(replacement)
1517            .filter(|at| !consumed.contains(*at));
1518        let Some(at) = matched else {
1519            next.retired_assertions.push(Retired {
1520                assertion: a.clone(),
1521                reason:
1522                    "assertion removed, changed or ambiguous; reuse its explanation after review"
1523                        .into(),
1524            });
1525            continue;
1526        };
1527        consumed.insert(at.clone());
1528        let mut updated = a.clone();
1529        updated.at = at.clone();
1530        for (prior, f) in a.flows.iter().zip(&mut updated.flows) {
1531            let key = flow_key(a, f);
1532            let base = generation(a, prior, state, &ledger);
1533            let mut dirty = BTreeSet::new();
1534            let mut notices = BTreeSet::new();
1535            match prior.basis.as_deref() {
1536                Some(basis) if superseded_basis(basis) => {
1537                    dirty.insert(SUPERSEDED_BASIS.into());
1538                }
1539                Some(basis) if basis != expected_basis_with(a, prior, state, old, &ledger) => {
1540                    dirty.insert("inherited claim still needs rechecking".into());
1541                }
1542                _ => {}
1543            }
1544            // What the flow's test ran, for the change record: a changed file
1545            // is assessed by whoever ran the change, and a change nobody ran
1546            // is not assessed at all.
1547            match executed(&records, prior, old) {
1548                Some(ran) => {
1549                    for (file, units) in &ran {
1550                        let reached = match changes.get(file) {
1551                            None
1552                            | Some(
1553                                FileChange::Same | FileChange::CommentsOnly | FileChange::Added,
1554                            ) => false,
1555                            Some(FileChange::Removed | FileChange::Bytes) => true,
1556                            Some(FileChange::Code { diff, narrow, .. }) => {
1557                                !*narrow || diff.changed.iter().any(|i| units.contains(i))
1558                            }
1559                        };
1560                        if reached {
1561                            exposed.entry(file).or_default().insert(key.clone());
1562                        }
1563                    }
1564                }
1565                None => {
1566                    for (file, change) in &changes {
1567                        if !matches!(
1568                            change,
1569                            FileChange::Same | FileChange::CommentsOnly | FileChange::Added
1570                        ) {
1571                            exposed.entry(file).or_default().insert(key.clone());
1572                        }
1573                    }
1574                }
1575            }
1576            // What the flow names: its test, its watch list and its assertion's
1577            // file as a whole; the file of a node for the declarations that
1578            // hold the node, its top level and its set of declarations.
1579            for file in dependencies(a, prior) {
1580                let roles = roles(a, prior, file);
1581                let verdict = match changes.get(file) {
1582                    None => Some(format!(
1583                        "{file} is not among the run's inputs{}",
1584                        in_role(&roles)
1585                    )),
1586                    Some(FileChange::Added) => Some(format!(
1587                        "{file} is new since the previous run{}",
1588                        in_role(&roles)
1589                    )),
1590                    Some(FileChange::Same | FileChange::CommentsOnly) => None,
1591                    Some(FileChange::Removed) => Some(format!("{file} removed{}", in_role(&roles))),
1592                    Some(FileChange::Bytes) => Some(format!("{file} changed{}", in_role(&roles))),
1593                    Some(FileChange::Code {
1594                        before,
1595                        after,
1596                        diff,
1597                        ..
1598                    }) => {
1599                        if whole_file(a, prior, file) {
1600                            // The file's roles describe the file. Attached to
1601                            // the declaration that changed they say something
1602                            // false: in `other (line 4) changed (holds this
1603                            // flow's n:2)`, `work` holds n:2 and `other` is its
1604                            // neighbour. What makes this change count is the
1605                            // dependency on the whole file; where the nodes sit
1606                            // is context, and is said as context.
1607                            let mut why = whole_file_roles(a, prior, file);
1608                            let sites = node_sites(prior, file);
1609                            if !sites.is_empty() {
1610                                let holders = prior
1611                                    .nodes
1612                                    .iter()
1613                                    .filter(|n| n.at.file == file)
1614                                    .map(|n| before.unit_at(n.at.line, n.at.column))
1615                                    .collect::<BTreeSet<_>>();
1616                                why.push(format!(
1617                                    "this flow's {} sits in {}",
1618                                    sites.join(", "),
1619                                    named(holders.iter().map(|i| &before.units[*i]))
1620                                ));
1621                            }
1622                            Some(format!(
1623                                "{file}: {} changed{}",
1624                                describe(before, after, diff),
1625                                in_role(&why)
1626                            ))
1627                        } else {
1628                            let holders = prior
1629                                .nodes
1630                                .iter()
1631                                .filter(|n| n.at.file == file)
1632                                .flat_map(|n| {
1633                                    before.ancestors(before.unit_at(n.at.line, n.at.column))
1634                                })
1635                                .collect::<BTreeSet<_>>();
1636                            let moved = holders
1637                                .iter()
1638                                .filter(|i| diff.changed.contains(i) || diff.removed.contains(i))
1639                                .map(|i| &before.units[*i])
1640                                .collect::<Vec<_>>();
1641                            if !moved.is_empty() {
1642                                Some(format!(
1643                                    "{file}: {} changed{}",
1644                                    named(moved),
1645                                    in_role(&roles)
1646                                ))
1647                            } else if diff.structural {
1648                                Some(format!(
1649                                    "{file}: declarations changed, {}{}",
1650                                    describe(before, after, diff),
1651                                    in_role(&roles)
1652                                ))
1653                            } else {
1654                                notices.insert(format!(
1655                                    "{file} changed outside this flow's nodes: {}",
1656                                    describe(before, after, diff)
1657                                ));
1658                                None
1659                            }
1660                        }
1661                    }
1662                };
1663                if let Some(reason) = verdict {
1664                    dirty.insert(reason);
1665                    marked.entry(file).or_default().insert(key.clone());
1666                }
1667            }
1668            if replacement.is_some() {
1669                dirty.insert("assertion changed or replaced; confirm identity and meaning".into());
1670            }
1671            for node in &mut f.nodes {
1672                if let Some(at) = relocate(&node.at, &old.files, &new.files) {
1673                    node.at = at;
1674                } else {
1675                    dirty.insert(format!("node {} changed or ambiguous", node.id));
1676                }
1677            }
1678            for file in f
1679                .watch
1680                .iter_mut()
1681                .chain(f.applies_to.iter_mut().map(|t| &mut t.file))
1682            {
1683                if let Some(target) = target_file(file, &old.files, &new.files) {
1684                    *file = target;
1685                } else {
1686                    dirty.insert(format!("dependency file removed: {file}"));
1687                }
1688            }
1689            if context_changed {
1690                dirty.insert("run configuration, dependencies or execution context changed".into());
1691            }
1692            next_state.flows.insert(
1693                key,
1694                FlowState {
1695                    generation: if dirty.is_empty() {
1696                        base
1697                    } else {
1698                        digest(&("supercov-carry-v3", base, &new_manifest, &dirty))
1699                    },
1700                    reasons: dirty,
1701                    notices,
1702                    footprint: footprint(a, f, &new_manifest),
1703                },
1704            );
1705        }
1706        next.assertions.push(updated);
1707    }
1708    let mut ids = map
1709        .assertions
1710        .iter()
1711        .map(|a| a.id.clone())
1712        .chain(
1713            map.retired_assertions
1714                .iter()
1715                .map(|r| r.assertion.id.clone()),
1716        )
1717        .collect::<BTreeSet<_>>();
1718    for a in seed(new, evidence_digest).0.assertions {
1719        if !consumed.contains(&a.at) {
1720            let mut a = a;
1721            while !ids.insert(a.id.clone()) {
1722                a.id.push('_');
1723            }
1724            next.assertions.push(a);
1725        }
1726    }
1727    for file in old
1728        .files
1729        .keys()
1730        .chain(new_manifest.files.keys())
1731        .collect::<BTreeSet<_>>()
1732    {
1733        // A manifest is answered for by the run's dependency fingerprint, which
1734        // reads what it declares. Reporting its bytes here as well would make
1735        // cutting a release look like a change to assess when nothing about the
1736        // project moved.
1737        if crate::integrity::tracked_manifest(file) {
1738            continue;
1739        }
1740        let exposed_to = exposed.get(file.as_str()).cloned().unwrap_or_default();
1741        match changes.get(file.as_str()) {
1742            // A comment is not a change to assess.
1743            Some(FileChange::Same | FileChange::CommentsOnly) => continue,
1744            // A change confined to code that only runs, which no selected test
1745            // ran, cannot have reached any claim; the flow claiming that code
1746            // is already stale for it. Nothing to ask.
1747            Some(FileChange::Code { narrow: true, .. }) if exposed_to.is_empty() => continue,
1748            _ => {}
1749        }
1750        add_change(
1751            &mut next_state,
1752            Some(file.clone()),
1753            old.files.get(file).map(|f| f.sha256.clone()),
1754            new_manifest.files.get(file).map(|f| f.sha256.clone()),
1755            "captured source file changed".into(),
1756            marked.get(file.as_str()).cloned().unwrap_or_default(),
1757            exposed_to,
1758        );
1759    }
1760    next.assertions.sort_by(|a, b| a.at.cmp(&b.at));
1761    Ok((next, next_state))
1762}
1763
1764#[path = "assertion_legacy.rs"]
1765mod legacy;
1766pub fn parse_stored(bytes: &[u8]) -> Result<AssertionMap, String> {
1767    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1768    match value
1769        .get("schemaVersion")
1770        .and_then(serde_json::Value::as_u64)
1771    {
1772        None | Some(1) => legacy::import(bytes),
1773        _ => parse(bytes).map_err(|e| e.to_string()),
1774    }
1775}
1776pub fn parse_state(
1777    bytes: &[u8],
1778    map: &AssertionMap,
1779    inputs: &InputManifest,
1780    evidence: &str,
1781    legacy_digest: Option<&str>,
1782) -> Result<State, String> {
1783    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1784    if value["schemaVersion"] == 3 {
1785        let state: State = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1786        if state.inputs_digest != digest(inputs) || state.evidence_digest != evidence {
1787            return Err("Assertion state belongs to different run evidence; rerun tests".into());
1788        }
1789        Ok(state)
1790    } else {
1791        legacy::state(bytes, map, inputs, evidence, legacy_digest)
1792    }
1793}