1use 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#[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 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
68pub 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 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
94pub 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
102pub 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
119pub 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 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 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#[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 #[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 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#[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
319fn 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}
341pub 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
360pub 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 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
431 pub notices: BTreeSet<String>,
432 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
439 pub footprint: BTreeMap<String, String>,
440}
441#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
443#[serde(rename_all = "camelCase", deny_unknown_fields)]
444pub struct Executions {
445 pub tests: Vec<Execution>,
446 pub probed: BTreeMap<String, Vec<usize>>,
449 #[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 pub files: BTreeMap<String, Vec<usize>>,
467 #[serde(default = "exact_by_default")]
476 pub attribution: String,
477}
478
479fn 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 pub known_flows: BTreeSet<String>,
495 #[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
569pub 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 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}
611pub 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 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 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
734fn 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}
753pub 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 .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}
774pub 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 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}
837pub struct Ledger<'a> {
844 pub inputs_digest: String,
845 keys: BTreeSet<String>,
846 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 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}
895fn 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}
920fn 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 String::new()
940 } else {
941 format!(" ({})", roles.join("; "))
942 }
943}
944fn 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#[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 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#[derive(Serialize)]
1043#[serde(rename_all = "camelCase")]
1044enum Footing<'a> {
1045 Absent,
1047 Bytes(&'a str),
1049 Semantic(&'a str),
1051 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}
1109fn 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}
1152fn 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}
1169pub 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 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}
1263pub 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 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 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 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 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 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 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
1420pub enum FileChange<'a> {
1423 Same,
1424 CommentsOnly,
1426 Added,
1428 Removed,
1429 Bytes,
1431 Code {
1432 before: &'a Code,
1433 after: &'a Code,
1434 diff: Diff,
1435 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}
1469pub 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}
1493fn 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
1520pub 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 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 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 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 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 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 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 Some(FileChange::Same | FileChange::CommentsOnly) => continue,
1873 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 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 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 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}