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