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