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 if !local_path(&self.file) || self.text.is_empty() || self.line == 0 || self.column == 0 {
48 return None;
49 }
50 let source = files.get(&self.file)?;
51 let start = source
52 .split_inclusive('\n')
53 .take(self.line - 1)
54 .map(str::len)
55 .sum::<usize>();
56 if source[..start].bytes().filter(|b| *b == b'\n').count() != self.line - 1 {
57 return None;
58 }
59 let line = source.get(start..)?.split('\n').next()?;
60 if self.column - 1 > line.len() {
61 return None;
62 }
63 let pos = start.checked_add(self.column - 1)?;
64 source.get(pos..)?.starts_with(&self.text).then_some(pos)
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct InventorySite {
71 pub at: Anchor,
72 pub operation: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
78#[serde(rename_all = "camelCase", deny_unknown_fields)]
79pub struct Inputs {
80 #[serde(default = "version")]
81 pub schema_version: u32,
82 pub language: String,
83 pub context_digest: String,
84 pub files: Files,
85 pub assertions: Vec<InventorySite>,
86 pub limitations: Vec<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91pub struct FileFingerprint {
92 pub sha256: String,
93 pub bytes: usize,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub code: Option<Code>,
100}
101impl FileFingerprint {
102 pub fn of(source: &str) -> Self {
103 Self {
104 sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
105 bytes: source.len(),
106 code: None,
107 }
108 }
109 pub fn read(path: &str, source: &str) -> Self {
111 let mut fingerprint = Self::of(source);
112 fingerprint.code = crate::source_units::code(path, source);
113 fingerprint
114 }
115 pub fn same_bytes(&self, other: &Self) -> bool {
116 self.sha256 == other.sha256
117 }
118}
119pub type FileManifest = BTreeMap<String, FileFingerprint>;
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124pub struct InputManifest {
125 pub schema_version: u32,
126 pub language: String,
127 pub context_digest: String,
128 pub files: FileManifest,
129 pub assertions: Vec<InventorySite>,
130 pub limitations: Vec<String>,
131}
132impl Inputs {
133 pub fn manifest(&self) -> InputManifest {
134 InputManifest {
135 schema_version: 2,
136 language: self.language.clone(),
137 context_digest: self.context_digest.clone(),
138 files: self
139 .files
140 .iter()
141 .map(|(p, s)| (p.clone(), FileFingerprint::read(p, s)))
142 .collect(),
143 assertions: self.assertions.clone(),
144 limitations: self.limitations.clone(),
145 }
146 }
147 pub fn identity(&self) -> String {
148 digest(&self.manifest())
149 }
150}
151impl InputManifest {
152 pub fn with_sources(&self, files: Files) -> Inputs {
153 Inputs {
154 schema_version: 1,
155 language: self.language.clone(),
156 context_digest: self.context_digest.clone(),
157 files,
158 assertions: self.assertions.clone(),
159 limitations: self.limitations.clone(),
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
165#[serde(rename_all = "camelCase", deny_unknown_fields)]
166pub struct Node {
167 pub id: String,
168 pub at: Anchor,
169 #[serde(default, skip_serializing_if = "String::is_empty")]
170 pub role: String,
171 #[serde(default, skip_serializing_if = "String::is_empty")]
172 pub meaning: String,
173}
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct Edge {
177 pub from: String,
178 pub to: String,
179 pub kind: String,
180 #[serde(default, skip_serializing_if = "String::is_empty")]
181 pub basis: String,
182}
183#[derive(
184 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
185)]
186#[serde(rename_all = "camelCase", deny_unknown_fields)]
187pub struct TestSelector {
188 pub file: String,
189 pub name: String,
190}
191
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct Flow {
195 pub id: String,
196 #[serde(deserialize_with = "required_basis")]
197 #[schemars(required, schema_with = "basis_schema")]
198 pub basis: Option<String>,
199 pub explanation: String,
200 pub applies_to: Vec<TestSelector>,
201 pub nodes: Vec<Node>,
202 #[serde(default)]
203 pub edges: Vec<Edge>,
204 pub counts_as_asserted: Vec<String>,
205 pub watch: Vec<String>,
206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub questions: Vec<String>,
208}
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
210#[serde(rename_all = "camelCase", deny_unknown_fields)]
211pub struct Assertion {
212 pub id: String,
213 pub at: Anchor,
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub questions: Vec<String>,
216 #[serde(default)]
217 pub observes: Vec<String>,
218 #[serde(default)]
219 pub flows: Vec<Flow>,
220}
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
222#[serde(rename_all = "camelCase", deny_unknown_fields)]
223pub struct Retired {
224 pub assertion: Assertion,
225 pub reason: String,
226}
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229pub struct AssertionMap {
230 #[schemars(range(min = 2, max = 2))]
231 pub schema_version: u32,
232 pub assertions: Vec<Assertion>,
233 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub change_assessments: Vec<ChangeAssessment>,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub retired_assertions: Vec<Retired>,
237}
238
239fn required_basis<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
241 let value = Option::<String>::deserialize(d)?;
242 if value.as_deref().is_some_and(|s| !valid_basis(s)) {
243 return Err(serde::de::Error::custom(
244 "expected null or scov3:<64 lowercase hex digits>",
245 ));
246 }
247 Ok(value)
248}
249fn basis_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
250 schemars::json_schema!({"type":["string","null"],"pattern":"^scov[23]:[0-9a-f]{64}$"})
251}
252fn valid_basis(s: &str) -> bool {
253 s.strip_prefix("scov3:")
254 .or_else(|| s.strip_prefix("scov2:"))
255 .is_some_and(|h| {
256 h.len() == 64
257 && h.bytes()
258 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
259 })
260}
261pub fn superseded_basis(s: &str) -> bool {
266 s.starts_with("scov2:")
267}
268pub const SUPERSEDED_BASIS: &str = "acknowledged under an earlier Supercov basis format; reread the claim and copy the current expectedBasis";
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
270#[serde(rename_all = "camelCase", deny_unknown_fields)]
271pub struct ChangeAssessment {
272 pub id: String,
273 #[serde(deserialize_with = "required_basis")]
274 #[schemars(required, schema_with = "basis_schema")]
275 pub basis: Option<String>,
276 pub affected_flows: Vec<String>,
277 pub explanation: String,
278}
279
280pub fn schema() -> serde_json::Value {
283 serde_json::to_value(schemars::schema_for!(AssertionMap)).expect("schema")
284}
285
286#[derive(Debug, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct ParseError {
289 pub pointer: String,
290 pub line: usize,
291 pub column: usize,
292 pub message: String,
293}
294impl std::fmt::Display for ParseError {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 write!(
297 f,
298 "{} at {} (JSON line {}, column {})",
299 self.message, self.pointer, self.line, self.column
300 )
301 }
302}
303pub fn parse(bytes: &[u8]) -> Result<AssertionMap, ParseError> {
304 let mut deserializer = serde_json::Deserializer::from_slice(bytes);
305 let map: AssertionMap = serde_path_to_error::deserialize(&mut deserializer).map_err(|e| {
306 let pointer = e
307 .path()
308 .iter()
309 .map(|segment| {
310 use serde_path_to_error::Segment;
311 let part = match segment {
312 Segment::Seq { index } => index.to_string(),
313 Segment::Map { key } => key.clone(),
314 Segment::Enum { variant } => variant.clone(),
315 Segment::Unknown => "?".into(),
316 };
317 format!("/{}", part.replace('~', "~0").replace('/', "~1"))
318 })
319 .collect();
320 ParseError {
321 pointer,
322 line: e.inner().line(),
323 column: e.inner().column(),
324 message: e.inner().to_string(),
325 }
326 })?;
327 deserializer.end().map_err(|e| ParseError {
328 pointer: String::new(),
329 line: e.line(),
330 column: e.column(),
331 message: e.to_string(),
332 })?;
333 if map.schema_version != 2 {
334 return Err(ParseError {
335 pointer: "/schemaVersion".into(),
336 line: 0,
337 column: 0,
338 message: "unsupported map schema version; expected 2".into(),
339 });
340 }
341 Ok(map)
342}
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase", deny_unknown_fields)]
345pub struct FlowState {
346 pub generation: String,
347 pub reasons: BTreeSet<String>,
348 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
351 pub notices: BTreeSet<String>,
352 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
359 pub footprint: BTreeMap<String, String>,
360}
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase", deny_unknown_fields)]
364pub struct Executions {
365 pub tests: Vec<Execution>,
366 pub probed: BTreeMap<String, Vec<usize>>,
369 #[serde(default)]
378 pub covered: BTreeMap<String, Vec<usize>>,
379}
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381#[serde(rename_all = "camelCase", deny_unknown_fields)]
382pub struct Execution {
383 pub test: TestSelector,
384 pub passed: bool,
385 pub files: BTreeMap<String, Vec<usize>>,
387 #[serde(default = "exact_by_default")]
396 pub attribution: String,
397}
398
399fn exact_by_default() -> String {
402 crate::coverage_report::ATTRIBUTION_EXACT.to_owned()
403}
404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
405#[serde(rename_all = "camelCase", deny_unknown_fields)]
406pub struct Change {
407 pub id: String,
408 pub file: Option<String>,
409 pub before: Option<String>,
410 pub after: Option<String>,
411 pub reason: String,
412 pub known_flows: BTreeSet<String>,
415 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
419 pub exposed: BTreeSet<String>,
420}
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422#[serde(rename_all = "camelCase", deny_unknown_fields)]
423pub struct State {
424 pub schema_version: u32,
425 pub inputs_digest: String,
426 pub evidence_digest: String,
427 pub flows: BTreeMap<String, FlowState>,
428 pub changes: Vec<Change>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub inheritance: Option<Inheritance>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub executions: Option<Executions>,
433}
434#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
435#[serde(rename_all = "camelCase", deny_unknown_fields)]
436pub struct Inheritance {
437 pub from: Option<String>,
438 pub skipped: Vec<SkippedMap>,
439}
440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
441#[serde(rename_all = "camelCase", deny_unknown_fields)]
442pub struct SkippedMap {
443 pub run: String,
444 pub reason: String,
445}
446pub fn flow_key(a: &Assertion, f: &Flow) -> String {
447 format!("{}/{}", a.id, f.id)
448}
449fn valid_id(id: &str) -> bool {
450 !id.is_empty()
451 && id
452 .bytes()
453 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
454}
455
456pub fn seed(inputs: &Inputs, evidence_digest: &str) -> (AssertionMap, State) {
457 seed_manifest(&inputs.manifest(), evidence_digest)
458}
459pub fn seed_manifest(inputs: &InputManifest, evidence_digest: &str) -> (AssertionMap, State) {
460 (
461 AssertionMap {
462 schema_version: 2,
463 assertions: inputs
464 .assertions
465 .iter()
466 .map(|site| Assertion {
467 id: format!("a_{}", &digest(&site.at)[..20]),
468 at: site.at.clone(),
469 questions: vec![],
470 observes: vec![],
471 flows: vec![],
472 })
473 .collect(),
474 change_assessments: vec![],
475 retired_assertions: vec![],
476 },
477 State {
478 schema_version: 3,
479 inputs_digest: digest(inputs),
480 evidence_digest: evidence_digest.into(),
481 flows: BTreeMap::new(),
482 changes: vec![],
483 inheritance: None,
484 executions: None,
485 },
486 )
487}
488
489pub fn validate(map: &AssertionMap, inputs: &Inputs) -> Vec<String> {
491 let mut errors = Vec::new();
492 if map.schema_version != 2 || inputs.schema_version != 1 {
493 errors.push("unsupported schema version".into());
494 }
495 let mut ids = BTreeSet::new();
496 let mut sites = BTreeSet::new();
497 for a in &map.assertions {
498 if !valid_id(&a.id) || !ids.insert(&a.id) {
499 errors.push(format!("{}: invalid/duplicate assertion ID", a.id));
500 }
501 if !sites.insert(&a.at) {
502 errors.push(format!("{}: duplicate assertion location", a.id));
503 }
504 if a.at.offset(&inputs.files).is_none() {
505 errors.push(format!("{}: invalid assertion anchor", a.id));
506 }
507 let mut flows = BTreeSet::new();
510 for f in &a.flows {
511 let key = flow_key(a, f);
512 if !valid_id(&f.id) || !flows.insert(&f.id) {
513 errors.push(format!("{key}: invalid/duplicate flow ID"));
514 }
515 errors.extend(
516 validate_flow(f, &inputs.files)
517 .into_iter()
518 .map(|e| format!("{key}: {e}")),
519 );
520 }
521 }
522 let mut changes = BTreeSet::new();
523 for change in &map.change_assessments {
524 if !valid_id(&change.id) || !changes.insert(&change.id) {
525 errors.push("invalid/duplicate change assessment ID".into());
526 }
527 }
528 errors
529}
530pub fn advisories(map: &AssertionMap) -> Vec<String> {
539 let mut out = Vec::new();
540 for a in &map.assertions {
541 for f in &a.flows {
542 for file in &f.watch {
543 let key = flow_key(a, f);
544 if crate::integrity::globally_tracked(file) {
545 out.push(format!(
546 "{key}: watch \"{file}\" is redundant; Supercov invalidates every flow when that file changes"
547 ));
548 continue;
549 }
550 if a.at.file == *file || f.applies_to.iter().any(|t| t.file == *file) {
551 out.push(format!(
552 "{key}: watch \"{file}\" is redundant; this flow already depends on that file as a whole"
553 ));
554 continue;
555 }
556 let here = f
565 .nodes
566 .iter()
567 .filter(|n| n.at.file == *file)
568 .map(|n| format!("{}:{}", n.id, n.at.line))
569 .collect::<Vec<_>>();
570 if !here.is_empty() {
571 out.push(format!(
572 "{key}: watch \"{file}\" widens this flow to the whole file; without it only the declarations holding its nodes ({}) and the file's set of declarations would count. Remove it unless a change anywhere in that file should be a review.",
573 here.join(", ")
574 ));
575 }
576 }
577 }
578 }
579 out
580}
581pub fn validate_flow(flow: &Flow, files: &Files) -> Vec<String> {
582 let mut errors = Vec::new();
583 let mut nodes = BTreeSet::new();
584 for node in &flow.nodes {
585 if !valid_id(&node.id) || !nodes.insert(&node.id) {
586 errors.push("invalid/duplicate node ID".into());
587 }
588 if node.at.offset(files).is_none() {
589 errors.push(format!("node {}: invalid anchor", node.id));
590 }
591 }
592 for edge in &flow.edges {
593 if !nodes.contains(&edge.from) || (!nodes.contains(&edge.to) && edge.to != "$assertion") {
594 errors.push("dangling edge".into());
595 }
596 }
597 if flow.counts_as_asserted.iter().any(|id| !nodes.contains(id)) {
598 errors.push("unknown counted node".into());
599 }
600 let mut reaches = BTreeSet::from(["$assertion".to_owned()]);
602 loop {
603 let size = reaches.len();
604 for edge in &flow.edges {
605 if reaches.contains(&edge.to) {
606 reaches.insert(edge.from.clone());
607 }
608 }
609 if reaches.len() == size {
610 break;
611 }
612 }
613 for id in &flow.counts_as_asserted {
614 if !reaches.contains(id) {
615 errors.push(format!(
616 "counted node {id} has no authored path to $assertion"
617 ));
618 }
619 }
620 if flow.edges.iter().any(|e| e.kind.trim().is_empty()) {
621 errors.push("missing edge kind".into());
622 }
623 if flow
624 .applies_to
625 .iter()
626 .any(|t| !local_path(&t.file) || !files.contains_key(&t.file) || t.name.trim().is_empty())
627 {
628 errors.push("invalid test selector file or name".into());
629 }
630 if flow.applies_to.iter().collect::<BTreeSet<_>>().len() != flow.applies_to.len() {
631 errors.push("duplicate test selector".into());
632 }
633 if flow
634 .counts_as_asserted
635 .iter()
636 .collect::<BTreeSet<_>>()
637 .len()
638 != flow.counts_as_asserted.len()
639 {
640 errors.push("duplicate counted node".into());
641 }
642 if flow.explanation.trim().is_empty() {
643 errors.push("missing explanation".into());
644 }
645 for file in &flow.watch {
646 if !local_path(file) || !files.contains_key(file) {
647 errors.push(format!("watched file missing: {file}"));
648 }
649 }
650 errors
651}
652
653fn watched(f: &Flow) -> impl Iterator<Item = &str> {
667 f.watch
668 .iter()
669 .map(String::as_str)
670 .filter(|path| !crate::integrity::globally_tracked(path))
671}
672pub fn dependencies<'a>(a: &'a Assertion, f: &'a Flow) -> BTreeSet<&'a str> {
674 std::iter::once(a.at.file.as_str())
675 .chain(f.applies_to.iter().map(|t| t.file.as_str()))
676 .chain(f.nodes.iter().map(|n| n.at.file.as_str()))
677 .chain(watched(f))
682 .collect()
683}
684fn token(value: &impl Serialize) -> String {
685 format!("scov3:{}", digest(value))
686}
687fn flow_keys(map: &AssertionMap) -> BTreeSet<String> {
688 map.assertions
689 .iter()
690 .flat_map(|a| a.flows.iter().map(move |f| flow_key(a, f)))
691 .collect()
692}
693pub fn change_errors(map: &AssertionMap, response: &ChangeAssessment) -> Vec<String> {
697 change_errors_with(&flow_keys(map), response)
698}
699fn change_errors_with(keys: &BTreeSet<String>, response: &ChangeAssessment) -> Vec<String> {
700 let affected = response
701 .affected_flows
702 .iter()
703 .cloned()
704 .collect::<BTreeSet<_>>();
705 let mut errors = Vec::new();
706 if response.explanation.trim().is_empty() {
707 errors.push("missing impact explanation".into());
708 }
709 if affected.len() != response.affected_flows.len() {
710 errors.push("duplicate affected flow".into());
711 }
712 if !affected.is_subset(keys) {
713 errors.push("unknown affected flow".into());
714 }
715 errors
731}
732pub fn expected_change_basis(
733 change: &Change,
734 response: &ChangeAssessment,
735 inputs: &InputManifest,
736) -> String {
737 expected_change_basis_with(change, response, &digest(inputs))
738}
739fn expected_change_basis_with(
740 change: &Change,
741 response: &ChangeAssessment,
742 inputs_digest: &str,
743) -> String {
744 token(&(
745 "supercov-change-v2",
746 change,
747 inputs_digest,
748 &response.id,
749 &response.affected_flows,
750 &response.explanation,
751 ))
752}
753pub fn change_current(map: &AssertionMap, change: &Change, inputs: &InputManifest) -> bool {
754 Ledger::with_changes(map, std::slice::from_ref(change), inputs).current(&change.id)
755}
756pub struct Ledger<'a> {
763 pub inputs_digest: String,
764 keys: BTreeSet<String>,
765 current: BTreeMap<&'a str, (&'a Option<String>, &'a [String])>,
768}
769impl<'a> Ledger<'a> {
770 pub fn new(map: &'a AssertionMap, state: &'a State, inputs: &InputManifest) -> Self {
771 Self::with_changes(map, &state.changes, inputs)
772 }
773 fn with_changes(map: &'a AssertionMap, changes: &'a [Change], inputs: &InputManifest) -> Self {
774 let inputs_digest = digest(inputs);
775 let keys = flow_keys(map);
776 let current = changes
777 .iter()
778 .filter_map(|change| {
779 let responses = map
780 .change_assessments
781 .iter()
782 .filter(|r| r.id == change.id)
783 .collect::<Vec<_>>();
784 match responses.as_slice() {
785 [r] if change_errors_with(&keys, r).is_empty()
786 && r.basis.as_deref()
787 == Some(
788 expected_change_basis_with(change, r, &inputs_digest).as_str(),
789 ) =>
790 {
791 Some((change.id.as_str(), (&r.basis, r.affected_flows.as_slice())))
792 }
793 _ => None,
794 }
795 })
796 .collect();
797 Self {
798 inputs_digest,
799 keys,
800 current,
801 }
802 }
803 pub fn current(&self, change: &str) -> bool {
805 self.current.contains_key(change)
806 }
807 pub fn expected_change_basis(&self, change: &Change, response: &ChangeAssessment) -> String {
808 expected_change_basis_with(change, response, &self.inputs_digest)
809 }
810 pub fn change_errors(&self, response: &ChangeAssessment) -> Vec<String> {
811 change_errors_with(&self.keys, response)
812 }
813}
814fn roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
824 let mut roles: Vec<String> = Vec::new();
825 let lines = node_sites(f, file);
826 if !lines.is_empty() {
827 roles.push(format!("holds this flow's {}", lines.join(", ")));
828 }
829 roles.extend(whole_file_roles(a, f, file));
830 roles
831}
832fn node_sites(f: &Flow, file: &str) -> Vec<String> {
833 f.nodes
834 .iter()
835 .filter(|n| n.at.file == file)
836 .map(|n| format!("{}:{}", n.id, n.at.line))
837 .collect()
838}
839fn whole_file_roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
842 let mut roles: Vec<String> = Vec::new();
843 if a.at.file == file {
844 roles.push(format!("holds the assertion, line {}", a.at.line));
845 }
846 if f.applies_to.iter().any(|t| t.file == file) {
847 roles.push("is the test this claim applies to".to_owned());
848 }
849 if f.watch.iter().any(|w| w == file) {
850 roles.push("is watched by this flow".to_owned());
851 }
852 roles
853}
854fn in_role(roles: &[String]) -> String {
855 if roles.is_empty() {
856 String::new()
859 } else {
860 format!(" ({})", roles.join("; "))
861 }
862}
863fn whole_file(a: &Assertion, f: &Flow, file: &str) -> bool {
867 a.at.file == file
868 || f.applies_to.iter().any(|t| t.file == file)
869 || f.watch.iter().any(|w| w == file)
870}
871#[derive(Serialize)]
880struct Site<'a> {
881 file: &'a str,
882 text: &'a str,
883 #[serde(skip_serializing_if = "Option::is_none")]
884 unit: Option<&'a str>,
885 line: usize,
886 #[serde(skip_serializing_if = "Option::is_none")]
887 column: Option<usize>,
888}
889fn site<'a>(at: &'a Anchor, inputs: &'a InputManifest) -> Site<'a> {
890 let code = inputs.files.get(&at.file).and_then(|f| f.code.as_ref());
891 let Some(code) = code else {
892 return Site {
893 file: &at.file,
894 text: &at.text,
895 unit: None,
896 line: at.line,
897 column: Some(at.column),
898 };
899 };
900 let holder = code.unit_at(at.line, at.column);
901 let mut boundary = code.units[holder].line;
902 for child in code.units.iter().filter(|u| u.parent == Some(holder)) {
903 if (child.end_line, child.end_column) <= (at.line, at.column) && child.end_line > boundary {
904 boundary = child.end_line;
905 }
906 }
907 Site {
908 file: &at.file,
909 text: &at.text,
910 unit: Some(&code.units[holder].path),
911 line: code
912 .code_line(at.line)
913 .saturating_sub(code.code_line(boundary)),
914 column: None,
915 }
916}
917#[derive(Serialize)]
918#[serde(rename_all = "camelCase")]
919struct NodeClaim<'a> {
920 id: &'a str,
921 at: Site<'a>,
922 role: &'a str,
923 meaning: &'a str,
924}
925#[derive(Serialize)]
926#[serde(rename_all = "camelCase")]
927struct Claim<'a> {
928 id: &'a str,
929 explanation: &'a str,
930 applies_to: &'a [TestSelector],
931 nodes: Vec<NodeClaim<'a>>,
932 edges: &'a [Edge],
933 counts_as_asserted: &'a [String],
934 watch: Vec<&'a str>,
937 questions: &'a [String],
938}
939fn claim<'a>(f: &'a Flow, inputs: &'a InputManifest) -> Claim<'a> {
940 Claim {
941 id: &f.id,
942 explanation: &f.explanation,
943 applies_to: &f.applies_to,
944 nodes: f
945 .nodes
946 .iter()
947 .map(|n| NodeClaim {
948 id: &n.id,
949 at: site(&n.at, inputs),
950 role: &n.role,
951 meaning: &n.meaning,
952 })
953 .collect(),
954 edges: &f.edges,
955 counts_as_asserted: &f.counts_as_asserted,
956 watch: watched(f).collect(),
957 questions: &f.questions,
958 }
959}
960#[derive(Serialize)]
962#[serde(rename_all = "camelCase")]
963enum Footing<'a> {
964 Absent,
966 Bytes(&'a str),
968 Semantic(&'a str),
970 Units {
974 structure: &'a str,
975 units: BTreeMap<&'a str, &'a str>,
976 },
977}
978fn footing<'a>(
979 a: &'a Assertion,
980 f: &'a Flow,
981 inputs: &'a InputManifest,
982) -> BTreeMap<&'a str, Footing<'a>> {
983 dependencies(a, f)
984 .into_iter()
985 .map(|file| {
986 let Some(fingerprint) = inputs.files.get(file) else {
987 return (file, Footing::Absent);
988 };
989 let Some(code) = &fingerprint.code else {
990 return (file, Footing::Bytes(&fingerprint.sha256));
991 };
992 if whole_file(a, f, file) {
993 return (file, Footing::Semantic(&code.semantic));
994 }
995 let units = f
996 .nodes
997 .iter()
998 .filter(|n| n.at.file == file)
999 .flat_map(|n| code.ancestors(code.unit_at(n.at.line, n.at.column)))
1000 .map(|i| (code.units[i].path.as_str(), code.units[i].digest.as_str()))
1001 .collect();
1002 (
1003 file,
1004 Footing::Units {
1005 structure: &code.structure,
1006 units,
1007 },
1008 )
1009 })
1010 .collect()
1011}
1012
1013fn generation(a: &Assertion, f: &Flow, state: &State, ledger: &Ledger<'_>) -> String {
1014 let key = flow_key(a, f);
1015 let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
1016 let impacts = ledger
1017 .current
1018 .iter()
1019 .filter(|(_, (_, affected))| affected.contains(&key))
1020 .map(|(id, (basis, _))| (*id, *basis))
1021 .collect::<BTreeMap<_, _>>();
1022 if impacts.is_empty() {
1023 base.into()
1024 } else {
1025 digest(&("supercov-generation-v2", base, impacts))
1026 }
1027}
1028fn footprint(a: &Assertion, f: &Flow, inputs: &InputManifest) -> BTreeMap<String, String> {
1035 let claim = claim(f, inputs);
1036 BTreeMap::from([
1037 (
1038 "the run's context".to_owned(),
1039 digest(&inputs.context_digest),
1040 ),
1041 (
1042 "the assertion's site".to_owned(),
1043 digest(&site(&a.at, inputs)),
1044 ),
1045 (
1046 "what the assertion observes".to_owned(),
1047 digest(&a.observes),
1048 ),
1049 (
1050 "the flow's explanation".to_owned(),
1051 digest(&claim.explanation),
1052 ),
1053 (
1054 "the test this flow applies to".to_owned(),
1055 digest(&claim.applies_to),
1056 ),
1057 ("the flow's nodes".to_owned(), digest(&claim.nodes)),
1058 ("the flow's edges".to_owned(), digest(&claim.edges)),
1059 (
1060 "the flow's counted nodes".to_owned(),
1061 digest(&claim.counts_as_asserted),
1062 ),
1063 ("the flow's watch list".to_owned(), digest(&claim.watch)),
1064 ("the flow's questions".to_owned(), digest(&claim.questions)),
1065 (
1066 "the files this flow rests on".to_owned(),
1067 digest(&footing(a, f, inputs)),
1068 ),
1069 ])
1070}
1071fn moved(before: &BTreeMap<String, String>, after: &BTreeMap<String, String>) -> Vec<String> {
1073 after
1074 .iter()
1075 .filter(|(part, now)| before.get(*part).is_some_and(|then| then != *now))
1076 .map(|(part, _)| format!("{part} changed"))
1077 .collect()
1078}
1079pub fn expected_basis(
1080 a: &Assertion,
1081 f: &Flow,
1082 map: &AssertionMap,
1083 state: &State,
1084 inputs: &InputManifest,
1085) -> String {
1086 expected_basis_with(a, f, state, inputs, &Ledger::new(map, state, inputs))
1087}
1088pub fn expected_basis_with(
1091 a: &Assertion,
1092 f: &Flow,
1093 state: &State,
1094 inputs: &InputManifest,
1095 ledger: &Ledger<'_>,
1096) -> String {
1097 token(&(
1098 "supercov-flow-v3",
1099 &inputs.context_digest,
1100 &a.id,
1101 site(&a.at, inputs),
1102 &a.observes,
1103 claim(f, inputs),
1104 footing(a, f, inputs),
1105 generation(a, f, state, ledger),
1106 ))
1107}
1108pub fn reasons(
1109 a: &Assertion,
1110 f: &Flow,
1111 map: &AssertionMap,
1112 state: &State,
1113 inputs: &Inputs,
1114) -> BTreeSet<String> {
1115 reasons_for_manifest(a, f, map, state, inputs, &inputs.manifest())
1116}
1117pub fn reasons_for_manifest(
1118 a: &Assertion,
1119 f: &Flow,
1120 map: &AssertionMap,
1121 state: &State,
1122 inputs: &Inputs,
1123 manifest: &InputManifest,
1124) -> BTreeSet<String> {
1125 reasons_with(
1126 a,
1127 f,
1128 state,
1129 inputs,
1130 manifest,
1131 &Ledger::new(map, state, manifest),
1132 )
1133}
1134pub fn reasons_with(
1135 a: &Assertion,
1136 f: &Flow,
1137 state: &State,
1138 inputs: &Inputs,
1139 manifest: &InputManifest,
1140 ledger: &Ledger<'_>,
1141) -> BTreeSet<String> {
1142 let mut reasons = BTreeSet::new();
1143 if state.schema_version != 3 || state.inputs_digest != ledger.inputs_digest {
1144 reasons.insert("state does not match run inputs".into());
1145 }
1146 if f.basis.as_deref() != Some(expected_basis_with(a, f, state, manifest, ledger).as_str()) {
1147 let recorded = state.flows.get(&flow_key(a, f));
1148 match f.basis.as_deref() {
1149 None => {
1150 reasons.insert("draft: input acknowledgement not recorded".into());
1151 }
1152 Some(basis) if superseded_basis(basis) => {
1153 reasons.insert(SUPERSEDED_BASIS.into());
1154 }
1155 Some(_) => {
1156 let parts = recorded
1160 .map(|s| moved(&s.footprint, &footprint(a, f, manifest)))
1161 .unwrap_or_default();
1162 if parts.is_empty() {
1163 reasons.insert("claim or inputs changed; needs rechecking".into());
1164 } else {
1165 reasons.extend(parts);
1166 }
1167 }
1168 }
1169 if let Some(s) = recorded {
1170 reasons.extend(s.reasons.iter().cloned());
1171 }
1172 }
1173 reasons.extend(validate_flow(f, &inputs.files));
1174 if a.at.offset(&inputs.files).is_none() {
1175 reasons.insert("invalid assertion anchor".into());
1176 }
1177 if !f.questions.is_empty() {
1178 reasons.insert("flow has unresolved questions".into());
1179 }
1180 reasons
1181}
1182pub fn validation(map: &AssertionMap, state: &State, inputs: &Inputs) -> serde_json::Value {
1184 use serde_json::json;
1185 let manifest = inputs.manifest();
1186 let ledger = Ledger::new(map, state, &manifest);
1187 let mut errors = validate(map, inputs);
1188 for r in &map.change_assessments {
1189 if !state.changes.iter().any(|c| c.id == r.id) {
1190 errors.push(format!("{}: unknown change assessment", r.id));
1191 }
1192 }
1193 let selectors = map
1196 .assertions
1197 .iter()
1198 .flat_map(|a| a.flows.iter().map(move |f| (flow_key(a, f), &f.applies_to)))
1199 .collect::<BTreeMap<_, _>>();
1200 let changes = state.changes.iter().map(|c| {
1201 let response = map.change_assessments.iter().find(|r| r.id == c.id);
1202 let faults = response.map(|r| ledger.change_errors(r)).unwrap_or_default();
1203 errors.extend(faults.iter().map(|e| format!("{}: {e}", c.id)));
1204 let tests = c.exposed.iter().filter_map(|k| selectors.get(k)).flat_map(|t| t.iter()).collect::<BTreeSet<_>>();
1209 let shown = tests.iter().take(20).collect::<Vec<_>>();
1210 json!({"id":c.id,"file":c.file,"before":c.before,"after":c.after,"reason":c.reason,
1211 "knownFlows":{"flows":c.known_flows.len(),"sample":c.known_flows.iter().take(8).collect::<Vec<_>>()},
1212 "exposed":{"flows":c.exposed.len(),"tests":shown,"testCount":tests.len(),"sample":c.exposed.iter().take(8).collect::<Vec<_>>()},
1213 "current":ledger.current(&c.id),"assessment":response,"errors":faults,
1214 "expectedBasis":response.map(|r| ledger.expected_change_basis(c,r))})
1215 }).collect::<Vec<_>>();
1216 let flows = map.assertions.iter().flat_map(|a| a.flows.iter().map(move |f| (a,f))).map(|(a,f)| {
1217 json!({"id":flow_key(a,f),"expectedBasis":expected_basis_with(a,f,state,&manifest,&ledger),"reasons":reasons_with(a,f,state,inputs,&manifest,&ledger)})
1218 }).collect::<Vec<_>>();
1219 json!({"valid":errors.is_empty(),"stage":"references","errors":errors,"flows":flows,"changes":changes,
1220 "meaning":"Authored graph references and input acknowledgements only; no semantic proof or completeness claim"})
1221}
1222pub fn invalidate(state: &mut State, map: &AssertionMap, reason: &str) {
1223 for a in &map.assertions {
1224 for f in &a.flows {
1225 let key = flow_key(a, f);
1226 let previous = state.flows.get(&key);
1227 let base = previous.map_or("0", |s| s.generation.as_str());
1228 let footprint = previous.map(|s| s.footprint.clone()).unwrap_or_default();
1231 let generation = digest(&(base, reason, &state.inputs_digest));
1232 state.flows.insert(
1233 key,
1234 FlowState {
1235 generation,
1236 reasons: BTreeSet::from([reason.into()]),
1237 notices: BTreeSet::new(),
1238 footprint,
1239 },
1240 );
1241 }
1242 }
1243}
1244pub fn add_change(
1245 state: &mut State,
1246 file: Option<String>,
1247 before: Option<String>,
1248 after: Option<String>,
1249 reason: String,
1250 known_flows: BTreeSet<String>,
1251 exposed: BTreeSet<String>,
1252) {
1253 let id = format!(
1255 "c_{}",
1256 &digest(&(
1257 "supercov-change-id-v2",
1258 &state.changes,
1259 &file,
1260 &before,
1261 &after,
1262 &reason
1263 ))[..24]
1264 );
1265 state.changes.push(Change {
1266 id,
1267 file,
1268 before,
1269 after,
1270 reason,
1271 known_flows,
1272 exposed,
1273 });
1274}
1275
1276fn unique_occurrence(text: &str, snippet: &str) -> Option<usize> {
1277 if snippet.is_empty() {
1278 return None;
1279 }
1280 let first = text.find(snippet)?;
1281 let next = first + text[first..].chars().next()?.len_utf8();
1283 text[next..]
1284 .contains(snippet)
1285 .then_some(())
1286 .map_or(Some(first), |_| None)
1287}
1288fn target_file(file: &str, old: &FileManifest, new: &Files) -> Option<String> {
1289 if new.contains_key(file) {
1290 return Some(file.into());
1291 }
1292 let hash = old.get(file)?;
1293 let mut matches = new
1294 .iter()
1295 .filter(|(_, s)| FileFingerprint::of(s).same_bytes(hash));
1296 let first = matches.next()?.0;
1297 matches.next().is_none().then(|| first.clone())
1298}
1299pub fn relocate(at: &Anchor, old: &FileManifest, new: &Files) -> Option<Anchor> {
1300 let before = old.get(&at.file)?;
1301 let target = target_file(&at.file, old, new)?;
1302 let after = &new[&target];
1303 let mut candidate = at.clone();
1304 candidate.file.clone_from(&target);
1305 if FileFingerprint::of(after).same_bytes(before) && candidate.offset(new).is_some() {
1306 return Some(candidate);
1307 }
1308 if let Some(start) = candidate.offset(new)
1318 && after.get(start..start + at.text.len()) == Some(at.text.as_str())
1319 {
1320 return Some(candidate);
1321 }
1322 let position = unique_occurrence(after, &at.text)?;
1323 Some(Anchor::new(
1324 &target,
1325 after,
1326 position,
1327 position + at.text.len(),
1328 ))
1329}
1330
1331pub enum FileChange<'a> {
1334 Same,
1335 CommentsOnly,
1337 Added,
1339 Removed,
1340 Bytes,
1342 Code {
1343 before: &'a Code,
1344 after: &'a Code,
1345 diff: Diff,
1346 narrow: bool,
1349 },
1350}
1351pub fn file_change<'a>(
1352 before: Option<&'a FileFingerprint>,
1353 after: Option<&'a FileFingerprint>,
1354 probed: Option<&[usize]>,
1355) -> Option<FileChange<'a>> {
1356 let Some(before) = before else {
1357 return after.map(|_| FileChange::Added);
1358 };
1359 let Some(after) = after else {
1360 return Some(FileChange::Removed);
1361 };
1362 if before.same_bytes(after) {
1363 return Some(FileChange::Same);
1364 }
1365 let (Some(old), Some(new)) = (&before.code, &after.code) else {
1366 return Some(FileChange::Bytes);
1367 };
1368 if old.semantic == new.semantic {
1369 return Some(FileChange::CommentsOnly);
1370 }
1371 let diff = old.diff(new);
1372 let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
1373 Some(FileChange::Code {
1374 before: old,
1375 after: new,
1376 diff,
1377 narrow,
1378 })
1379}
1380pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
1382 let mut parts = Vec::new();
1383 if !diff.changed.is_empty() {
1384 parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
1385 }
1386 if !diff.added.is_empty() {
1387 parts.push(format!(
1388 "added {}",
1389 named(diff.added.iter().map(|i| &after.units[*i]))
1390 ));
1391 }
1392 if !diff.removed.is_empty() {
1393 parts.push(format!(
1394 "removed {}",
1395 named(diff.removed.iter().map(|i| &before.units[*i]))
1396 ));
1397 }
1398 if parts.is_empty() {
1399 "declarations".to_owned()
1400 } else {
1401 parts.join("; ")
1402 }
1403}
1404fn executed<'s>(
1408 records: &BTreeMap<&TestSelector, &'s Execution>,
1409 f: &Flow,
1410 manifest: &InputManifest,
1411) -> Option<BTreeMap<&'s str, BTreeSet<usize>>> {
1412 let mut out: BTreeMap<&str, BTreeSet<usize>> = BTreeMap::new();
1413 for selector in &f.applies_to {
1414 let record = records.get(selector)?;
1415 for (file, units) in &record.files {
1416 let code = manifest.files.get(file).and_then(|fp| fp.code.as_ref());
1417 let set = out.entry(file.as_str()).or_default();
1418 for &unit in units {
1419 match code {
1420 Some(code) if unit < code.units.len() => set.extend(code.ancestors(unit)),
1421 _ => {
1422 set.insert(unit);
1423 }
1424 }
1425 }
1426 }
1427 }
1428 Some(out)
1429}
1430
1431pub fn carry(
1447 map: &AssertionMap,
1448 state: &State,
1449 old: &InputManifest,
1450 new: &Inputs,
1451 evidence_digest: &str,
1452 context_changed: bool,
1453) -> Result<(AssertionMap, State), String> {
1454 if map.schema_version != 2 || old.schema_version != 2 || new.schema_version != 1 {
1455 return Err("unsupported map/input schema version".into());
1456 }
1457 if state.inputs_digest != digest(old) || state.schema_version != 3 {
1458 return Err("old map state does not match its run inputs".into());
1459 }
1460 let ledger = Ledger::new(map, state, old);
1461 let new_manifest = new.manifest();
1462 let (mut next, mut next_state) = seed_manifest(&new_manifest, evidence_digest);
1463 next.assertions.clear();
1464 next.retired_assertions = map.retired_assertions.clone();
1465 next_state.changes = state
1466 .changes
1467 .iter()
1468 .filter(|c| !ledger.current(&c.id))
1469 .cloned()
1470 .collect();
1471 next.change_assessments = map
1472 .change_assessments
1473 .iter()
1474 .filter(|r| next_state.changes.iter().any(|c| c.id == r.id))
1475 .cloned()
1476 .collect();
1477 let records = state
1478 .executions
1479 .iter()
1480 .flat_map(|e| e.tests.iter().map(|t| (&t.test, t)))
1481 .collect::<BTreeMap<_, _>>();
1482 let probed = |file: &str| {
1483 state
1484 .executions
1485 .as_ref()
1486 .and_then(|e| e.probed.get(file))
1487 .map(Vec::as_slice)
1488 };
1489 let changes = old
1490 .files
1491 .keys()
1492 .chain(new_manifest.files.keys())
1493 .collect::<BTreeSet<_>>()
1494 .into_iter()
1495 .filter_map(|file| {
1496 file_change(
1497 old.files.get(file),
1498 new_manifest.files.get(file),
1499 probed(file),
1500 )
1501 .map(|change| (file.as_str(), change))
1502 })
1503 .collect::<BTreeMap<_, _>>();
1504 let mut marked: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1508 let mut exposed: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
1509 let mut consumed = BTreeSet::new();
1510 let exact = map
1511 .assertions
1512 .iter()
1513 .map(|a| {
1514 relocate(&a.at, &old.files, &new.files).filter(|at| {
1515 new.assertions.iter().any(|s| &s.at == at)
1516 || !old.assertions.iter().any(|s| s.at == a.at)
1517 })
1518 })
1519 .collect::<Vec<_>>();
1520 let reserved = exact.iter().flatten().collect::<BTreeSet<_>>();
1521 for (index, a) in map.assertions.iter().enumerate() {
1522 let candidates = new
1525 .assertions
1526 .iter()
1527 .filter(|s| s.at.file == a.at.file && !reserved.contains(&s.at))
1528 .collect::<Vec<_>>();
1529 let unmatched = map
1530 .assertions
1531 .iter()
1532 .zip(&exact)
1533 .filter(|(other, at)| other.at.file == a.at.file && at.is_none())
1534 .count();
1535 let replacement = if exact[index].is_none() && unmatched == 1 && candidates.len() == 1 {
1536 Some(&candidates[0].at)
1537 } else {
1538 None
1539 };
1540 let matched = exact[index]
1541 .as_ref()
1542 .or(replacement)
1543 .filter(|at| !consumed.contains(*at));
1544 let Some(at) = matched else {
1545 next.retired_assertions.push(Retired {
1546 assertion: a.clone(),
1547 reason:
1548 "assertion removed, changed or ambiguous; reuse its explanation after review"
1549 .into(),
1550 });
1551 continue;
1552 };
1553 consumed.insert(at.clone());
1554 let mut updated = a.clone();
1555 updated.at = at.clone();
1556 for (prior, f) in a.flows.iter().zip(&mut updated.flows) {
1557 let key = flow_key(a, f);
1558 let base = generation(a, prior, state, &ledger);
1559 let mut dirty = BTreeSet::new();
1560 let mut notices = BTreeSet::new();
1561 match prior.basis.as_deref() {
1562 Some(basis) if superseded_basis(basis) => {
1563 dirty.insert(SUPERSEDED_BASIS.into());
1564 }
1565 Some(basis) if basis != expected_basis_with(a, prior, state, old, &ledger) => {
1566 dirty.insert("inherited claim still needs rechecking".into());
1567 }
1568 _ => {}
1569 }
1570 match executed(&records, prior, old) {
1574 Some(ran) => {
1575 for (file, units) in &ran {
1576 let reached = match changes.get(file) {
1577 None
1578 | Some(
1579 FileChange::Same | FileChange::CommentsOnly | FileChange::Added,
1580 ) => false,
1581 Some(FileChange::Removed | FileChange::Bytes) => true,
1582 Some(FileChange::Code { diff, narrow, .. }) => {
1583 !*narrow || diff.changed.iter().any(|i| units.contains(i))
1584 }
1585 };
1586 if reached {
1587 exposed.entry(file).or_default().insert(key.clone());
1588 }
1589 }
1590 }
1591 None => {
1592 for (file, change) in &changes {
1593 if !matches!(
1594 change,
1595 FileChange::Same | FileChange::CommentsOnly | FileChange::Added
1596 ) {
1597 exposed.entry(file).or_default().insert(key.clone());
1598 }
1599 }
1600 }
1601 }
1602 for file in dependencies(a, prior) {
1606 let roles = roles(a, prior, file);
1607 let verdict = match changes.get(file) {
1608 None => Some(format!(
1609 "{file} is not among the run's inputs{}",
1610 in_role(&roles)
1611 )),
1612 Some(FileChange::Added) => Some(format!(
1613 "{file} is new since the previous run{}",
1614 in_role(&roles)
1615 )),
1616 Some(FileChange::Same | FileChange::CommentsOnly) => None,
1617 Some(FileChange::Removed) => Some(format!("{file} removed{}", in_role(&roles))),
1618 Some(FileChange::Bytes) => Some(format!("{file} changed{}", in_role(&roles))),
1619 Some(FileChange::Code {
1620 before,
1621 after,
1622 diff,
1623 ..
1624 }) => {
1625 if whole_file(a, prior, file) {
1626 let mut why = whole_file_roles(a, prior, file);
1634 let sites = node_sites(prior, file);
1635 if !sites.is_empty() {
1636 let holders = prior
1637 .nodes
1638 .iter()
1639 .filter(|n| n.at.file == file)
1640 .map(|n| before.unit_at(n.at.line, n.at.column))
1641 .collect::<BTreeSet<_>>();
1642 why.push(format!(
1643 "this flow's {} sits in {}",
1644 sites.join(", "),
1645 named(holders.iter().map(|i| &before.units[*i]))
1646 ));
1647 }
1648 Some(format!(
1649 "{file}: {} changed{}",
1650 describe(before, after, diff),
1651 in_role(&why)
1652 ))
1653 } else {
1654 let holders = prior
1655 .nodes
1656 .iter()
1657 .filter(|n| n.at.file == file)
1658 .flat_map(|n| {
1659 before.ancestors(before.unit_at(n.at.line, n.at.column))
1660 })
1661 .collect::<BTreeSet<_>>();
1662 let moved = holders
1663 .iter()
1664 .filter(|i| diff.changed.contains(i) || diff.removed.contains(i))
1665 .map(|i| &before.units[*i])
1666 .collect::<Vec<_>>();
1667 if !moved.is_empty() {
1668 Some(format!(
1669 "{file}: {} changed{}",
1670 named(moved),
1671 in_role(&roles)
1672 ))
1673 } else if diff.structural {
1674 Some(format!(
1675 "{file}: declarations changed, {}{}",
1676 describe(before, after, diff),
1677 in_role(&roles)
1678 ))
1679 } else {
1680 notices.insert(format!(
1681 "{file} changed outside this flow's nodes: {}",
1682 describe(before, after, diff)
1683 ));
1684 None
1685 }
1686 }
1687 }
1688 };
1689 if let Some(reason) = verdict {
1690 dirty.insert(reason);
1691 marked.entry(file).or_default().insert(key.clone());
1692 }
1693 }
1694 if replacement.is_some() {
1695 dirty.insert("assertion changed or replaced; confirm identity and meaning".into());
1696 }
1697 for node in &mut f.nodes {
1698 if let Some(at) = relocate(&node.at, &old.files, &new.files) {
1699 node.at = at;
1700 } else {
1701 dirty.insert(format!("node {} changed or ambiguous", node.id));
1702 }
1703 }
1704 for file in f
1705 .watch
1706 .iter_mut()
1707 .chain(f.applies_to.iter_mut().map(|t| &mut t.file))
1708 {
1709 if let Some(target) = target_file(file, &old.files, &new.files) {
1710 *file = target;
1711 } else {
1712 dirty.insert(format!("dependency file removed: {file}"));
1713 }
1714 }
1715 if context_changed {
1716 dirty.insert("run configuration, dependencies or execution context changed".into());
1717 }
1718 next_state.flows.insert(
1719 key,
1720 FlowState {
1721 generation: if dirty.is_empty() {
1722 base
1723 } else {
1724 digest(&("supercov-carry-v3", base, &new_manifest, &dirty))
1725 },
1726 reasons: dirty,
1727 notices,
1728 footprint: footprint(a, f, &new_manifest),
1729 },
1730 );
1731 }
1732 next.assertions.push(updated);
1733 }
1734 let mut ids = map
1735 .assertions
1736 .iter()
1737 .map(|a| a.id.clone())
1738 .chain(
1739 map.retired_assertions
1740 .iter()
1741 .map(|r| r.assertion.id.clone()),
1742 )
1743 .collect::<BTreeSet<_>>();
1744 for a in seed(new, evidence_digest).0.assertions {
1745 if !consumed.contains(&a.at) {
1746 let mut a = a;
1747 while !ids.insert(a.id.clone()) {
1748 a.id.push('_');
1749 }
1750 next.assertions.push(a);
1751 }
1752 }
1753 for file in old
1754 .files
1755 .keys()
1756 .chain(new_manifest.files.keys())
1757 .collect::<BTreeSet<_>>()
1758 {
1759 if crate::integrity::tracked_manifest(file) {
1764 continue;
1765 }
1766 let exposed_to = exposed.get(file.as_str()).cloned().unwrap_or_default();
1767 match changes.get(file.as_str()) {
1768 Some(FileChange::Same | FileChange::CommentsOnly) => continue,
1770 Some(FileChange::Code { narrow: true, .. }) if exposed_to.is_empty() => continue,
1774 _ => {}
1775 }
1776 add_change(
1777 &mut next_state,
1778 Some(file.clone()),
1779 old.files.get(file).map(|f| f.sha256.clone()),
1780 new_manifest.files.get(file).map(|f| f.sha256.clone()),
1781 "captured source file changed".into(),
1782 marked.get(file.as_str()).cloned().unwrap_or_default(),
1783 exposed_to,
1784 );
1785 }
1786 next.assertions.sort_by(|a, b| a.at.cmp(&b.at));
1787 Ok((next, next_state))
1788}
1789
1790#[path = "assertion_legacy.rs"]
1791mod legacy;
1792pub fn parse_stored(bytes: &[u8]) -> Result<AssertionMap, String> {
1793 let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1794 match value
1795 .get("schemaVersion")
1796 .and_then(serde_json::Value::as_u64)
1797 {
1798 None | Some(1) => legacy::import(bytes),
1799 _ => parse(bytes).map_err(|e| e.to_string()),
1800 }
1801}
1802pub fn parse_state(
1803 bytes: &[u8],
1804 map: &AssertionMap,
1805 inputs: &InputManifest,
1806 evidence: &str,
1807 legacy_digest: Option<&str>,
1808) -> Result<State, String> {
1809 let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1810 if value["schemaVersion"] == 3 {
1811 let state: State = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1812 if state.inputs_digest != digest(inputs) || state.evidence_digest != evidence {
1813 return Err("Assertion state belongs to different run evidence; rerun tests".into());
1814 }
1815 Ok(state)
1816 } else {
1817 legacy::state(bytes, map, inputs, evidence, legacy_digest)
1818 }
1819}