use crate::source_units::{Code, Diff, named};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
pub type Files = BTreeMap<String, String>;
pub fn digest(value: &impl Serialize) -> String {
format!(
"{:x}",
Sha256::digest(serde_json::to_vec(value).expect("serializable map"))
)
}
fn version() -> u32 {
1
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Anchor {
pub file: String,
pub line: usize,
pub column: usize,
pub text: String,
}
pub fn local_path(file: &str) -> bool {
!file.is_empty()
&& !file.contains(['\\', ':'])
&& file.split('/').all(|p| !matches!(p, "" | "." | ".."))
}
impl Anchor {
pub fn new(file: &str, source: &str, start: usize, end: usize) -> Self {
Self {
file: file.into(),
line: source[..start].bytes().filter(|b| *b == b'\n').count() + 1,
column: start - source[..start].rfind('\n').map_or(0, |n| n + 1) + 1,
text: source[start..end].into(),
}
}
pub fn offset(&self, files: &Files) -> Option<usize> {
if !local_path(&self.file) || self.text.is_empty() || self.line == 0 || self.column == 0 {
return None;
}
let source = files.get(&self.file)?;
let start = source
.split_inclusive('\n')
.take(self.line - 1)
.map(str::len)
.sum::<usize>();
if source[..start].bytes().filter(|b| *b == b'\n').count() != self.line - 1 {
return None;
}
let line = source.get(start..)?.split('\n').next()?;
if self.column - 1 > line.len() {
return None;
}
let pos = start.checked_add(self.column - 1)?;
source.get(pos..)?.starts_with(&self.text).then_some(pos)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InventorySite {
pub at: Anchor,
pub operation: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Inputs {
#[serde(default = "version")]
pub schema_version: u32,
pub language: String,
pub context_digest: String,
pub files: Files,
pub assertions: Vec<InventorySite>,
pub limitations: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct FileFingerprint {
pub sha256: String,
pub bytes: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<Code>,
}
impl FileFingerprint {
pub fn of(source: &str) -> Self {
Self {
sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
bytes: source.len(),
code: None,
}
}
pub fn read(path: &str, source: &str) -> Self {
let mut fingerprint = Self::of(source);
fingerprint.code = crate::source_units::code(path, source);
fingerprint
}
pub fn same_bytes(&self, other: &Self) -> bool {
self.sha256 == other.sha256
}
}
pub type FileManifest = BTreeMap<String, FileFingerprint>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InputManifest {
pub schema_version: u32,
pub language: String,
pub context_digest: String,
pub files: FileManifest,
pub assertions: Vec<InventorySite>,
pub limitations: Vec<String>,
}
impl Inputs {
pub fn manifest(&self) -> InputManifest {
InputManifest {
schema_version: 2,
language: self.language.clone(),
context_digest: self.context_digest.clone(),
files: self
.files
.iter()
.map(|(p, s)| (p.clone(), FileFingerprint::read(p, s)))
.collect(),
assertions: self.assertions.clone(),
limitations: self.limitations.clone(),
}
}
pub fn identity(&self) -> String {
digest(&self.manifest())
}
}
impl InputManifest {
pub fn with_sources(&self, files: Files) -> Inputs {
Inputs {
schema_version: 1,
language: self.language.clone(),
context_digest: self.context_digest.clone(),
files,
assertions: self.assertions.clone(),
limitations: self.limitations.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Node {
pub id: String,
pub at: Anchor,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub role: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub meaning: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Edge {
pub from: String,
pub to: String,
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub basis: String,
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TestSelector {
pub file: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Flow {
pub id: String,
#[serde(deserialize_with = "required_basis")]
#[schemars(required, schema_with = "basis_schema")]
pub basis: Option<String>,
pub explanation: String,
pub applies_to: Vec<TestSelector>,
pub nodes: Vec<Node>,
#[serde(default)]
pub edges: Vec<Edge>,
pub counts_as_asserted: Vec<String>,
pub watch: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub questions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Assertion {
pub id: String,
pub at: Anchor,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub questions: Vec<String>,
#[serde(default)]
pub observes: Vec<String>,
#[serde(default)]
pub flows: Vec<Flow>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Retired {
pub assertion: Assertion,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AssertionMap {
#[schemars(range(min = 2, max = 2))]
pub schema_version: u32,
pub assertions: Vec<Assertion>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub change_assessments: Vec<ChangeAssessment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub retired_assertions: Vec<Retired>,
}
fn required_basis<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
let value = Option::<String>::deserialize(d)?;
if value.as_deref().is_some_and(|s| !valid_basis(s)) {
return Err(serde::de::Error::custom(
"expected null or scov3:<64 lowercase hex digits>",
));
}
Ok(value)
}
fn basis_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({"type":["string","null"],"pattern":"^scov[23]:[0-9a-f]{64}$"})
}
fn valid_basis(s: &str) -> bool {
s.strip_prefix("scov3:")
.or_else(|| s.strip_prefix("scov2:"))
.is_some_and(|h| {
h.len() == 64
&& h.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
pub fn superseded_basis(s: &str) -> bool {
s.starts_with("scov2:")
}
pub const SUPERSEDED_BASIS: &str = "acknowledged under an earlier Supercov basis format; reread the claim and copy the current expectedBasis";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChangeAssessment {
pub id: String,
#[serde(deserialize_with = "required_basis")]
#[schemars(required, schema_with = "basis_schema")]
pub basis: Option<String>,
pub affected_flows: Vec<String>,
pub explanation: String,
}
pub fn schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(AssertionMap)).expect("schema")
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ParseError {
pub pointer: String,
pub line: usize,
pub column: usize,
pub message: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} at {} (JSON line {}, column {})",
self.message, self.pointer, self.line, self.column
)
}
}
pub fn parse(bytes: &[u8]) -> Result<AssertionMap, ParseError> {
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
let map: AssertionMap = serde_path_to_error::deserialize(&mut deserializer).map_err(|e| {
let pointer = e
.path()
.iter()
.map(|segment| {
use serde_path_to_error::Segment;
let part = match segment {
Segment::Seq { index } => index.to_string(),
Segment::Map { key } => key.clone(),
Segment::Enum { variant } => variant.clone(),
Segment::Unknown => "?".into(),
};
format!("/{}", part.replace('~', "~0").replace('/', "~1"))
})
.collect();
ParseError {
pointer,
line: e.inner().line(),
column: e.inner().column(),
message: e.inner().to_string(),
}
})?;
deserializer.end().map_err(|e| ParseError {
pointer: String::new(),
line: e.line(),
column: e.column(),
message: e.to_string(),
})?;
if map.schema_version != 2 {
return Err(ParseError {
pointer: "/schemaVersion".into(),
line: 0,
column: 0,
message: "unsupported map schema version; expected 2".into(),
});
}
Ok(map)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct FlowState {
pub generation: String,
pub reasons: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub notices: BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Executions {
pub tests: Vec<Execution>,
pub probed: BTreeMap<String, Vec<usize>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Execution {
pub test: TestSelector,
pub passed: bool,
pub files: BTreeMap<String, Vec<usize>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Change {
pub id: String,
pub file: Option<String>,
pub before: Option<String>,
pub after: Option<String>,
pub reason: String,
pub known_flows: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub exposed: BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct State {
pub schema_version: u32,
pub inputs_digest: String,
pub evidence_digest: String,
pub flows: BTreeMap<String, FlowState>,
pub changes: Vec<Change>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inheritance: Option<Inheritance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executions: Option<Executions>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Inheritance {
pub from: Option<String>,
pub skipped: Vec<SkippedMap>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SkippedMap {
pub run: String,
pub reason: String,
}
pub fn flow_key(a: &Assertion, f: &Flow) -> String {
format!("{}/{}", a.id, f.id)
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
}
pub fn seed(inputs: &Inputs, evidence_digest: &str) -> (AssertionMap, State) {
seed_manifest(&inputs.manifest(), evidence_digest)
}
pub fn seed_manifest(inputs: &InputManifest, evidence_digest: &str) -> (AssertionMap, State) {
(
AssertionMap {
schema_version: 2,
assertions: inputs
.assertions
.iter()
.map(|site| Assertion {
id: format!("a_{}", &digest(&site.at)[..20]),
at: site.at.clone(),
questions: vec![],
observes: vec![],
flows: vec![],
})
.collect(),
change_assessments: vec![],
retired_assertions: vec![],
},
State {
schema_version: 3,
inputs_digest: digest(inputs),
evidence_digest: evidence_digest.into(),
flows: BTreeMap::new(),
changes: vec![],
inheritance: None,
executions: None,
},
)
}
pub fn validate(map: &AssertionMap, inputs: &Inputs) -> Vec<String> {
let mut errors = Vec::new();
if map.schema_version != 2 || inputs.schema_version != 1 {
errors.push("unsupported schema version".into());
}
let mut ids = BTreeSet::new();
let mut sites = BTreeSet::new();
for a in &map.assertions {
if !valid_id(&a.id) || !ids.insert(&a.id) {
errors.push(format!("{}: invalid/duplicate assertion ID", a.id));
}
if !sites.insert(&a.at) {
errors.push(format!("{}: duplicate assertion location", a.id));
}
if a.at.offset(&inputs.files).is_none() {
errors.push(format!("{}: invalid assertion anchor", a.id));
}
let mut flows = BTreeSet::new();
for f in &a.flows {
let key = flow_key(a, f);
if !valid_id(&f.id) || !flows.insert(&f.id) {
errors.push(format!("{key}: invalid/duplicate flow ID"));
}
errors.extend(
validate_flow(f, &inputs.files)
.into_iter()
.map(|e| format!("{key}: {e}")),
);
}
}
let mut changes = BTreeSet::new();
for change in &map.change_assessments {
if !valid_id(&change.id) || !changes.insert(&change.id) {
errors.push("invalid/duplicate change assessment ID".into());
}
}
errors
}
pub fn advisories(map: &AssertionMap) -> Vec<String> {
let mut out = Vec::new();
for a in &map.assertions {
for f in &a.flows {
for file in &f.watch {
if crate::integrity::globally_tracked(file) {
out.push(format!(
"{}: watch \"{file}\" is redundant; Supercov invalidates every flow when that file changes",
flow_key(a, f)
));
}
}
}
}
out
}
pub fn validate_flow(flow: &Flow, files: &Files) -> Vec<String> {
let mut errors = Vec::new();
let mut nodes = BTreeSet::new();
for node in &flow.nodes {
if !valid_id(&node.id) || !nodes.insert(&node.id) {
errors.push("invalid/duplicate node ID".into());
}
if node.at.offset(files).is_none() {
errors.push(format!("node {}: invalid anchor", node.id));
}
}
for edge in &flow.edges {
if !nodes.contains(&edge.from) || (!nodes.contains(&edge.to) && edge.to != "$assertion") {
errors.push("dangling edge".into());
}
}
if flow.counts_as_asserted.iter().any(|id| !nodes.contains(id)) {
errors.push("unknown counted node".into());
}
let mut reaches = BTreeSet::from(["$assertion".to_owned()]);
loop {
let size = reaches.len();
for edge in &flow.edges {
if reaches.contains(&edge.to) {
reaches.insert(edge.from.clone());
}
}
if reaches.len() == size {
break;
}
}
for id in &flow.counts_as_asserted {
if !reaches.contains(id) {
errors.push(format!(
"counted node {id} has no authored path to $assertion"
));
}
}
if flow.edges.iter().any(|e| e.kind.trim().is_empty()) {
errors.push("missing edge kind".into());
}
if flow
.applies_to
.iter()
.any(|t| !local_path(&t.file) || !files.contains_key(&t.file) || t.name.trim().is_empty())
{
errors.push("invalid test selector file or name".into());
}
if flow.applies_to.iter().collect::<BTreeSet<_>>().len() != flow.applies_to.len() {
errors.push("duplicate test selector".into());
}
if flow
.counts_as_asserted
.iter()
.collect::<BTreeSet<_>>()
.len()
!= flow.counts_as_asserted.len()
{
errors.push("duplicate counted node".into());
}
if flow.explanation.trim().is_empty() {
errors.push("missing explanation".into());
}
for file in &flow.watch {
if !local_path(file) || !files.contains_key(file) {
errors.push(format!("watched file missing: {file}"));
}
}
errors
}
pub fn dependencies<'a>(a: &'a Assertion, f: &'a Flow) -> BTreeSet<&'a str> {
std::iter::once(a.at.file.as_str())
.chain(f.applies_to.iter().map(|t| t.file.as_str()))
.chain(f.nodes.iter().map(|n| n.at.file.as_str()))
.chain(
f.watch
.iter()
.map(String::as_str)
.filter(|path| !crate::integrity::globally_tracked(path)),
)
.collect()
}
fn token(value: &impl Serialize) -> String {
format!("scov3:{}", digest(value))
}
fn flow_keys(map: &AssertionMap) -> BTreeSet<String> {
map.assertions
.iter()
.flat_map(|a| a.flows.iter().map(move |f| flow_key(a, f)))
.collect()
}
pub fn change_errors(
map: &AssertionMap,
change: &Change,
response: &ChangeAssessment,
) -> Vec<String> {
change_errors_with(&flow_keys(map), change, response)
}
fn change_errors_with(
keys: &BTreeSet<String>,
change: &Change,
response: &ChangeAssessment,
) -> Vec<String> {
let affected = response
.affected_flows
.iter()
.cloned()
.collect::<BTreeSet<_>>();
let mut errors = Vec::new();
if response.explanation.trim().is_empty() {
errors.push("missing impact explanation".into());
}
if affected.len() != response.affected_flows.len() {
errors.push("duplicate affected flow".into());
}
if !affected.is_subset(keys) {
errors.push("unknown affected flow".into());
}
if !change
.known_flows
.intersection(keys)
.all(|k| affected.contains(k))
{
errors.push("known dependent flows must be included unless removed from the map".into());
}
errors
}
pub fn expected_change_basis(
change: &Change,
response: &ChangeAssessment,
inputs: &InputManifest,
) -> String {
expected_change_basis_with(change, response, &digest(inputs))
}
fn expected_change_basis_with(
change: &Change,
response: &ChangeAssessment,
inputs_digest: &str,
) -> String {
token(&(
"supercov-change-v2",
change,
inputs_digest,
&response.id,
&response.affected_flows,
&response.explanation,
))
}
pub fn change_current(map: &AssertionMap, change: &Change, inputs: &InputManifest) -> bool {
Ledger::with_changes(map, std::slice::from_ref(change), inputs).current(&change.id)
}
pub struct Ledger<'a> {
pub inputs_digest: String,
keys: BTreeSet<String>,
current: BTreeMap<&'a str, (&'a Option<String>, &'a [String])>,
}
impl<'a> Ledger<'a> {
pub fn new(map: &'a AssertionMap, state: &'a State, inputs: &InputManifest) -> Self {
Self::with_changes(map, &state.changes, inputs)
}
fn with_changes(map: &'a AssertionMap, changes: &'a [Change], inputs: &InputManifest) -> Self {
let inputs_digest = digest(inputs);
let keys = flow_keys(map);
let current = changes
.iter()
.filter_map(|change| {
let responses = map
.change_assessments
.iter()
.filter(|r| r.id == change.id)
.collect::<Vec<_>>();
match responses.as_slice() {
[r] if change_errors_with(&keys, change, r).is_empty()
&& r.basis.as_deref()
== Some(
expected_change_basis_with(change, r, &inputs_digest).as_str(),
) =>
{
Some((change.id.as_str(), (&r.basis, r.affected_flows.as_slice())))
}
_ => None,
}
})
.collect();
Self {
inputs_digest,
keys,
current,
}
}
pub fn current(&self, change: &str) -> bool {
self.current.contains_key(change)
}
pub fn expected_change_basis(&self, change: &Change, response: &ChangeAssessment) -> String {
expected_change_basis_with(change, response, &self.inputs_digest)
}
pub fn change_errors(&self, change: &Change, response: &ChangeAssessment) -> Vec<String> {
change_errors_with(&self.keys, change, response)
}
}
fn roles(a: &Assertion, f: &Flow, file: &str) -> Vec<String> {
let mut roles: Vec<String> = Vec::new();
let lines = f
.nodes
.iter()
.filter(|n| n.at.file == file)
.map(|n| format!("{}:{}", n.id, n.at.line))
.collect::<Vec<_>>();
if !lines.is_empty() {
roles.push(format!("holds this flow's {}", lines.join(", ")));
}
if a.at.file == file {
roles.push(format!("holds the assertion, line {}", a.at.line));
}
if f.applies_to.iter().any(|t| t.file == file) {
roles.push("is the test this claim applies to".to_owned());
}
if f.watch.iter().any(|w| w == file) {
roles.push("is watched by this flow".to_owned());
}
roles
}
fn in_role(roles: &[String]) -> String {
if roles.is_empty() {
String::new()
} else {
format!(" ({})", roles.join("; "))
}
}
fn whole_file(a: &Assertion, f: &Flow, file: &str) -> bool {
a.at.file == file
|| f.applies_to.iter().any(|t| t.file == file)
|| f.watch.iter().any(|w| w == file)
}
#[derive(Serialize)]
struct Site<'a> {
file: &'a str,
text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
unit: Option<&'a str>,
line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
column: Option<usize>,
}
fn site<'a>(at: &'a Anchor, inputs: &'a InputManifest) -> Site<'a> {
let code = inputs.files.get(&at.file).and_then(|f| f.code.as_ref());
let Some(code) = code else {
return Site {
file: &at.file,
text: &at.text,
unit: None,
line: at.line,
column: Some(at.column),
};
};
let holder = code.unit_at(at.line, at.column);
let mut boundary = code.units[holder].line;
for child in code.units.iter().filter(|u| u.parent == Some(holder)) {
if (child.end_line, child.end_column) <= (at.line, at.column) && child.end_line > boundary {
boundary = child.end_line;
}
}
Site {
file: &at.file,
text: &at.text,
unit: Some(&code.units[holder].path),
line: code
.code_line(at.line)
.saturating_sub(code.code_line(boundary)),
column: None,
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NodeClaim<'a> {
id: &'a str,
at: Site<'a>,
role: &'a str,
meaning: &'a str,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Claim<'a> {
id: &'a str,
explanation: &'a str,
applies_to: &'a [TestSelector],
nodes: Vec<NodeClaim<'a>>,
edges: &'a [Edge],
counts_as_asserted: &'a [String],
watch: &'a [String],
questions: &'a [String],
}
fn claim<'a>(f: &'a Flow, inputs: &'a InputManifest) -> Claim<'a> {
Claim {
id: &f.id,
explanation: &f.explanation,
applies_to: &f.applies_to,
nodes: f
.nodes
.iter()
.map(|n| NodeClaim {
id: &n.id,
at: site(&n.at, inputs),
role: &n.role,
meaning: &n.meaning,
})
.collect(),
edges: &f.edges,
counts_as_asserted: &f.counts_as_asserted,
watch: &f.watch,
questions: &f.questions,
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
enum Footing<'a> {
Absent,
Bytes(&'a str),
Semantic(&'a str),
Units {
structure: &'a str,
units: BTreeMap<&'a str, &'a str>,
},
}
fn footing<'a>(
a: &'a Assertion,
f: &'a Flow,
inputs: &'a InputManifest,
) -> BTreeMap<&'a str, Footing<'a>> {
dependencies(a, f)
.into_iter()
.map(|file| {
let Some(fingerprint) = inputs.files.get(file) else {
return (file, Footing::Absent);
};
let Some(code) = &fingerprint.code else {
return (file, Footing::Bytes(&fingerprint.sha256));
};
if whole_file(a, f, file) {
return (file, Footing::Semantic(&code.semantic));
}
let units = f
.nodes
.iter()
.filter(|n| n.at.file == file)
.flat_map(|n| code.ancestors(code.unit_at(n.at.line, n.at.column)))
.map(|i| (code.units[i].path.as_str(), code.units[i].digest.as_str()))
.collect();
(
file,
Footing::Units {
structure: &code.structure,
units,
},
)
})
.collect()
}
fn generation(a: &Assertion, f: &Flow, state: &State, ledger: &Ledger<'_>) -> String {
let key = flow_key(a, f);
let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
let impacts = ledger
.current
.iter()
.filter(|(_, (_, affected))| affected.contains(&key))
.map(|(id, (basis, _))| (*id, *basis))
.collect::<BTreeMap<_, _>>();
if impacts.is_empty() {
base.into()
} else {
digest(&("supercov-generation-v2", base, impacts))
}
}
pub fn expected_basis(
a: &Assertion,
f: &Flow,
map: &AssertionMap,
state: &State,
inputs: &InputManifest,
) -> String {
expected_basis_with(a, f, state, inputs, &Ledger::new(map, state, inputs))
}
pub fn expected_basis_with(
a: &Assertion,
f: &Flow,
state: &State,
inputs: &InputManifest,
ledger: &Ledger<'_>,
) -> String {
token(&(
"supercov-flow-v3",
&inputs.context_digest,
&a.id,
site(&a.at, inputs),
&a.observes,
claim(f, inputs),
footing(a, f, inputs),
generation(a, f, state, ledger),
))
}
pub fn reasons(
a: &Assertion,
f: &Flow,
map: &AssertionMap,
state: &State,
inputs: &Inputs,
) -> BTreeSet<String> {
reasons_for_manifest(a, f, map, state, inputs, &inputs.manifest())
}
pub fn reasons_for_manifest(
a: &Assertion,
f: &Flow,
map: &AssertionMap,
state: &State,
inputs: &Inputs,
manifest: &InputManifest,
) -> BTreeSet<String> {
reasons_with(
a,
f,
state,
inputs,
manifest,
&Ledger::new(map, state, manifest),
)
}
pub fn reasons_with(
a: &Assertion,
f: &Flow,
state: &State,
inputs: &Inputs,
manifest: &InputManifest,
ledger: &Ledger<'_>,
) -> BTreeSet<String> {
let mut reasons = BTreeSet::new();
if state.schema_version != 3 || state.inputs_digest != ledger.inputs_digest {
reasons.insert("state does not match run inputs".into());
}
if f.basis.as_deref() != Some(expected_basis_with(a, f, state, manifest, ledger).as_str()) {
reasons.insert(
match f.basis.as_deref() {
None => "draft: input acknowledgement not recorded",
Some(basis) if superseded_basis(basis) => SUPERSEDED_BASIS,
Some(_) => "claim or inputs changed; needs rechecking",
}
.into(),
);
if let Some(s) = state.flows.get(&flow_key(a, f)) {
reasons.extend(s.reasons.iter().cloned());
}
}
reasons.extend(validate_flow(f, &inputs.files));
if a.at.offset(&inputs.files).is_none() {
reasons.insert("invalid assertion anchor".into());
}
if !f.questions.is_empty() {
reasons.insert("flow has unresolved questions".into());
}
reasons
}
pub fn validation(map: &AssertionMap, state: &State, inputs: &Inputs) -> serde_json::Value {
use serde_json::json;
let manifest = inputs.manifest();
let ledger = Ledger::new(map, state, &manifest);
let mut errors = validate(map, inputs);
for r in &map.change_assessments {
if !state.changes.iter().any(|c| c.id == r.id) {
errors.push(format!("{}: unknown change assessment", r.id));
}
}
let selectors = map
.assertions
.iter()
.flat_map(|a| a.flows.iter().map(move |f| (flow_key(a, f), &f.applies_to)))
.collect::<BTreeMap<_, _>>();
let changes = state.changes.iter().map(|c| {
let response = map.change_assessments.iter().find(|r| r.id == c.id);
let faults = response.map(|r| ledger.change_errors(c, r)).unwrap_or_default();
errors.extend(faults.iter().map(|e| format!("{}: {e}", c.id)));
let tests = c.exposed.iter().filter_map(|k| selectors.get(k)).flat_map(|t| t.iter()).collect::<BTreeSet<_>>();
json!({"id":c.id,"file":c.file,"before":c.before,"after":c.after,"reason":c.reason,"knownFlows":c.known_flows,
"exposed":{"flows":c.exposed.len(),"tests":tests,"sample":c.exposed.iter().take(8).collect::<Vec<_>>()},
"current":ledger.current(&c.id),"assessment":response,"errors":faults,
"expectedBasis":response.map(|r| ledger.expected_change_basis(c,r))})
}).collect::<Vec<_>>();
let flows = map.assertions.iter().flat_map(|a| a.flows.iter().map(move |f| (a,f))).map(|(a,f)| {
json!({"id":flow_key(a,f),"expectedBasis":expected_basis_with(a,f,state,&manifest,&ledger),"reasons":reasons_with(a,f,state,inputs,&manifest,&ledger)})
}).collect::<Vec<_>>();
json!({"valid":errors.is_empty(),"stage":"references","errors":errors,"flows":flows,"changes":changes,
"meaning":"Authored graph references and input acknowledgements only; no semantic proof or completeness claim"})
}
pub fn invalidate(state: &mut State, map: &AssertionMap, reason: &str) {
for a in &map.assertions {
for f in &a.flows {
let key = flow_key(a, f);
let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
state.flows.insert(
key,
FlowState {
generation: digest(&(base, reason, &state.inputs_digest)),
reasons: BTreeSet::from([reason.into()]),
notices: BTreeSet::new(),
},
);
}
}
}
pub fn add_change(
state: &mut State,
file: Option<String>,
before: Option<String>,
after: Option<String>,
reason: String,
known_flows: BTreeSet<String>,
exposed: BTreeSet<String>,
) {
let id = format!(
"c_{}",
&digest(&(
"supercov-change-id-v2",
&state.changes,
&file,
&before,
&after,
&reason
))[..24]
);
state.changes.push(Change {
id,
file,
before,
after,
reason,
known_flows,
exposed,
});
}
fn unique_occurrence(text: &str, snippet: &str) -> Option<usize> {
if snippet.is_empty() {
return None;
}
let first = text.find(snippet)?;
let next = first + text[first..].chars().next()?.len_utf8();
text[next..]
.contains(snippet)
.then_some(())
.map_or(Some(first), |_| None)
}
fn target_file(file: &str, old: &FileManifest, new: &Files) -> Option<String> {
if new.contains_key(file) {
return Some(file.into());
}
let hash = old.get(file)?;
let mut matches = new
.iter()
.filter(|(_, s)| FileFingerprint::of(s).same_bytes(hash));
let first = matches.next()?.0;
matches.next().is_none().then(|| first.clone())
}
pub fn relocate(at: &Anchor, old: &FileManifest, new: &Files) -> Option<Anchor> {
let before = old.get(&at.file)?;
let target = target_file(&at.file, old, new)?;
let after = &new[&target];
let mut candidate = at.clone();
candidate.file.clone_from(&target);
if FileFingerprint::of(after).same_bytes(before) && candidate.offset(new).is_some() {
return Some(candidate);
}
if let Some(start) = candidate.offset(new)
&& after.get(start..start + at.text.len()) == Some(at.text.as_str())
{
return Some(candidate);
}
let position = unique_occurrence(after, &at.text)?;
Some(Anchor::new(
&target,
after,
position,
position + at.text.len(),
))
}
pub enum FileChange<'a> {
Same,
CommentsOnly,
Added,
Removed,
Bytes,
Code {
before: &'a Code,
after: &'a Code,
diff: Diff,
narrow: bool,
},
}
pub fn file_change<'a>(
before: Option<&'a FileFingerprint>,
after: Option<&'a FileFingerprint>,
probed: Option<&[usize]>,
) -> Option<FileChange<'a>> {
let Some(before) = before else {
return after.map(|_| FileChange::Added);
};
let Some(after) = after else {
return Some(FileChange::Removed);
};
if before.same_bytes(after) {
return Some(FileChange::Same);
}
let (Some(old), Some(new)) = (&before.code, &after.code) else {
return Some(FileChange::Bytes);
};
if old.semantic == new.semantic {
return Some(FileChange::CommentsOnly);
}
let diff = old.diff(new);
let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
Some(FileChange::Code {
before: old,
after: new,
diff,
narrow,
})
}
pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
let mut parts = Vec::new();
if !diff.changed.is_empty() {
parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
}
if !diff.added.is_empty() {
parts.push(format!(
"added {}",
named(diff.added.iter().map(|i| &after.units[*i]))
));
}
if !diff.removed.is_empty() {
parts.push(format!(
"removed {}",
named(diff.removed.iter().map(|i| &before.units[*i]))
));
}
if parts.is_empty() {
"declarations".to_owned()
} else {
parts.join("; ")
}
}
fn executed<'s>(
records: &BTreeMap<&TestSelector, &'s Execution>,
f: &Flow,
manifest: &InputManifest,
) -> Option<BTreeMap<&'s str, BTreeSet<usize>>> {
let mut out: BTreeMap<&str, BTreeSet<usize>> = BTreeMap::new();
for selector in &f.applies_to {
let record = records.get(selector)?;
for (file, units) in &record.files {
let code = manifest.files.get(file).and_then(|fp| fp.code.as_ref());
let set = out.entry(file.as_str()).or_default();
for &unit in units {
match code {
Some(code) if unit < code.units.len() => set.extend(code.ancestors(unit)),
_ => {
set.insert(unit);
}
}
}
}
}
Some(out)
}
pub fn carry(
map: &AssertionMap,
state: &State,
old: &InputManifest,
new: &Inputs,
evidence_digest: &str,
context_changed: bool,
) -> Result<(AssertionMap, State), String> {
if map.schema_version != 2 || old.schema_version != 2 || new.schema_version != 1 {
return Err("unsupported map/input schema version".into());
}
if state.inputs_digest != digest(old) || state.schema_version != 3 {
return Err("old map state does not match its run inputs".into());
}
let ledger = Ledger::new(map, state, old);
let new_manifest = new.manifest();
let (mut next, mut next_state) = seed_manifest(&new_manifest, evidence_digest);
next.assertions.clear();
next.retired_assertions = map.retired_assertions.clone();
next_state.changes = state
.changes
.iter()
.filter(|c| !ledger.current(&c.id))
.cloned()
.collect();
next.change_assessments = map
.change_assessments
.iter()
.filter(|r| next_state.changes.iter().any(|c| c.id == r.id))
.cloned()
.collect();
let records = state
.executions
.iter()
.flat_map(|e| e.tests.iter().map(|t| (&t.test, t)))
.collect::<BTreeMap<_, _>>();
let probed = |file: &str| {
state
.executions
.as_ref()
.and_then(|e| e.probed.get(file))
.map(Vec::as_slice)
};
let changes = old
.files
.keys()
.chain(new_manifest.files.keys())
.collect::<BTreeSet<_>>()
.into_iter()
.filter_map(|file| {
file_change(
old.files.get(file),
new_manifest.files.get(file),
probed(file),
)
.map(|change| (file.as_str(), change))
})
.collect::<BTreeMap<_, _>>();
let mut marked: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
let mut exposed: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
let mut consumed = BTreeSet::new();
let exact = map
.assertions
.iter()
.map(|a| {
relocate(&a.at, &old.files, &new.files).filter(|at| {
new.assertions.iter().any(|s| &s.at == at)
|| !old.assertions.iter().any(|s| s.at == a.at)
})
})
.collect::<Vec<_>>();
let reserved = exact.iter().flatten().collect::<BTreeSet<_>>();
for (index, a) in map.assertions.iter().enumerate() {
let candidates = new
.assertions
.iter()
.filter(|s| s.at.file == a.at.file && !reserved.contains(&s.at))
.collect::<Vec<_>>();
let unmatched = map
.assertions
.iter()
.zip(&exact)
.filter(|(other, at)| other.at.file == a.at.file && at.is_none())
.count();
let replacement = if exact[index].is_none() && unmatched == 1 && candidates.len() == 1 {
Some(&candidates[0].at)
} else {
None
};
let matched = exact[index]
.as_ref()
.or(replacement)
.filter(|at| !consumed.contains(*at));
let Some(at) = matched else {
next.retired_assertions.push(Retired {
assertion: a.clone(),
reason:
"assertion removed, changed or ambiguous; reuse its explanation after review"
.into(),
});
continue;
};
consumed.insert(at.clone());
let mut updated = a.clone();
updated.at = at.clone();
for (prior, f) in a.flows.iter().zip(&mut updated.flows) {
let key = flow_key(a, f);
let base = generation(a, prior, state, &ledger);
let mut dirty = BTreeSet::new();
let mut notices = BTreeSet::new();
match prior.basis.as_deref() {
Some(basis) if superseded_basis(basis) => {
dirty.insert(SUPERSEDED_BASIS.into());
}
Some(basis) if basis != expected_basis_with(a, prior, state, old, &ledger) => {
dirty.insert("inherited claim still needs rechecking".into());
}
_ => {}
}
match executed(&records, prior, old) {
Some(ran) => {
for (file, units) in &ran {
let reached = match changes.get(file) {
None
| Some(
FileChange::Same | FileChange::CommentsOnly | FileChange::Added,
) => false,
Some(FileChange::Removed | FileChange::Bytes) => true,
Some(FileChange::Code { diff, narrow, .. }) => {
!*narrow || diff.changed.iter().any(|i| units.contains(i))
}
};
if reached {
exposed.entry(file).or_default().insert(key.clone());
}
}
}
None => {
for (file, change) in &changes {
if !matches!(
change,
FileChange::Same | FileChange::CommentsOnly | FileChange::Added
) {
exposed.entry(file).or_default().insert(key.clone());
}
}
}
}
for file in dependencies(a, prior) {
let roles = roles(a, prior, file);
let verdict = match changes.get(file) {
None => Some(format!(
"{file} is not among the run's inputs{}",
in_role(&roles)
)),
Some(FileChange::Added) => Some(format!(
"{file} is new since the previous run{}",
in_role(&roles)
)),
Some(FileChange::Same | FileChange::CommentsOnly) => None,
Some(FileChange::Removed) => Some(format!("{file} removed{}", in_role(&roles))),
Some(FileChange::Bytes) => Some(format!("{file} changed{}", in_role(&roles))),
Some(FileChange::Code {
before,
after,
diff,
..
}) => {
if whole_file(a, prior, file) {
Some(format!(
"{file}: {} changed{}",
describe(before, after, diff),
in_role(&roles)
))
} else {
let holders = prior
.nodes
.iter()
.filter(|n| n.at.file == file)
.flat_map(|n| {
before.ancestors(before.unit_at(n.at.line, n.at.column))
})
.collect::<BTreeSet<_>>();
let moved = holders
.iter()
.filter(|i| diff.changed.contains(i) || diff.removed.contains(i))
.map(|i| &before.units[*i])
.collect::<Vec<_>>();
if !moved.is_empty() {
Some(format!(
"{file}: {} changed{}",
named(moved),
in_role(&roles)
))
} else if diff.structural {
Some(format!(
"{file}: declarations changed, {}{}",
describe(before, after, diff),
in_role(&roles)
))
} else {
notices.insert(format!(
"{file} changed outside this flow's nodes: {}",
describe(before, after, diff)
));
None
}
}
}
};
if let Some(reason) = verdict {
dirty.insert(reason);
marked.entry(file).or_default().insert(key.clone());
}
}
if replacement.is_some() {
dirty.insert("assertion changed or replaced; confirm identity and meaning".into());
}
for node in &mut f.nodes {
if let Some(at) = relocate(&node.at, &old.files, &new.files) {
node.at = at;
} else {
dirty.insert(format!("node {} changed or ambiguous", node.id));
}
}
for file in f
.watch
.iter_mut()
.chain(f.applies_to.iter_mut().map(|t| &mut t.file))
{
if let Some(target) = target_file(file, &old.files, &new.files) {
*file = target;
} else {
dirty.insert(format!("dependency file removed: {file}"));
}
}
if context_changed {
dirty.insert("run configuration, dependencies or execution context changed".into());
}
next_state.flows.insert(
key,
FlowState {
generation: if dirty.is_empty() {
base
} else {
digest(&("supercov-carry-v3", base, &new_manifest, &dirty))
},
reasons: dirty,
notices,
},
);
}
next.assertions.push(updated);
}
let mut ids = map
.assertions
.iter()
.map(|a| a.id.clone())
.chain(
map.retired_assertions
.iter()
.map(|r| r.assertion.id.clone()),
)
.collect::<BTreeSet<_>>();
for a in seed(new, evidence_digest).0.assertions {
if !consumed.contains(&a.at) {
let mut a = a;
while !ids.insert(a.id.clone()) {
a.id.push('_');
}
next.assertions.push(a);
}
}
for file in old
.files
.keys()
.chain(new_manifest.files.keys())
.collect::<BTreeSet<_>>()
{
if crate::integrity::tracked_manifest(file) {
continue;
}
let exposed_to = exposed.get(file.as_str()).cloned().unwrap_or_default();
match changes.get(file.as_str()) {
Some(FileChange::Same | FileChange::CommentsOnly) => continue,
Some(FileChange::Code { narrow: true, .. }) if exposed_to.is_empty() => continue,
_ => {}
}
add_change(
&mut next_state,
Some(file.clone()),
old.files.get(file).map(|f| f.sha256.clone()),
new_manifest.files.get(file).map(|f| f.sha256.clone()),
"captured source file changed".into(),
marked.get(file.as_str()).cloned().unwrap_or_default(),
exposed_to,
);
}
next.assertions.sort_by(|a, b| a.at.cmp(&b.at));
Ok((next, next_state))
}
#[path = "assertion_legacy.rs"]
mod legacy;
pub fn parse_stored(bytes: &[u8]) -> Result<AssertionMap, String> {
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
match value
.get("schemaVersion")
.and_then(serde_json::Value::as_u64)
{
None | Some(1) => legacy::import(bytes),
_ => parse(bytes).map_err(|e| e.to_string()),
}
}
pub fn parse_state(
bytes: &[u8],
map: &AssertionMap,
inputs: &InputManifest,
evidence: &str,
legacy_digest: Option<&str>,
) -> Result<State, String> {
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
if value["schemaVersion"] == 3 {
let state: State = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
if state.inputs_digest != digest(inputs) || state.evidence_digest != evidence {
return Err("Assertion state belongs to different run evidence; rerun tests".into());
}
Ok(state)
} else {
legacy::state(bytes, map, inputs, evidence, legacy_digest)
}
}