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        let source = files.get(&self.file)?;
48        self.offset_in(source, &line_starts(source))
49    }
50
51    /// `offset`, given where each line of the anchor's file starts. A caller
52    /// locating many anchors computes those starts once per file: scanning
53    /// from the top of the file for every anchor made an assessment quadratic
54    /// in file length, and a fifth of `runs latest` on a 300-file run was
55    /// spent here.
56    pub fn offset_in(&self, source: &str, starts: &[usize]) -> Option<usize> {
57        locate(
58            &self.file,
59            self.line,
60            self.column,
61            &self.text,
62            source,
63            starts,
64        )
65    }
66}
67
68/// Where an anchor with these parts starts in `source`, without building one.
69/// Counting the statements that cannot be located needs only this answer, and
70/// building an anchor clones the source text of the node around the statement
71/// -- once per statement, which is the allocation a summary exists to avoid.
72pub fn locate(
73    file: &str,
74    line: usize,
75    column: usize,
76    text: &str,
77    source: &str,
78    starts: &[usize],
79) -> Option<usize> {
80    if !local_path(file) || text.is_empty() || line == 0 || column == 0 {
81        return None;
82    }
83    // A line the file does not reach has no start; this is the check the scan
84    // used to make by counting the line feeds before it.
85    let start = *starts.get(line - 1)?;
86    let row = source.get(start..)?.split('\n').next()?;
87    if column - 1 > row.len() {
88        return None;
89    }
90    let pos = start.checked_add(column - 1)?;
91    source.get(pos..)?.starts_with(text).then_some(pos)
92}
93
94/// The byte offset at which each line of `source` starts: 0, then one past
95/// every line feed.
96pub fn line_starts(source: &str) -> Vec<usize> {
97    std::iter::once(0)
98        .chain(source.match_indices('\n').map(|(at, _)| at + 1))
99        .collect()
100}
101
102/// The text of 1-based `line`, exactly as `str::lines` would yield it: without
103/// its line ending, with a carriage return stripped only where a line feed
104/// follows it, and with no empty line after a final line feed.
105pub fn line_text<'s>(source: &'s str, starts: &[usize], line: usize) -> Option<&'s str> {
106    let start = *starts.get(line.checked_sub(1)?)?;
107    if start >= source.len() {
108        return None;
109    }
110    match starts.get(line) {
111        Some(next) => {
112            let text = &source[start..next - 1];
113            Some(text.strip_suffix('\r').unwrap_or(text))
114        }
115        None => Some(&source[start..]),
116    }
117}
118
119/// Line starts for every file of a source set, computed once.
120pub struct LineIndex<'a> {
121    starts: BTreeMap<&'a str, (&'a str, Vec<usize>)>,
122}
123
124impl<'a> LineIndex<'a> {
125    pub fn new(files: &'a Files) -> Self {
126        Self {
127            starts: files
128                .iter()
129                .map(|(file, source)| (file.as_str(), (source.as_str(), line_starts(source))))
130                .collect(),
131        }
132    }
133
134    /// A file's source and the start of each of its lines.
135    pub fn get(&self, file: &str) -> Option<(&'a str, &[usize])> {
136        self.starts
137            .get(file)
138            .map(|(source, starts)| (*source, starts.as_slice()))
139    }
140
141    /// The same answer as `Anchor::offset`, without rescanning the file.
142    pub fn offset(&self, anchor: &Anchor) -> Option<usize> {
143        let (source, starts) = self.get(&anchor.file)?;
144        anchor.offset_in(source, starts)
145    }
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct InventorySite {
151    pub at: Anchor,
152    pub operation: String,
153}
154
155/// Source text held in memory for capture or a verified current-checkout query.
156/// The serialized form is retained only for reading legacy source archives.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
158#[serde(rename_all = "camelCase", deny_unknown_fields)]
159pub struct Inputs {
160    #[serde(default = "version")]
161    pub schema_version: u32,
162    pub language: String,
163    pub context_digest: String,
164    pub files: Files,
165    pub assertions: Vec<InventorySite>,
166    pub limitations: Vec<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct FileFingerprint {
172    pub sha256: String,
173    pub bytes: usize,
174    /// The parser's view of the file: what it declares, each declaration
175    /// digested with comments blanked. Absent for a file no parser reads and
176    /// in manifests written before this existed; such a file is compared by
177    /// its bytes, as every file once was.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub code: Option<Code>,
180}
181impl FileFingerprint {
182    pub fn of(source: &str) -> Self {
183        Self {
184            sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
185            bytes: source.len(),
186            code: None,
187        }
188    }
189    /// Bytes and, where Supercov has a parser for the file, its declarations.
190    pub fn read(path: &str, source: &str) -> Self {
191        let mut fingerprint = Self::of(source);
192        fingerprint.code = crate::source_units::code(path, source);
193        fingerprint
194    }
195    pub fn same_bytes(&self, other: &Self) -> bool {
196        self.sha256 == other.sha256
197    }
198}
199pub type FileManifest = BTreeMap<String, FileFingerprint>;
200
201/// The run stores identities and hashes, never complete source files.
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase", deny_unknown_fields)]
204pub struct InputManifest {
205    pub schema_version: u32,
206    pub language: String,
207    pub context_digest: String,
208    pub files: FileManifest,
209    pub assertions: Vec<InventorySite>,
210    pub limitations: Vec<String>,
211}
212impl Inputs {
213    pub fn manifest(&self) -> InputManifest {
214        InputManifest {
215            schema_version: 2,
216            language: self.language.clone(),
217            context_digest: self.context_digest.clone(),
218            files: self
219                .files
220                .iter()
221                .map(|(p, s)| (p.clone(), FileFingerprint::read(p, s)))
222                .collect(),
223            assertions: self.assertions.clone(),
224            limitations: self.limitations.clone(),
225        }
226    }
227    pub fn identity(&self) -> String {
228        digest(&self.manifest())
229    }
230}
231impl InputManifest {
232    pub fn with_sources(&self, files: Files) -> Inputs {
233        Inputs {
234            schema_version: 1,
235            language: self.language.clone(),
236            context_digest: self.context_digest.clone(),
237            files,
238            assertions: self.assertions.clone(),
239            limitations: self.limitations.clone(),
240        }
241    }
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
245#[serde(rename_all = "camelCase", deny_unknown_fields)]
246pub struct Node {
247    pub id: String,
248    pub at: Anchor,
249    #[serde(default, skip_serializing_if = "String::is_empty")]
250    pub role: String,
251    #[serde(default, skip_serializing_if = "String::is_empty")]
252    pub meaning: String,
253}
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
255#[serde(rename_all = "camelCase", deny_unknown_fields)]
256pub struct Edge {
257    pub from: String,
258    pub to: String,
259    pub kind: String,
260    #[serde(default, skip_serializing_if = "String::is_empty")]
261    pub basis: String,
262}
263#[derive(
264    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
265)]
266#[serde(rename_all = "camelCase", deny_unknown_fields)]
267pub struct TestSelector {
268    pub file: String,
269    pub name: String,
270}
271
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
273#[serde(rename_all = "camelCase", deny_unknown_fields)]
274pub struct Flow {
275    pub id: String,
276    #[serde(deserialize_with = "required_basis")]
277    #[schemars(required, schema_with = "basis_schema")]
278    pub basis: Option<String>,
279    pub explanation: String,
280    pub applies_to: Vec<TestSelector>,
281    pub nodes: Vec<Node>,
282    #[serde(default)]
283    pub edges: Vec<Edge>,
284    pub counts_as_asserted: Vec<String>,
285    pub watch: Vec<String>,
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub questions: Vec<String>,
288}
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
290#[serde(rename_all = "camelCase", deny_unknown_fields)]
291pub struct Assertion {
292    pub id: String,
293    pub at: Anchor,
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub questions: Vec<String>,
296    #[serde(default)]
297    pub observes: Vec<String>,
298    #[serde(default)]
299    pub flows: Vec<Flow>,
300}
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
302#[serde(rename_all = "camelCase", deny_unknown_fields)]
303pub struct Retired {
304    pub assertion: Assertion,
305    pub reason: String,
306}
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
308#[serde(rename_all = "camelCase", deny_unknown_fields)]
309pub struct AssertionMap {
310    #[schemars(range(min = 2, max = 2))]
311    pub schema_version: u32,
312    pub assertions: Vec<Assertion>,
313    #[serde(default, skip_serializing_if = "Vec::is_empty")]
314    pub change_assessments: Vec<ChangeAssessment>,
315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
316    pub retired_assertions: Vec<Retired>,
317}
318
319// Missing basis is a syntax error; null explicitly means unfinished work.
320fn required_basis<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
321    let value = Option::<String>::deserialize(d)?;
322    if value.as_deref().is_some_and(|s| !valid_basis(s)) {
323        return Err(serde::de::Error::custom(
324            "expected null or scov3:<64 lowercase hex digits>",
325        ));
326    }
327    Ok(value)
328}
329fn basis_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
330    schemars::json_schema!({"type":["string","null"],"pattern":"^scov[23]:[0-9a-f]{64}$"})
331}
332fn valid_basis(s: &str) -> bool {
333    s.strip_prefix("scov3:")
334        .or_else(|| s.strip_prefix("scov2:"))
335        .is_some_and(|h| {
336            h.len() == 64
337                && h.bytes()
338                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
339        })
340}
341/// A token from a release whose basis pinned files rather than the code a
342/// claim rests on. It still parses, so the map stays valid; it can no longer
343/// match, so the claim reads as needing acknowledgement, with this as its
344/// reason rather than a change that never happened.
345pub fn superseded_basis(s: &str) -> bool {
346    s.starts_with("scov2:")
347}
348pub const SUPERSEDED_BASIS: &str = "acknowledged under an earlier Supercov basis format; reread the claim and copy the current expectedBasis";
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
350#[serde(rename_all = "camelCase", deny_unknown_fields)]
351pub struct ChangeAssessment {
352    pub id: String,
353    #[serde(deserialize_with = "required_basis")]
354    #[schemars(required, schema_with = "basis_schema")]
355    pub basis: Option<String>,
356    pub affected_flows: Vec<String>,
357    pub explanation: String,
358}
359
360/// Editor schema generated from the same Rust types used by every map command.
361/// Source existence, links, freshness and semantic meaning are outside JSON Schema.
362pub fn schema() -> serde_json::Value {
363    serde_json::to_value(schemars::schema_for!(AssertionMap)).expect("schema")
364}
365
366#[derive(Debug, Serialize)]
367#[serde(rename_all = "camelCase")]
368pub struct ParseError {
369    pub pointer: String,
370    pub line: usize,
371    pub column: usize,
372    pub message: String,
373}
374impl std::fmt::Display for ParseError {
375    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376        write!(
377            f,
378            "{} at {} (JSON line {}, column {})",
379            self.message, self.pointer, self.line, self.column
380        )
381    }
382}
383pub fn parse(bytes: &[u8]) -> Result<AssertionMap, ParseError> {
384    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
385    let map: AssertionMap = serde_path_to_error::deserialize(&mut deserializer).map_err(|e| {
386        let pointer = e
387            .path()
388            .iter()
389            .map(|segment| {
390                use serde_path_to_error::Segment;
391                let part = match segment {
392                    Segment::Seq { index } => index.to_string(),
393                    Segment::Map { key } => key.clone(),
394                    Segment::Enum { variant } => variant.clone(),
395                    Segment::Unknown => "?".into(),
396                };
397                format!("/{}", part.replace('~', "~0").replace('/', "~1"))
398            })
399            .collect();
400        ParseError {
401            pointer,
402            line: e.inner().line(),
403            column: e.inner().column(),
404            message: e.inner().to_string(),
405        }
406    })?;
407    deserializer.end().map_err(|e| ParseError {
408        pointer: String::new(),
409        line: e.line(),
410        column: e.column(),
411        message: e.to_string(),
412    })?;
413    if map.schema_version != 2 {
414        return Err(ParseError {
415            pointer: "/schemaVersion".into(),
416            line: 0,
417            column: 0,
418            message: "unsupported map schema version; expected 2".into(),
419        });
420    }
421    Ok(map)
422}
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase", deny_unknown_fields)]
425pub struct FlowState {
426    pub generation: String,
427    pub reasons: BTreeSet<String>,
428    /// Changes near this flow that could not have reached it: a file it
429    /// depends on changed only in code its test never ran. Told, not asked.
430    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
431    pub notices: BTreeSet<String>,
432    /// What the flow rested on when this state was written, part by part.
433    ///
434    /// `basis` is one hash of everything, so a mismatch can say that
435    /// *something* moved and no more. Recording the parts separately lets a
436    /// later mismatch name the one that moved -- an author who deleted a watch
437    /// entry and one who rewrote an explanation were given the same sentence.
438    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
439    pub footprint: BTreeMap<String, String>,
440}
441/// What each test of a run executed, in the units of that run's manifest.
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
443#[serde(rename_all = "camelCase", deny_unknown_fields)]
444pub struct Executions {
445    pub tests: Vec<Execution>,
446    /// Per file, the units that hold a probe of their own. A change confined
447    /// to these can reach a test only by being run.
448    pub probed: BTreeMap<String, Vec<usize>>,
449    /// Per file, the units the run actually reached -- every record's, the one
450    /// holding execution no test could be credited with included.
451    ///
452    /// This is the bound on a test whose own reach nothing recorded. It cannot
453    /// have run more than the run did, so a change outside this reached no
454    /// test at all and a change inside it might have reached any of them.
455    /// Without it the only sound answer for such a test would be "affected by
456    /// everything", which is true and useless.
457    #[serde(default)]
458    pub covered: BTreeMap<String, Vec<usize>>,
459}
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
461#[serde(rename_all = "camelCase", deny_unknown_fields)]
462pub struct Execution {
463    pub test: TestSelector,
464    pub passed: bool,
465    /// Per file, the innermost unit of every probe this test fired.
466    pub files: BTreeMap<String, Vec<usize>>,
467    /// How completely `files` describes what this test ran: `exact`, `partial`
468    /// where it is a lower bound, or `run-wide` where nothing was recorded.
469    ///
470    /// Only under `exact` does an absence here mean the test did not run the
471    /// code. A Go test that called `t.Parallel()` records nothing of its own,
472    /// and a Ruby test records only the lines no earlier test had reached --
473    /// so reading either silence as proof is how a change to code a test
474    /// exercised comes back as "this test is unaffected".
475    #[serde(default = "exact_by_default")]
476    pub attribution: String,
477}
478
479/// Every execution recorded before attribution was a question was an exact
480/// one, so a record that does not say is one.
481fn exact_by_default() -> String {
482    crate::coverage_report::ATTRIBUTION_EXACT.to_owned()
483}
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
485#[serde(rename_all = "camelCase", deny_unknown_fields)]
486pub struct Change {
487    pub id: String,
488    pub file: Option<String>,
489    pub before: Option<String>,
490    pub after: Option<String>,
491    pub reason: String,
492    /// Flows this change has already made stale. An assessment has to name
493    /// them; it may name more.
494    pub known_flows: BTreeSet<String>,
495    /// Flows whose selected tests ran the changed code, or have no execution
496    /// record to say. Not stale for it -- the claim they make does not pass
497    /// through that code -- but these are the ones the assessment is about.
498    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
499    pub exposed: BTreeSet<String>,
500}
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502#[serde(rename_all = "camelCase", deny_unknown_fields)]
503pub struct State {
504    pub schema_version: u32,
505    pub inputs_digest: String,
506    pub evidence_digest: String,
507    pub flows: BTreeMap<String, FlowState>,
508    pub changes: Vec<Change>,
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub inheritance: Option<Inheritance>,
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub executions: Option<Executions>,
513}
514#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
515#[serde(rename_all = "camelCase", deny_unknown_fields)]
516pub struct Inheritance {
517    pub from: Option<String>,
518    pub skipped: Vec<SkippedMap>,
519}
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
521#[serde(rename_all = "camelCase", deny_unknown_fields)]
522pub struct SkippedMap {
523    pub run: String,
524    pub reason: String,
525}
526pub fn flow_key(a: &Assertion, f: &Flow) -> String {
527    format!("{}/{}", a.id, f.id)
528}
529fn valid_id(id: &str) -> bool {
530    !id.is_empty()
531        && id
532            .bytes()
533            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
534}
535
536pub fn seed(inputs: &Inputs, evidence_digest: &str) -> (AssertionMap, State) {
537    seed_manifest(&inputs.manifest(), evidence_digest)
538}
539pub fn seed_manifest(inputs: &InputManifest, evidence_digest: &str) -> (AssertionMap, State) {
540    (
541        AssertionMap {
542            schema_version: 2,
543            assertions: inputs
544                .assertions
545                .iter()
546                .map(|site| Assertion {
547                    id: format!("a_{}", &digest(&site.at)[..20]),
548                    at: site.at.clone(),
549                    questions: vec![],
550                    observes: vec![],
551                    flows: vec![],
552                })
553                .collect(),
554            change_assessments: vec![],
555            retired_assertions: vec![],
556        },
557        State {
558            schema_version: 3,
559            inputs_digest: digest(inputs),
560            evidence_digest: evidence_digest.into(),
561            flows: BTreeMap::new(),
562            changes: vec![],
563            inheritance: None,
564            executions: None,
565        },
566    )
567}
568
569/// Structural checks only; malformed entries cannot silently earn credit.
570pub fn validate(map: &AssertionMap, inputs: &Inputs) -> Vec<String> {
571    let mut errors = Vec::new();
572    let lines = LineIndex::new(&inputs.files);
573    if map.schema_version != 2 || inputs.schema_version != 1 {
574        errors.push("unsupported schema version".into());
575    }
576    let mut ids = BTreeSet::new();
577    let mut sites = BTreeSet::new();
578    for a in &map.assertions {
579        if !valid_id(&a.id) || !ids.insert(&a.id) {
580            errors.push(format!("{}: invalid/duplicate assertion ID", a.id));
581        }
582        if !sites.insert(&a.at) {
583            errors.push(format!("{}: duplicate assertion location", a.id));
584        }
585        if lines.offset(&a.at).is_none() {
586            errors.push(format!("{}: invalid assertion anchor", a.id));
587        }
588        // Agents can register custom assertions absent from the syntax inventory.
589        // They still require exact run evidence to earn execution-backed credit.
590        let mut flows = BTreeSet::new();
591        for f in &a.flows {
592            let key = flow_key(a, f);
593            if !valid_id(&f.id) || !flows.insert(&f.id) {
594                errors.push(format!("{key}: invalid/duplicate flow ID"));
595            }
596            errors.extend(
597                validate_flow(f, &inputs.files)
598                    .into_iter()
599                    .map(|e| format!("{key}: {e}")),
600            );
601        }
602    }
603    let mut changes = BTreeSet::new();
604    for change in &map.change_assessments {
605        if !valid_id(&change.id) || !changes.insert(&change.id) {
606            errors.push("invalid/duplicate change assessment ID".into());
607        }
608    }
609    errors
610}
611/// Things worth telling the author that do not make the map wrong.
612///
613/// A redundant `watch` is the one that matters today. Supercov already marks
614/// every flow dirty when a dependency manifest or the execution configuration
615/// changes, so naming one of those files per flow catches nothing extra. It
616/// does teach a false model -- that per-flow watching is how dependency drift
617/// is caught -- and an author who believes it spends the effort on entries that
618/// change nothing instead of on the helper their claim actually rests on.
619pub fn advisories(map: &AssertionMap) -> Vec<String> {
620    let mut out = Vec::new();
621    for a in &map.assertions {
622        for f in &a.flows {
623            for file in &f.watch {
624                let key = flow_key(a, f);
625                if crate::integrity::globally_tracked(file) {
626                    out.push(format!(
627                        "{key}: watch \"{file}\" is redundant; Supercov invalidates every flow when that file changes"
628                    ));
629                    continue;
630                }
631                if a.at.file == *file || f.applies_to.iter().any(|t| t.file == *file) {
632                    out.push(format!(
633                        "{key}: watch \"{file}\" is redundant; this flow already depends on that file as a whole"
634                    ));
635                    continue;
636                }
637                // Not redundant -- it changes what the flow rests on, which is
638                // the part an author cannot see. Naming a file that holds this
639                // flow's nodes takes the file out of declaration-level footing
640                // and puts the whole file back in, so a neighbouring function's
641                // body becomes a review again. That can be exactly what the
642                // author means -- "no other handler in here registers /admin"
643                // is a claim about the file's shape -- so it is said, not
644                // refused.
645                let here = f
646                    .nodes
647                    .iter()
648                    .filter(|n| n.at.file == *file)
649                    .map(|n| format!("{}:{}", n.id, n.at.line))
650                    .collect::<Vec<_>>();
651                if !here.is_empty() {
652                    out.push(format!(
653                        "{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.",
654                        here.join(", ")
655                    ));
656                }
657            }
658        }
659    }
660    out
661}
662pub fn validate_flow(flow: &Flow, files: &Files) -> Vec<String> {
663    let mut errors = Vec::new();
664    let mut nodes = BTreeSet::new();
665    for node in &flow.nodes {
666        if !valid_id(&node.id) || !nodes.insert(&node.id) {
667            errors.push("invalid/duplicate node ID".into());
668        }
669        if node.at.offset(files).is_none() {
670            errors.push(format!("node {}: invalid anchor", node.id));
671        }
672    }
673    for edge in &flow.edges {
674        if !nodes.contains(&edge.from) || (!nodes.contains(&edge.to) && edge.to != "$assertion") {
675            errors.push("dangling edge".into());
676        }
677    }
678    if flow.counts_as_asserted.iter().any(|id| !nodes.contains(id)) {
679        errors.push("unknown counted node".into());
680    }
681    // Traverse only the author's graph. Never infer a dependency from source.
682    let mut reaches = BTreeSet::from(["$assertion".to_owned()]);
683    loop {
684        let size = reaches.len();
685        for edge in &flow.edges {
686            if reaches.contains(&edge.to) {
687                reaches.insert(edge.from.clone());
688            }
689        }
690        if reaches.len() == size {
691            break;
692        }
693    }
694    for id in &flow.counts_as_asserted {
695        if !reaches.contains(id) {
696            errors.push(format!(
697                "counted node {id} has no authored path to $assertion"
698            ));
699        }
700    }
701    if flow.edges.iter().any(|e| e.kind.trim().is_empty()) {
702        errors.push("missing edge kind".into());
703    }
704    if flow
705        .applies_to
706        .iter()
707        .any(|t| !local_path(&t.file) || !files.contains_key(&t.file) || t.name.trim().is_empty())
708    {
709        errors.push("invalid test selector file or name".into());
710    }
711    if flow.applies_to.iter().collect::<BTreeSet<_>>().len() != flow.applies_to.len() {
712        errors.push("duplicate test selector".into());
713    }
714    if flow
715        .counts_as_asserted
716        .iter()
717        .collect::<BTreeSet<_>>()
718        .len()
719        != flow.counts_as_asserted.len()
720    {
721        errors.push("duplicate counted node".into());
722    }
723    if flow.explanation.trim().is_empty() {
724        errors.push("missing explanation".into());
725    }
726    for file in &flow.watch {
727        if !local_path(file) || !files.contains_key(file) {
728            errors.push(format!("watched file missing: {file}"));
729        }
730    }
731    errors
732}
733
734/// The watch entries that are the flow's own.
735///
736/// A manifest, lockfile or runner configuration is tracked for the whole run,
737/// so naming one here catches nothing -- `advisories()` tells the author so.
738/// An entry that catches nothing must also cost nothing, in both directions:
739/// it is not a dependency, and taking it back out is not a new claim. Those
740/// are two different code paths -- `dependencies()` and `claim()` -- and when
741/// only the first filtered, Supercov advised authors to delete an entry it
742/// then charged a full re-acknowledgement for.
743///
744/// Only the watch list is filtered. An anchor or a node in one of those files
745/// is the flow's actual subject -- `setup.py` is a dependency manifest and
746/// measured source at once -- and editing it must still cost a review.
747fn watched(f: &Flow) -> impl Iterator<Item = &str> {
748    f.watch
749        .iter()
750        .map(String::as_str)
751        .filter(|path| !crate::integrity::globally_tracked(path))
752}
753/// Whole-file input dependencies, not a mechanically inferred semantic slice.
754pub fn dependencies<'a>(a: &'a Assertion, f: &'a Flow) -> BTreeSet<&'a str> {
755    std::iter::once(a.at.file.as_str())
756        .chain(f.applies_to.iter().map(|t| t.file.as_str()))
757        .chain(f.nodes.iter().map(|n| n.at.file.as_str()))
758        // Hashing a manifest's bytes here would quietly undo the manifest
759        // rule: a version bump would make every flow that names
760        // `package.json` stale, which is most of them in a real map. The
761        // run-level signal still fires, as a change to assess.
762        .chain(watched(f))
763        .collect()
764}
765fn token(value: &impl Serialize) -> String {
766    format!("scov3:{}", digest(value))
767}
768fn flow_keys(map: &AssertionMap) -> BTreeSet<String> {
769    map.assertions
770        .iter()
771        .flat_map(|a| a.flows.iter().map(move |f| flow_key(a, f)))
772        .collect()
773}
774/// An assessment is the author's judgement, so it is validated against the
775/// map it names flows in -- not against the change. The change no longer
776/// determines any part of a well-formed response.
777pub fn change_errors(map: &AssertionMap, response: &ChangeAssessment) -> Vec<String> {
778    change_errors_with(&flow_keys(map), response)
779}
780fn change_errors_with(keys: &BTreeSet<String>, response: &ChangeAssessment) -> Vec<String> {
781    let affected = response
782        .affected_flows
783        .iter()
784        .cloned()
785        .collect::<BTreeSet<_>>();
786    let mut errors = Vec::new();
787    if response.explanation.trim().is_empty() {
788        errors.push("missing impact explanation".into());
789    }
790    if affected.len() != response.affected_flows.len() {
791        errors.push("duplicate affected flow".into());
792    }
793    if !affected.is_subset(keys) {
794        errors.push("unknown affected flow".into());
795    }
796    // `affectedFlows` is the author's judgement -- the dependents this change
797    // actually invalidates -- not a restatement of `knownFlows`.
798    //
799    // Requiring the exhaustive set made the field carry no judgement at all: it
800    // was fully determined by data Supercov already holds, and every flow it
801    // named lost its acknowledgement. On a real map that meant one no-op
802    // manifest edit took 657 flows to zero, so the only ways forward were to
803    // copy 653 basis tokens for claims nobody had read, or leave the change
804    // pending and keep no percentage. That is the rubber-stamping the whole
805    // invalidation rule exists to prevent, one step further down.
806    //
807    // There is deliberately no floor -- not even "name everything whose test
808    // ran the change". Under an integration suite every test runs everything,
809    // so that floor is the same cascade wearing a different hat. Exposure is
810    // reported so the author can judge; it does not judge for them.
811    errors
812}
813pub fn expected_change_basis(
814    change: &Change,
815    response: &ChangeAssessment,
816    inputs: &InputManifest,
817) -> String {
818    expected_change_basis_with(change, response, &digest(inputs))
819}
820fn expected_change_basis_with(
821    change: &Change,
822    response: &ChangeAssessment,
823    inputs_digest: &str,
824) -> String {
825    token(&(
826        "supercov-change-v2",
827        change,
828        inputs_digest,
829        &response.id,
830        &response.affected_flows,
831        &response.explanation,
832    ))
833}
834pub fn change_current(map: &AssertionMap, change: &Change, inputs: &InputManifest) -> bool {
835    Ledger::with_changes(map, std::slice::from_ref(change), inputs).current(&change.id)
836}
837/// What every token of one run shares, computed once: the manifest's digest,
838/// and each change whose assessment is current with the flows it names.
839///
840/// Computed per flow instead, this serialised the whole manifest once per
841/// flow per pending change -- minutes on a real map with a backlog of
842/// changes, on every carry and every report.
843pub struct Ledger<'a> {
844    pub inputs_digest: String,
845    keys: BTreeSet<String>,
846    /// Current assessments by change id: the basis the author recorded and
847    /// the flows it names.
848    current: BTreeMap<&'a str, (&'a Option<String>, &'a [String])>,
849}
850impl<'a> Ledger<'a> {
851    pub fn new(map: &'a AssertionMap, state: &'a State, inputs: &InputManifest) -> Self {
852        Self::with_changes(map, &state.changes, inputs)
853    }
854    fn with_changes(map: &'a AssertionMap, changes: &'a [Change], inputs: &InputManifest) -> Self {
855        let inputs_digest = digest(inputs);
856        let keys = flow_keys(map);
857        let current = changes
858            .iter()
859            .filter_map(|change| {
860                let responses = map
861                    .change_assessments
862                    .iter()
863                    .filter(|r| r.id == change.id)
864                    .collect::<Vec<_>>();
865                match responses.as_slice() {
866                    [r] if change_errors_with(&keys, r).is_empty()
867                        && r.basis.as_deref()
868                            == Some(
869                                expected_change_basis_with(change, r, &inputs_digest).as_str(),
870                            ) =>
871                    {
872                        Some((change.id.as_str(), (&r.basis, r.affected_flows.as_slice())))
873                    }
874                    _ => None,
875                }
876            })
877            .collect();
878        Self {
879            inputs_digest,
880            keys,
881            current,
882        }
883    }
884    /// Whether the change has a valid, current assessment.
885    pub fn current(&self, change: &str) -> bool {
886        self.current.contains_key(change)
887    }
888    pub fn expected_change_basis(&self, change: &Change, response: &ChangeAssessment) -> String {
889        expected_change_basis_with(change, response, &self.inputs_digest)
890    }
891    pub fn change_errors(&self, response: &ChangeAssessment) -> Vec<String> {
892        change_errors_with(&self.keys, response)
893    }
894}
895/// Why a file is one of a flow's dependencies, and where the flow sits in it.
896///
897/// "dependency file changed" names a file and leaves the author to work out
898/// what it has to do with this claim. A flow depends on a file for one of four
899/// reasons, and they call for different judgements: a node there is the claim's
900/// subject, a watch is something the author asked to be told about, the test
901/// file is where the claim is exercised, and the assertion's own file is where
902/// it is written. Saying which -- and where the nodes are -- is the difference
903/// between rereading a claim and glancing at a line number.
904fn roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
905    let mut roles: Vec<String> = Vec::new();
906    let lines = node_sites(f, file);
907    if !lines.is_empty() {
908        roles.push(format!("holds this flow's {}", lines.join(", ")));
909    }
910    roles.extend(whole_file_roles(a, f, file));
911    roles
912}
913fn node_sites(f: &Flow, file: &str) -> Vec<String> {
914    f.nodes
915        .iter()
916        .filter(|n| n.at.file == file)
917        .map(|n| format!("{}:{}", n.id, n.at.line))
918        .collect()
919}
920/// Why the flow depends on the file *as a whole*, which is a different claim
921/// from where its nodes sit in it.
922fn whole_file_roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
923    let mut roles: Vec<String> = Vec::new();
924    if a.at.file == file {
925        roles.push(format!("holds the assertion, line {}", a.at.line));
926    }
927    if f.applies_to.iter().any(|t| t.file == file) {
928        roles.push("is the test this claim applies to".to_owned());
929    }
930    if f.watch.iter().any(|w| w == file) {
931        roles.push("is watched by this flow".to_owned());
932    }
933    roles
934}
935fn in_role(roles: &[String]) -> String {
936    if roles.is_empty() {
937        // Every path into dependencies() is covered above; say nothing rather
938        // than guess if that ever stops being true.
939        String::new()
940    } else {
941        format!(" ({})", roles.join("; "))
942    }
943}
944/// A file the flow rests on as a whole: the test it applies to, a file it
945/// watches, the file its assertion is written in. Any change there is the
946/// author's to judge; only its comments are not.
947fn whole_file(a: &Assertion, f: &Flow, file: &str) -> bool {
948    a.at.file == file
949        || f.applies_to.iter().any(|t| t.file == file)
950        || f.watch.iter().any(|w| w == file)
951}
952/// A claim's identity is where it points and what it says, not the line it
953/// happens to be on: a file edited above a node moves the node without
954/// touching the claim. Where the file has a parser's view, a node is placed by
955/// the declaration holding it and how many lines of code lie between it and
956/// the nearest boundary in that declaration -- the declaration's start, or the
957/// end of the last nested declaration before it. Growth anywhere else, and
958/// comments or blank lines anywhere, leave it in place; pointing it at another
959/// statement of the same text on another line does not.
960#[derive(Serialize)]
961struct Site<'a> {
962    file: &'a str,
963    text: &'a str,
964    #[serde(skip_serializing_if = "Option::is_none")]
965    unit: Option<&'a str>,
966    line: usize,
967    #[serde(skip_serializing_if = "Option::is_none")]
968    column: Option<usize>,
969}
970fn site<'a>(at: &'a Anchor, inputs: &'a InputManifest) -> Site<'a> {
971    let code = inputs.files.get(&at.file).and_then(|f| f.code.as_ref());
972    let Some(code) = code else {
973        return Site {
974            file: &at.file,
975            text: &at.text,
976            unit: None,
977            line: at.line,
978            column: Some(at.column),
979        };
980    };
981    let holder = code.unit_at(at.line, at.column);
982    let mut boundary = code.units[holder].line;
983    for child in code.units.iter().filter(|u| u.parent == Some(holder)) {
984        if (child.end_line, child.end_column) <= (at.line, at.column) && child.end_line > boundary {
985            boundary = child.end_line;
986        }
987    }
988    Site {
989        file: &at.file,
990        text: &at.text,
991        unit: Some(&code.units[holder].path),
992        line: code
993            .code_line(at.line)
994            .saturating_sub(code.code_line(boundary)),
995        column: None,
996    }
997}
998#[derive(Serialize)]
999#[serde(rename_all = "camelCase")]
1000struct NodeClaim<'a> {
1001    id: &'a str,
1002    at: Site<'a>,
1003    role: &'a str,
1004    meaning: &'a str,
1005}
1006#[derive(Serialize)]
1007#[serde(rename_all = "camelCase")]
1008struct Claim<'a> {
1009    id: &'a str,
1010    explanation: &'a str,
1011    applies_to: &'a [TestSelector],
1012    nodes: Vec<NodeClaim<'a>>,
1013    edges: &'a [Edge],
1014    counts_as_asserted: &'a [String],
1015    /// The flow's own watches. A redundant manifest entry is left out so that
1016    /// writing one and removing it are both free.
1017    watch: Vec<&'a str>,
1018    questions: &'a [String],
1019}
1020fn claim<'a>(f: &'a Flow, inputs: &'a InputManifest) -> Claim<'a> {
1021    Claim {
1022        id: &f.id,
1023        explanation: &f.explanation,
1024        applies_to: &f.applies_to,
1025        nodes: f
1026            .nodes
1027            .iter()
1028            .map(|n| NodeClaim {
1029                id: &n.id,
1030                at: site(&n.at, inputs),
1031                role: &n.role,
1032                meaning: &n.meaning,
1033            })
1034            .collect(),
1035        edges: &f.edges,
1036        counts_as_asserted: &f.counts_as_asserted,
1037        watch: watched(f).collect(),
1038        questions: &f.questions,
1039    }
1040}
1041/// What an acknowledgement rests on in one dependency file.
1042#[derive(Serialize)]
1043#[serde(rename_all = "camelCase")]
1044enum Footing<'a> {
1045    /// Named by the flow but not among the run's inputs.
1046    Absent,
1047    /// No parser reads this file; its bytes are the claim's ground.
1048    Bytes(&'a str),
1049    /// Everything the file does, comments aside.
1050    Semantic(&'a str),
1051    /// The declarations holding this flow's nodes, and the file's set of
1052    /// declarations. Code elsewhere in the file is answered for by what the
1053    /// flow's test executed, which `carry` judges.
1054    Units {
1055        structure: &'a str,
1056        units: BTreeMap<&'a str, &'a str>,
1057    },
1058}
1059fn footing<'a>(
1060    a: &'a Assertion,
1061    f: &'a Flow,
1062    inputs: &'a InputManifest,
1063) -> BTreeMap<&'a str, Footing<'a>> {
1064    dependencies(a, f)
1065        .into_iter()
1066        .map(|file| {
1067            let Some(fingerprint) = inputs.files.get(file) else {
1068                return (file, Footing::Absent);
1069            };
1070            let Some(code) = &fingerprint.code else {
1071                return (file, Footing::Bytes(&fingerprint.sha256));
1072            };
1073            if whole_file(a, f, file) {
1074                return (file, Footing::Semantic(&code.semantic));
1075            }
1076            let units = f
1077                .nodes
1078                .iter()
1079                .filter(|n| n.at.file == file)
1080                .flat_map(|n| code.ancestors(code.unit_at(n.at.line, n.at.column)))
1081                .map(|i| (code.units[i].path.as_str(), code.units[i].digest.as_str()))
1082                .collect();
1083            (
1084                file,
1085                Footing::Units {
1086                    structure: &code.structure,
1087                    units,
1088                },
1089            )
1090        })
1091        .collect()
1092}
1093
1094fn generation(a: &Assertion, f: &Flow, state: &State, ledger: &Ledger<'_>) -> String {
1095    let key = flow_key(a, f);
1096    let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
1097    let impacts = ledger
1098        .current
1099        .iter()
1100        .filter(|(_, (_, affected))| affected.contains(&key))
1101        .map(|(id, (basis, _))| (*id, *basis))
1102        .collect::<BTreeMap<_, _>>();
1103    if impacts.is_empty() {
1104        base.into()
1105    } else {
1106        digest(&("supercov-generation-v2", base, impacts))
1107    }
1108}
1109/// The parts of `expected_basis` that depend only on the map and the run's
1110/// inputs, each digested on its own.
1111///
1112/// `generation` is deliberately absent: it is a function of the state being
1113/// written, and a change assessment naming this flow already explains itself
1114/// through the change channel.
1115fn footprint(a: &Assertion, f: &Flow, inputs: &InputManifest) -> BTreeMap<String, String> {
1116    let claim = claim(f, inputs);
1117    BTreeMap::from([
1118        (
1119            "the run's context".to_owned(),
1120            digest(&inputs.context_digest),
1121        ),
1122        (
1123            "the assertion's site".to_owned(),
1124            digest(&site(&a.at, inputs)),
1125        ),
1126        (
1127            "what the assertion observes".to_owned(),
1128            digest(&a.observes),
1129        ),
1130        (
1131            "the flow's explanation".to_owned(),
1132            digest(&claim.explanation),
1133        ),
1134        (
1135            "the test this flow applies to".to_owned(),
1136            digest(&claim.applies_to),
1137        ),
1138        ("the flow's nodes".to_owned(), digest(&claim.nodes)),
1139        ("the flow's edges".to_owned(), digest(&claim.edges)),
1140        (
1141            "the flow's counted nodes".to_owned(),
1142            digest(&claim.counts_as_asserted),
1143        ),
1144        ("the flow's watch list".to_owned(), digest(&claim.watch)),
1145        ("the flow's questions".to_owned(), digest(&claim.questions)),
1146        (
1147            "the files this flow rests on".to_owned(),
1148            digest(&footing(a, f, inputs)),
1149        ),
1150    ])
1151}
1152/// Which recorded parts no longer match, in the order they are listed above.
1153fn moved(before: &BTreeMap<String, String>, after: &BTreeMap<String, String>) -> Vec<String> {
1154    after
1155        .iter()
1156        .filter(|(part, now)| before.get(*part).is_some_and(|then| then != *now))
1157        .map(|(part, _)| format!("{part} changed"))
1158        .collect()
1159}
1160pub fn expected_basis(
1161    a: &Assertion,
1162    f: &Flow,
1163    map: &AssertionMap,
1164    state: &State,
1165    inputs: &InputManifest,
1166) -> String {
1167    expected_basis_with(a, f, state, inputs, &Ledger::new(map, state, inputs))
1168}
1169/// The token with the run-wide facts already in hand; what every caller with
1170/// more than one flow to judge should use.
1171pub fn expected_basis_with(
1172    a: &Assertion,
1173    f: &Flow,
1174    state: &State,
1175    inputs: &InputManifest,
1176    ledger: &Ledger<'_>,
1177) -> String {
1178    token(&(
1179        "supercov-flow-v3",
1180        &inputs.context_digest,
1181        &a.id,
1182        site(&a.at, inputs),
1183        &a.observes,
1184        claim(f, inputs),
1185        footing(a, f, inputs),
1186        generation(a, f, state, ledger),
1187    ))
1188}
1189pub fn reasons(
1190    a: &Assertion,
1191    f: &Flow,
1192    map: &AssertionMap,
1193    state: &State,
1194    inputs: &Inputs,
1195) -> BTreeSet<String> {
1196    reasons_for_manifest(a, f, map, state, inputs, &inputs.manifest())
1197}
1198pub fn reasons_for_manifest(
1199    a: &Assertion,
1200    f: &Flow,
1201    map: &AssertionMap,
1202    state: &State,
1203    inputs: &Inputs,
1204    manifest: &InputManifest,
1205) -> BTreeSet<String> {
1206    reasons_with(
1207        a,
1208        f,
1209        state,
1210        inputs,
1211        manifest,
1212        &Ledger::new(map, state, manifest),
1213    )
1214}
1215pub fn reasons_with(
1216    a: &Assertion,
1217    f: &Flow,
1218    state: &State,
1219    inputs: &Inputs,
1220    manifest: &InputManifest,
1221    ledger: &Ledger<'_>,
1222) -> BTreeSet<String> {
1223    let mut reasons = BTreeSet::new();
1224    if state.schema_version != 3 || state.inputs_digest != ledger.inputs_digest {
1225        reasons.insert("state does not match run inputs".into());
1226    }
1227    if f.basis.as_deref() != Some(expected_basis_with(a, f, state, manifest, ledger).as_str()) {
1228        let recorded = state.flows.get(&flow_key(a, f));
1229        match f.basis.as_deref() {
1230            None => {
1231                reasons.insert("draft: input acknowledgement not recorded".into());
1232            }
1233            Some(basis) if superseded_basis(basis) => {
1234                reasons.insert(SUPERSEDED_BASIS.into());
1235            }
1236            Some(_) => {
1237                // Say which part moved. One hash over everything can only
1238                // report that something did, which gave an author who deleted
1239                // a watch entry the same sentence as one who rewrote a claim.
1240                let parts = recorded
1241                    .map(|s| moved(&s.footprint, &footprint(a, f, manifest)))
1242                    .unwrap_or_default();
1243                if parts.is_empty() {
1244                    reasons.insert("claim or inputs changed; needs rechecking".into());
1245                } else {
1246                    reasons.extend(parts);
1247                }
1248            }
1249        }
1250        if let Some(s) = recorded {
1251            reasons.extend(s.reasons.iter().cloned());
1252        }
1253    }
1254    reasons.extend(validate_flow(f, &inputs.files));
1255    if a.at.offset(&inputs.files).is_none() {
1256        reasons.insert("invalid assertion anchor".into());
1257    }
1258    if !f.questions.is_empty() {
1259        reasons.insert("flow has unresolved questions".into());
1260    }
1261    reasons
1262}
1263/// Read-only validation. Tokens acknowledge authored claims, never prove them.
1264pub fn validation(map: &AssertionMap, state: &State, inputs: &Inputs) -> serde_json::Value {
1265    use serde_json::json;
1266    let manifest = inputs.manifest();
1267    let ledger = Ledger::new(map, state, &manifest);
1268    let mut errors = validate(map, inputs);
1269    for r in &map.change_assessments {
1270        if !state.changes.iter().any(|c| c.id == r.id) {
1271            errors.push(format!("{}: unknown change assessment", r.id));
1272        }
1273    }
1274    // Exposure is kept per flow but read per test: hundreds of flow keys say
1275    // less than the dozen tests they apply to, and cost more to page.
1276    let selectors = map
1277        .assertions
1278        .iter()
1279        .flat_map(|a| a.flows.iter().map(move |f| (flow_key(a, f), &f.applies_to)))
1280        .collect::<BTreeMap<_, _>>();
1281    let changes = state.changes.iter().map(|c| {
1282        let response = map.change_assessments.iter().find(|r| r.id == c.id);
1283        let faults = response.map(|r| ledger.change_errors(r)).unwrap_or_default();
1284        errors.extend(faults.iter().map(|e| format!("{}: {e}", c.id)));
1285        // Every list a change carries is bounded, or one item outgrows any
1286        // page and cannot be fetched at all -- B17. `knownFlows` was the first
1287        // to do it; `exposed` inherited the defect the moment it was added,
1288        // because its test list grows with the suite, not with the change.
1289        let tests = c.exposed.iter().filter_map(|k| selectors.get(k)).flat_map(|t| t.iter()).collect::<BTreeSet<_>>();
1290        let shown = tests.iter().take(20).collect::<Vec<_>>();
1291        json!({"id":c.id,"file":c.file,"before":c.before,"after":c.after,"reason":c.reason,
1292            "knownFlows":{"flows":c.known_flows.len(),"sample":c.known_flows.iter().take(8).collect::<Vec<_>>()},
1293            "exposed":{"flows":c.exposed.len(),"tests":shown,"testCount":tests.len(),"sample":c.exposed.iter().take(8).collect::<Vec<_>>()},
1294            "current":ledger.current(&c.id),"assessment":response,"errors":faults,
1295            "expectedBasis":response.map(|r| ledger.expected_change_basis(c,r))})
1296    }).collect::<Vec<_>>();
1297    let flows = map.assertions.iter().flat_map(|a| a.flows.iter().map(move |f| (a,f))).map(|(a,f)| {
1298        json!({"id":flow_key(a,f),"expectedBasis":expected_basis_with(a,f,state,&manifest,&ledger),"reasons":reasons_with(a,f,state,inputs,&manifest,&ledger)})
1299    }).collect::<Vec<_>>();
1300    json!({"valid":errors.is_empty(),"stage":"references","errors":errors,"flows":flows,"changes":changes,
1301        "meaning":"Authored graph references and input acknowledgements only; no semantic proof or completeness claim"})
1302}
1303pub fn invalidate(state: &mut State, map: &AssertionMap, reason: &str) {
1304    for a in &map.assertions {
1305        for f in &a.flows {
1306            let key = flow_key(a, f);
1307            let previous = state.flows.get(&key);
1308            let base = previous.map_or("0", |s| s.generation.as_str());
1309            // Whole-map invalidation says nothing about the parts, so keep the
1310            // record rather than erasing what it knew.
1311            let footprint = previous.map(|s| s.footprint.clone()).unwrap_or_default();
1312            let generation = digest(&(base, reason, &state.inputs_digest));
1313            state.flows.insert(
1314                key,
1315                FlowState {
1316                    generation,
1317                    reasons: BTreeSet::from([reason.into()]),
1318                    notices: BTreeSet::new(),
1319                    footprint,
1320                },
1321            );
1322        }
1323    }
1324}
1325pub fn add_change(
1326    state: &mut State,
1327    file: Option<String>,
1328    before: Option<String>,
1329    after: Option<String>,
1330    reason: String,
1331    known_flows: BTreeSet<String>,
1332    exposed: BTreeSet<String>,
1333) {
1334    // Include pending history so edit/revert/edit cannot alias a still-pending event.
1335    let id = format!(
1336        "c_{}",
1337        &digest(&(
1338            "supercov-change-id-v2",
1339            &state.changes,
1340            &file,
1341            &before,
1342            &after,
1343            &reason
1344        ))[..24]
1345    );
1346    state.changes.push(Change {
1347        id,
1348        file,
1349        before,
1350        after,
1351        reason,
1352        known_flows,
1353        exposed,
1354    });
1355}
1356
1357fn unique_occurrence(text: &str, snippet: &str) -> Option<usize> {
1358    if snippet.is_empty() {
1359        return None;
1360    }
1361    let first = text.find(snippet)?;
1362    // Include overlapping occurrences; match_indices skips them.
1363    let next = first + text[first..].chars().next()?.len_utf8();
1364    text[next..]
1365        .contains(snippet)
1366        .then_some(())
1367        .map_or(Some(first), |_| None)
1368}
1369fn target_file(file: &str, old: &FileManifest, new: &Files) -> Option<String> {
1370    if new.contains_key(file) {
1371        return Some(file.into());
1372    }
1373    let hash = old.get(file)?;
1374    let mut matches = new
1375        .iter()
1376        .filter(|(_, s)| FileFingerprint::of(s).same_bytes(hash));
1377    let first = matches.next()?.0;
1378    matches.next().is_none().then(|| first.clone())
1379}
1380pub fn relocate(at: &Anchor, old: &FileManifest, new: &Files) -> Option<Anchor> {
1381    relocate_indexed(at, old, new, None)
1382}
1383
1384fn relocate_indexed(
1385    at: &Anchor,
1386    old: &FileManifest,
1387    new: &Files,
1388    lines: Option<&LineIndex<'_>>,
1389) -> Option<Anchor> {
1390    old.get(&at.file)?;
1391    let target = target_file(&at.file, old, new)?;
1392    let after = &new[&target];
1393    let mut candidate = at.clone();
1394    candidate.file.clone_from(&target);
1395    // Read the recorded position first. Edits elsewhere in the file say nothing
1396    // about this anchor: if its complete text is still here, it did not move.
1397    //
1398    // Without this, every anchor in a changed file is re-found by searching the
1399    // whole file, and that search insists the text be unique -- so a statement
1400    // that appears twice is reported "changed or ambiguous" while sitting
1401    // untouched at the line it was recorded at. That is a false statement about
1402    // a specific node, and it is most of the staleness in a real map.
1403    // offset checks the anchor's complete text, so a whole-file fingerprint
1404    // adds nothing here. Carrying many anchors reuses one line index per file.
1405    if lines
1406        .map_or_else(|| candidate.offset(new), |index| index.offset(&candidate))
1407        .is_some()
1408    {
1409        return Some(candidate);
1410    }
1411    let position = unique_occurrence(after, &at.text)?;
1412    Some(Anchor::new(
1413        &target,
1414        after,
1415        position,
1416        position + at.text.len(),
1417    ))
1418}
1419
1420/// How one captured file moved between two runs, judged once and read for
1421/// every flow.
1422pub enum FileChange<'a> {
1423    Same,
1424    /// Only comments changed: no program can tell.
1425    CommentsOnly,
1426    /// Not among the previous run's inputs.
1427    Added,
1428    Removed,
1429    /// No parser reads the file on one side or the other; its bytes moved.
1430    Bytes,
1431    Code {
1432        before: &'a Code,
1433        after: &'a Code,
1434        diff: Diff,
1435        /// The change is confined to declaration bodies that only run: it
1436        /// reaches a test only if the test ran one of them.
1437        narrow: bool,
1438    },
1439}
1440pub fn file_change<'a>(
1441    before: Option<&'a FileFingerprint>,
1442    after: Option<&'a FileFingerprint>,
1443    probed: Option<&[usize]>,
1444) -> Option<FileChange<'a>> {
1445    let Some(before) = before else {
1446        return after.map(|_| FileChange::Added);
1447    };
1448    let Some(after) = after else {
1449        return Some(FileChange::Removed);
1450    };
1451    if before.same_bytes(after) {
1452        return Some(FileChange::Same);
1453    }
1454    let (Some(old), Some(new)) = (&before.code, &after.code) else {
1455        return Some(FileChange::Bytes);
1456    };
1457    if old.semantic == new.semantic {
1458        return Some(FileChange::CommentsOnly);
1459    }
1460    let diff = old.diff(new);
1461    let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
1462    Some(FileChange::Code {
1463        before: old,
1464        after: new,
1465        diff,
1466        narrow,
1467    })
1468}
1469/// Every unit that moved, by name: what changed, what arrived, what went.
1470pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
1471    let mut parts = Vec::new();
1472    if !diff.changed.is_empty() {
1473        parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
1474    }
1475    if !diff.added.is_empty() {
1476        parts.push(format!(
1477            "added {}",
1478            named(diff.added.iter().map(|i| &after.units[*i]))
1479        ));
1480    }
1481    if !diff.removed.is_empty() {
1482        parts.push(format!(
1483            "removed {}",
1484            named(diff.removed.iter().map(|i| &before.units[*i]))
1485        ));
1486    }
1487    if parts.is_empty() {
1488        "declarations".to_owned()
1489    } else {
1490        parts.join("; ")
1491    }
1492}
1493/// The units a flow's tests executed, per file, each with what it sits
1494/// inside; `None` when a selected test has no execution record in this state,
1495/// in which case nothing about execution can be assumed.
1496fn executed<'s>(
1497    records: &BTreeMap<&TestSelector, &'s Execution>,
1498    f: &Flow,
1499    manifest: &InputManifest,
1500) -> Option<BTreeMap<&'s str, BTreeSet<usize>>> {
1501    let mut out: BTreeMap<&str, BTreeSet<usize>> = BTreeMap::new();
1502    for selector in &f.applies_to {
1503        let record = records.get(selector)?;
1504        for (file, units) in &record.files {
1505            let code = manifest.files.get(file).and_then(|fp| fp.code.as_ref());
1506            let set = out.entry(file.as_str()).or_default();
1507            for &unit in units {
1508                match code {
1509                    Some(code) if unit < code.units.len() => set.extend(code.ancestors(unit)),
1510                    _ => {
1511                        set.insert(unit);
1512                    }
1513                }
1514            }
1515        }
1516    }
1517    Some(out)
1518}
1519
1520/// Carries explanations, never execution events. Uncertain matches are retained
1521/// as retired suggestions; no nearest-line heuristic assigns semantic meaning.
1522///
1523/// A flow goes stale for a change to what its claim rests on and for nothing
1524/// else: the declarations holding its nodes and the top level of their files,
1525/// the test it applies to, a file it watches, its assertion, the run's
1526/// context. A change elsewhere in a node's file is a notice. A change to
1527/// comments or blank lines is nothing.
1528///
1529/// What each flow's test executed does not make the flow stale -- a claim
1530/// does not pass through every function its test happened to run, and an
1531/// acknowledgement demanded for all of them at once stops being read. It goes
1532/// on the change record instead: a changed file names the flows whose tests
1533/// ran the changed code, so the one assessment the change asks for is asked
1534/// of the right people, and a change nobody ran asks for none.
1535pub fn carry(
1536    map: &AssertionMap,
1537    state: &State,
1538    old: &InputManifest,
1539    new: &Inputs,
1540    evidence_digest: &str,
1541    context_changed: bool,
1542) -> Result<(AssertionMap, State), String> {
1543    if map.schema_version != 2 || old.schema_version != 2 || new.schema_version != 1 {
1544        return Err("unsupported map/input schema version".into());
1545    }
1546    if state.inputs_digest != digest(old) || state.schema_version != 3 {
1547        return Err("old map state does not match its run inputs".into());
1548    }
1549    let ledger = Ledger::new(map, state, old);
1550    let new_manifest = new.manifest();
1551    let (mut next, mut next_state) = seed_manifest(&new_manifest, evidence_digest);
1552    next.assertions.clear();
1553    next.retired_assertions = map.retired_assertions.clone();
1554    next_state.changes = state
1555        .changes
1556        .iter()
1557        .filter(|c| !ledger.current(&c.id))
1558        .cloned()
1559        .collect();
1560    next.change_assessments = map
1561        .change_assessments
1562        .iter()
1563        .filter(|r| next_state.changes.iter().any(|c| c.id == r.id))
1564        .cloned()
1565        .collect();
1566    let records = state
1567        .executions
1568        .iter()
1569        .flat_map(|e| e.tests.iter().map(|t| (&t.test, t)))
1570        .collect::<BTreeMap<_, _>>();
1571    let probed = |file: &str| {
1572        state
1573            .executions
1574            .as_ref()
1575            .and_then(|e| e.probed.get(file))
1576            .map(Vec::as_slice)
1577    };
1578    let changes = old
1579        .files
1580        .keys()
1581        .chain(new_manifest.files.keys())
1582        .collect::<BTreeSet<_>>()
1583        .into_iter()
1584        .filter_map(|file| {
1585            file_change(
1586                old.files.get(file),
1587                new_manifest.files.get(file),
1588                probed(file),
1589            )
1590            .map(|change| (file.as_str(), change))
1591        })
1592        .collect::<BTreeMap<_, _>>();
1593    // Per changed file: the flows it made stale, and the flows whose tests ran
1594    // the changed code or have no record to say -- what the change record
1595    // names as known and as exposed.
1596    let mut marked: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1597    let mut exposed: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1598    let mut consumed = BTreeSet::new();
1599    let lines = LineIndex::new(&new.files);
1600    let new_sites = new
1601        .assertions
1602        .iter()
1603        .map(|site| &site.at)
1604        .collect::<BTreeSet<_>>();
1605    let old_sites = old
1606        .assertions
1607        .iter()
1608        .map(|site| &site.at)
1609        .collect::<BTreeSet<_>>();
1610    let exact = map
1611        .assertions
1612        .iter()
1613        .map(|a| {
1614            relocate_indexed(&a.at, &old.files, &new.files, Some(&lines))
1615                .filter(|at| new_sites.contains(at) || !old_sites.contains(&a.at))
1616        })
1617        .collect::<Vec<_>>();
1618    let reserved = exact.iter().flatten().collect::<BTreeSet<_>>();
1619    let mut candidates = BTreeMap::<&str, Vec<&Anchor>>::new();
1620    for site in &new.assertions {
1621        if !reserved.contains(&site.at) {
1622            candidates.entry(&site.at.file).or_default().push(&site.at);
1623        }
1624    }
1625    let mut unmatched = BTreeMap::<&str, usize>::new();
1626    for (other, at) in map.assertions.iter().zip(&exact) {
1627        if at.is_none() {
1628            *unmatched.entry(&other.at.file).or_default() += 1;
1629        }
1630    }
1631    for (index, a) in map.assertions.iter().enumerate() {
1632        // A sole old/new unmatched site in the same file is a review
1633        // suggestion. Preserve its explanation but never its reviewed status.
1634        let replacement = if exact[index].is_none() && unmatched.get(a.at.file.as_str()) == Some(&1)
1635        {
1636            match candidates.get(a.at.file.as_str()).map(Vec::as_slice) {
1637                Some([at]) => Some(*at),
1638                _ => None,
1639            }
1640        } else {
1641            None
1642        };
1643        let matched = exact[index]
1644            .as_ref()
1645            .or(replacement)
1646            .filter(|at| !consumed.contains(*at));
1647        let Some(at) = matched else {
1648            next.retired_assertions.push(Retired {
1649                assertion: a.clone(),
1650                reason:
1651                    "assertion removed, changed or ambiguous; reuse its explanation after review"
1652                        .into(),
1653            });
1654            continue;
1655        };
1656        consumed.insert(at.clone());
1657        let mut updated = a.clone();
1658        updated.at = at.clone();
1659        for (prior, f) in a.flows.iter().zip(&mut updated.flows) {
1660            let key = flow_key(a, f);
1661            let base = generation(a, prior, state, &ledger);
1662            let mut dirty = BTreeSet::new();
1663            let mut notices = BTreeSet::new();
1664            match prior.basis.as_deref() {
1665                Some(basis) if superseded_basis(basis) => {
1666                    dirty.insert(SUPERSEDED_BASIS.into());
1667                }
1668                Some(basis) if basis != expected_basis_with(a, prior, state, old, &ledger) => {
1669                    dirty.insert("inherited claim still needs rechecking".into());
1670                }
1671                _ => {}
1672            }
1673            // What the flow's test ran, for the change record: a changed file
1674            // is assessed by whoever ran the change, and a change nobody ran
1675            // is not assessed at all.
1676            match executed(&records, prior, old) {
1677                Some(ran) => {
1678                    for (file, units) in &ran {
1679                        let reached = match changes.get(file) {
1680                            None
1681                            | Some(
1682                                FileChange::Same | FileChange::CommentsOnly | FileChange::Added,
1683                            ) => false,
1684                            Some(FileChange::Removed | FileChange::Bytes) => true,
1685                            Some(FileChange::Code { diff, narrow, .. }) => {
1686                                !*narrow || diff.changed.iter().any(|i| units.contains(i))
1687                            }
1688                        };
1689                        if reached {
1690                            exposed.entry(file).or_default().insert(key.clone());
1691                        }
1692                    }
1693                }
1694                None => {
1695                    for (file, change) in &changes {
1696                        if !matches!(
1697                            change,
1698                            FileChange::Same | FileChange::CommentsOnly | FileChange::Added
1699                        ) {
1700                            exposed.entry(file).or_default().insert(key.clone());
1701                        }
1702                    }
1703                }
1704            }
1705            // What the flow names: its test, its watch list and its assertion's
1706            // file as a whole; the file of a node for the declarations that
1707            // hold the node, its top level and its set of declarations.
1708            for file in dependencies(a, prior) {
1709                let roles = roles(a, prior, file);
1710                let verdict = match changes.get(file) {
1711                    None => Some(format!(
1712                        "{file} is not among the run's inputs{}",
1713                        in_role(&roles)
1714                    )),
1715                    Some(FileChange::Added) => Some(format!(
1716                        "{file} is new since the previous run{}",
1717                        in_role(&roles)
1718                    )),
1719                    Some(FileChange::Same | FileChange::CommentsOnly) => None,
1720                    Some(FileChange::Removed) => Some(format!("{file} removed{}", in_role(&roles))),
1721                    Some(FileChange::Bytes) => Some(format!("{file} changed{}", in_role(&roles))),
1722                    Some(FileChange::Code {
1723                        before,
1724                        after,
1725                        diff,
1726                        ..
1727                    }) => {
1728                        if whole_file(a, prior, file) {
1729                            // The file's roles describe the file. Attached to
1730                            // the declaration that changed they say something
1731                            // false: in `other (line 4) changed (holds this
1732                            // flow's n:2)`, `work` holds n:2 and `other` is its
1733                            // neighbour. What makes this change count is the
1734                            // dependency on the whole file; where the nodes sit
1735                            // is context, and is said as context.
1736                            let mut why = whole_file_roles(a, prior, file);
1737                            let sites = node_sites(prior, file);
1738                            if !sites.is_empty() {
1739                                let holders = prior
1740                                    .nodes
1741                                    .iter()
1742                                    .filter(|n| n.at.file == file)
1743                                    .map(|n| before.unit_at(n.at.line, n.at.column))
1744                                    .collect::<BTreeSet<_>>();
1745                                why.push(format!(
1746                                    "this flow's {} sits in {}",
1747                                    sites.join(", "),
1748                                    named(holders.iter().map(|i| &before.units[*i]))
1749                                ));
1750                            }
1751                            Some(format!(
1752                                "{file}: {} changed{}",
1753                                describe(before, after, diff),
1754                                in_role(&why)
1755                            ))
1756                        } else {
1757                            let holders = prior
1758                                .nodes
1759                                .iter()
1760                                .filter(|n| n.at.file == file)
1761                                .flat_map(|n| {
1762                                    before.ancestors(before.unit_at(n.at.line, n.at.column))
1763                                })
1764                                .collect::<BTreeSet<_>>();
1765                            let moved = holders
1766                                .iter()
1767                                .filter(|i| diff.changed.contains(i) || diff.removed.contains(i))
1768                                .map(|i| &before.units[*i])
1769                                .collect::<Vec<_>>();
1770                            if !moved.is_empty() {
1771                                Some(format!(
1772                                    "{file}: {} changed{}",
1773                                    named(moved),
1774                                    in_role(&roles)
1775                                ))
1776                            } else if diff.structural {
1777                                Some(format!(
1778                                    "{file}: declarations changed, {}{}",
1779                                    describe(before, after, diff),
1780                                    in_role(&roles)
1781                                ))
1782                            } else {
1783                                notices.insert(format!(
1784                                    "{file} changed outside this flow's nodes: {}",
1785                                    describe(before, after, diff)
1786                                ));
1787                                None
1788                            }
1789                        }
1790                    }
1791                };
1792                if let Some(reason) = verdict {
1793                    dirty.insert(reason);
1794                    marked.entry(file).or_default().insert(key.clone());
1795                }
1796            }
1797            if replacement.is_some() {
1798                dirty.insert("assertion changed or replaced; confirm identity and meaning".into());
1799            }
1800            for node in &mut f.nodes {
1801                if let Some(at) = relocate_indexed(&node.at, &old.files, &new.files, Some(&lines)) {
1802                    node.at = at;
1803                } else {
1804                    dirty.insert(format!("node {} changed or ambiguous", node.id));
1805                }
1806            }
1807            for file in f
1808                .watch
1809                .iter_mut()
1810                .chain(f.applies_to.iter_mut().map(|t| &mut t.file))
1811            {
1812                if let Some(target) = target_file(file, &old.files, &new.files) {
1813                    *file = target;
1814                } else {
1815                    dirty.insert(format!("dependency file removed: {file}"));
1816                }
1817            }
1818            if context_changed {
1819                dirty.insert("run configuration, dependencies or execution context changed".into());
1820            }
1821            next_state.flows.insert(
1822                key,
1823                FlowState {
1824                    generation: if dirty.is_empty() {
1825                        base
1826                    } else {
1827                        digest(&("supercov-carry-v3", base, &new_manifest, &dirty))
1828                    },
1829                    reasons: dirty,
1830                    notices,
1831                    footprint: footprint(a, f, &new_manifest),
1832                },
1833            );
1834        }
1835        next.assertions.push(updated);
1836    }
1837    let mut ids = map
1838        .assertions
1839        .iter()
1840        .map(|a| a.id.clone())
1841        .chain(
1842            map.retired_assertions
1843                .iter()
1844                .map(|r| r.assertion.id.clone()),
1845        )
1846        .collect::<BTreeSet<_>>();
1847    for a in seed(new, evidence_digest).0.assertions {
1848        if !consumed.contains(&a.at) {
1849            let mut a = a;
1850            while !ids.insert(a.id.clone()) {
1851                a.id.push('_');
1852            }
1853            next.assertions.push(a);
1854        }
1855    }
1856    for file in old
1857        .files
1858        .keys()
1859        .chain(new_manifest.files.keys())
1860        .collect::<BTreeSet<_>>()
1861    {
1862        // A manifest is answered for by the run's dependency fingerprint, which
1863        // reads what it declares. Reporting its bytes here as well would make
1864        // cutting a release look like a change to assess when nothing about the
1865        // project moved.
1866        if crate::integrity::tracked_manifest(file) {
1867            continue;
1868        }
1869        let exposed_to = exposed.get(file.as_str()).cloned().unwrap_or_default();
1870        match changes.get(file.as_str()) {
1871            // A comment is not a change to assess.
1872            Some(FileChange::Same | FileChange::CommentsOnly) => continue,
1873            // A change confined to code that only runs, which no selected test
1874            // ran, cannot have reached any claim; the flow claiming that code
1875            // is already stale for it. Nothing to ask.
1876            Some(FileChange::Code { narrow: true, .. }) if exposed_to.is_empty() => continue,
1877            _ => {}
1878        }
1879        add_change(
1880            &mut next_state,
1881            Some(file.clone()),
1882            old.files.get(file).map(|f| f.sha256.clone()),
1883            new_manifest.files.get(file).map(|f| f.sha256.clone()),
1884            "captured source file changed".into(),
1885            marked.get(file.as_str()).cloned().unwrap_or_default(),
1886            exposed_to,
1887        );
1888    }
1889    next.assertions.sort_by(|a, b| a.at.cmp(&b.at));
1890    Ok((next, next_state))
1891}
1892
1893#[path = "assertion_legacy.rs"]
1894mod legacy;
1895pub fn parse_stored(bytes: &[u8]) -> Result<AssertionMap, String> {
1896    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1897    match value
1898        .get("schemaVersion")
1899        .and_then(serde_json::Value::as_u64)
1900    {
1901        None | Some(1) => legacy::import(bytes),
1902        _ => parse(bytes).map_err(|e| e.to_string()),
1903    }
1904}
1905pub fn parse_state(
1906    bytes: &[u8],
1907    map: &AssertionMap,
1908    inputs: &InputManifest,
1909    evidence: &str,
1910    legacy_digest: Option<&str>,
1911) -> Result<State, String> {
1912    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1913    if value["schemaVersion"] == 3 {
1914        let state: State = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1915        if state.inputs_digest != digest(inputs) || state.evidence_digest != evidence {
1916            return Err("Assertion state belongs to different run evidence; rerun tests".into());
1917        }
1918        Ok(state)
1919    } else {
1920        legacy::state(bytes, map, inputs, evidence, legacy_digest)
1921    }
1922}
1923
1924#[cfg(test)]
1925mod line_index_tests {
1926    use super::*;
1927
1928    // The awkward shapes: no final line feed, a final one, blank lines, CRLF,
1929    // a lone carriage return, an empty file, and text whose UTF-8 and UTF-16
1930    // widths differ.
1931    const SOURCES: &[&str] = &[
1932        "",
1933        "a",
1934        "a\n",
1935        "a\n\n",
1936        "\n\n\n",
1937        "a\r\nb",
1938        "a\r\nb\r\n",
1939        "a\rb\nc",
1940        "x\r",
1941        "héllo\nwörld\n",
1942        "😀x\ny😀z\n",
1943        "def f(a):\n    if a:\n        return 1\n    return 0\n",
1944    ];
1945
1946    /// `Anchor::offset` as it was before it took line starts: the oracle the
1947    /// new form must agree with everywhere.
1948    fn reference_offset(anchor: &Anchor, source: &str) -> Option<usize> {
1949        if !local_path(&anchor.file)
1950            || anchor.text.is_empty()
1951            || anchor.line == 0
1952            || anchor.column == 0
1953        {
1954            return None;
1955        }
1956        let start = source
1957            .split_inclusive('\n')
1958            .take(anchor.line - 1)
1959            .map(str::len)
1960            .sum::<usize>();
1961        if source[..start].bytes().filter(|b| *b == b'\n').count() != anchor.line - 1 {
1962            return None;
1963        }
1964        let line = source.get(start..)?.split('\n').next()?;
1965        if anchor.column - 1 > line.len() {
1966            return None;
1967        }
1968        let pos = start.checked_add(anchor.column - 1)?;
1969        source.get(pos..)?.starts_with(&anchor.text).then_some(pos)
1970    }
1971
1972    #[test]
1973    fn a_line_reads_exactly_as_str_lines_reads_it() {
1974        for source in SOURCES {
1975            let starts = line_starts(source);
1976            for line in 0..source.len() + 3 {
1977                let expected = line.checked_sub(1).and_then(|n| source.lines().nth(n));
1978                assert_eq!(
1979                    line_text(source, &starts, line),
1980                    expected,
1981                    "line {line} of {source:?}"
1982                );
1983            }
1984        }
1985    }
1986
1987    #[test]
1988    fn an_offset_from_line_starts_agrees_with_scanning_everywhere() {
1989        let mut compared = 0;
1990        for source in SOURCES {
1991            let files = Files::from([("f.py".to_owned(), (*source).to_owned())]);
1992            let index = LineIndex::new(&files);
1993            // Every text that occurs, a text that does not, and every position
1994            // around each line -- including just past its end.
1995            let mut texts = vec!["zzz".to_owned()];
1996            for start in 0..=source.len() {
1997                for end in start..=source.len() {
1998                    if let Some(text) = source.get(start..end) {
1999                        texts.push(text.to_owned());
2000                    }
2001                }
2002            }
2003            texts.sort();
2004            texts.dedup();
2005            for line in 0..source.lines().count() + 3 {
2006                for column in 0..source.len() + 3 {
2007                    for text in &texts {
2008                        let anchor = Anchor {
2009                            file: "f.py".into(),
2010                            line,
2011                            column,
2012                            text: text.clone(),
2013                        };
2014                        let expected = reference_offset(&anchor, source);
2015                        assert_eq!(anchor.offset(&files), expected, "{anchor:?} in {source:?}");
2016                        assert_eq!(index.offset(&anchor), expected, "{anchor:?} in {source:?}");
2017                        compared += 1;
2018                    }
2019                }
2020            }
2021        }
2022        assert!(
2023            compared > 10_000,
2024            "the comparison has to be broad to mean anything: {compared}"
2025        );
2026    }
2027}