use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::Context;
use freenet::conformance::generator::Corpus;
use freenet::conformance::verifier::Bytes;
use freenet::conformance::{
ConformanceCase, ConformanceEvidence, ConformanceProperty, EVIDENCE_SCHEMA_VERSION,
GeneratorConfig, Inconclusive, MinimizeConfig, OracleBuildError, PropertyOutcome, ReplayBundle,
RuntimeOracle, Severity, generate_cases, minimize, verify_case,
};
use freenet_stdlib::prelude::{CodeHash, ContractCode, ContractInstanceId};
use serde::Serialize;
#[derive(clap::Parser, Clone)]
pub struct ConformanceConfig {
#[arg(long)]
pub(crate) wasm: Option<PathBuf>,
#[arg(long)]
pub(crate) params: Option<PathBuf>,
#[arg(long = "state")]
pub(crate) states: Vec<PathBuf>,
#[arg(long)]
pub(crate) bundle: Option<PathBuf>,
#[arg(long)]
pub(crate) contract_store: Option<PathBuf>,
#[arg(long)]
pub(crate) max_cases: Option<usize>,
#[arg(long = "property")]
pub(crate) properties: Vec<String>,
#[arg(long)]
pub(crate) json: bool,
#[arg(long = "evidence-out")]
pub(crate) evidence_out: Option<PathBuf>,
#[arg(long = "evidence")]
pub(crate) evidence_in: Option<PathBuf>,
#[arg(long = "bundle-out")]
pub(crate) bundle_out: Option<PathBuf>,
}
pub async fn conformance(config: ConformanceConfig) -> anyhow::Result<()> {
if let Some(path) = config.evidence_in.clone() {
return verify_evidence(&config, &path).await;
}
let properties = parse_properties(&config.properties)?;
let (wasm, parameters, corpus) = load_inputs(&config)?;
if corpus.is_empty() {
anyhow::bail!("no states to check: the corpus is empty");
}
let mut generator_config = GeneratorConfig::default();
if let Some(max_cases) = config.max_cases {
generator_config.max_cases = max_cases;
}
if !properties.is_empty() {
generator_config.properties = properties;
}
let cases = generate_cases(&corpus, &generator_config);
if cases.is_empty() {
anyhow::bail!(
"no cases could be generated from this corpus: {} state(s), {} delta(s), \
{} summary/summaries for the selected properties. Nothing was checked, \
so this is not a pass — supply more states (commutativity and \
reconciliation need at least two), or captured deltas for the \
delta properties.",
corpus.states.len(),
corpus.deltas.len(),
corpus.summaries.len(),
);
}
if let Some(path) = &config.bundle_out {
write_bundle(path, &wasm, ¶meters, &corpus)?;
}
let mut oracle = RuntimeOracle::standalone(wasm, parameters.clone())
.await
.map_err(describe_oracle_build_error)?;
let instance = oracle.instance_id();
let outcomes: Vec<(ConformanceCase, PropertyOutcome)> = cases
.into_iter()
.map(|case| {
let outcome = verify_case(&mut oracle, &case);
(case, outcome)
})
.collect();
let evidence = match &config.evidence_out {
Some(dir) => Some(write_evidence(
dir,
instance,
¶meters,
&outcomes,
&mut oracle,
&corpus.states,
&corpus.deltas,
)?),
None => None,
};
let report = Report::build(&corpus, &outcomes, evidence);
if config.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
report.print_human();
}
let just_outcomes: Vec<&PropertyOutcome> = outcomes.iter().map(|(_, o)| o).collect();
let enforceable = just_outcomes
.iter()
.filter(|o| o.is_enforceable_violation())
.count();
if exit_code(just_outcomes) != 0 {
return Err(ConformanceViolations { count: enforceable }.into());
}
Ok(())
}
fn write_bundle(
path: &Path,
wasm: &[u8],
parameters: &[u8],
corpus: &Corpus,
) -> anyhow::Result<()> {
let mut bundle = ReplayBundle::new(wasm.to_vec(), parameters.to_vec());
bundle.states = corpus.states.iter().map(|s| s.to_vec()).collect();
bundle.deltas = corpus.deltas.iter().map(|d| d.to_vec()).collect();
bundle.summaries = corpus.summaries.iter().map(|s| s.to_vec()).collect();
bundle.note = Some(format!(
"captured by fdev conformance {}",
env!("CARGO_PKG_VERSION")
));
bundle
.write_to(path)
.with_context(|| format!("writing bundle to {}", path.display()))?;
eprintln!(
"wrote replay bundle to {} ({} state(s), {} delta(s), {} summary/summaries)",
path.display(),
bundle.states.len(),
bundle.deltas.len(),
bundle.summaries.len(),
);
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[error("{count} enforceable conformance violation(s) found")]
pub struct ConformanceViolations {
pub count: usize,
}
fn exit_code<'a>(outcomes: impl IntoIterator<Item = &'a PropertyOutcome>) -> i32 {
if outcomes
.into_iter()
.any(PropertyOutcome::is_enforceable_violation)
{
1
} else {
0
}
}
fn parse_properties(names: &[String]) -> anyhow::Result<Vec<ConformanceProperty>> {
names
.iter()
.map(|name| {
ConformanceProperty::ALL
.iter()
.copied()
.find(|p| p.as_str() == name)
.ok_or_else(|| {
let valid = ConformanceProperty::ALL
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!("unknown property {name:?}; valid properties: {valid}")
})
})
.collect()
}
async fn verify_evidence(config: &ConformanceConfig, path: &PathBuf) -> anyhow::Result<()> {
let bytes = read_file(path)?;
let evidence: ConformanceEvidence = bincode::deserialize(&bytes)
.with_context(|| format!("decoding evidence {}", path.display()))?;
if evidence.schema_version != EVIDENCE_SCHEMA_VERSION {
anyhow::bail!(
"evidence uses schema version {}, this build understands {}",
evidence.schema_version,
EVIDENCE_SCHEMA_VERSION
);
}
evidence
.check_bounds()
.map_err(|rejected| anyhow::anyhow!("evidence is not shippable: {rejected}"))?;
let code = match (&config.wasm, &config.contract_store) {
(Some(wasm), _) => read_file(wasm)?,
(None, Some(store)) => find_code_for_instance(store, &evidence)?,
(None, None) => anyhow::bail!(
"verifying evidence needs the contract it accuses: pass --wasm, or \
--contract-store pointing at a node that hosts it"
),
};
let mut oracle = RuntimeOracle::standalone(code, evidence.parameters.clone())
.await
.map_err(describe_oracle_build_error)?;
if oracle.instance_id() != evidence.contract {
anyhow::bail!(
"the contract supplied is {} but the evidence is about {}",
oracle.instance_id(),
evidence.contract
);
}
let case = evidence.to_case();
let outcome = verify_case(&mut oracle, &case);
println!("evidence {}", evidence.id());
println!(" contract : {}", evidence.contract);
println!(" property : {}", evidence.property);
println!(
" claimed : {}",
match &evidence.observed {
Some(v) => format!("{} ({})", v.property, v.detail),
None => "nothing recorded by the sender".to_string(),
}
);
match &outcome {
PropertyOutcome::Violated(v) => {
println!(
" verdict : REPRODUCED \u{2014} {} ({})",
v.property, v.detail
);
if evidence
.observed
.as_ref()
.is_some_and(|o| o.property != v.property)
{
println!(" note : a DIFFERENT law broke here than the sender reported");
}
if v.severity == Severity::Violation {
return Err(ConformanceViolations { count: 1 }.into());
}
Ok(())
}
PropertyOutcome::Holds => {
println!(" verdict : NOT REPRODUCED \u{2014} the law holds on this runtime");
println!(
" note : the sender's runtime was {:?}; a finding that does not \
reproduce must not be acted on",
evidence.runtime
);
Ok(())
}
PropertyOutcome::Inconclusive(reason) => {
println!(" verdict : INCONCLUSIVE \u{2014} {reason}");
Ok(())
}
}
}
fn find_code_for_instance(store: &Path, evidence: &ConformanceEvidence) -> anyhow::Result<Vec<u8>> {
let params = freenet_stdlib::prelude::Parameters::from(evidence.parameters.clone());
let mut examined = 0usize;
for entry in std::fs::read_dir(store)
.with_context(|| format!("reading contract store {}", store.display()))?
{
let path = entry
.with_context(|| format!("listing contract store {}", store.display()))?
.path();
if !path.is_file() || path.extension().is_none_or(|ext| ext != "wasm") {
continue;
}
let Ok((code, _version)) = ContractCode::load_versioned_from_path(&path) else {
continue;
};
examined += 1;
if ContractInstanceId::from_params_and_code(¶ms, &code) == evidence.contract {
return Ok(code.data().to_vec());
}
}
anyhow::bail!(
"no contract in {} is instance {} ({} candidate(s) examined). A peer only \
stores contracts it hosts, so pass --wasm if this node does not.",
store.display(),
evidence.contract,
examined
)
}
fn find_code_in_store(store: &Path, bundle: &ReplayBundle) -> anyhow::Result<Vec<u8>> {
let Some(hash) = bundle.code_hash else {
anyhow::bail!(
"bundle names no contract (no code hash), so no store lookup could \
identify the right WASM"
);
};
let path = store
.join(CodeHash::new(hash).encode())
.with_extension("wasm");
if !path.exists() {
anyhow::bail!(
"this node's contract store has no code for the bundle's contract \
(looked for {}). A peer only stores contracts it hosts, so a capture \
replayed on a different node may need --wasm.",
path.display()
);
}
let (code, _version) = ContractCode::load_versioned_from_path(&path)
.with_context(|| format!("reading contract code from {}", path.display()))?;
Ok(code.data().to_vec())
}
fn load_inputs(config: &ConformanceConfig) -> anyhow::Result<(Vec<u8>, Vec<u8>, Corpus)> {
if let Some(bundle_path) = &config.bundle {
let bundle = ReplayBundle::read_from(bundle_path)
.with_context(|| format!("reading bundle {}", bundle_path.display()))?;
let supplied = match (&config.wasm, &config.contract_store) {
(Some(path), _) => Some(read_file(path)?),
(None, Some(store)) => Some(find_code_in_store(store, &bundle)?),
(None, None) => None,
};
let wasm = bundle.resolve_code(supplied).with_context(|| {
format!(
"resolving contract code for bundle {}",
bundle_path.display()
)
})?;
let parameters = bundle.parameters.clone();
if let Some(note) = bundle.note.as_deref() {
eprintln!("bundle note: {note}");
}
let corpus = bundle.to_corpus();
Ok((wasm, parameters, corpus))
} else {
let wasm_path = config
.wasm
.as_ref()
.context("--wasm is required unless --bundle is given")?;
let wasm = read_file(wasm_path)?;
let parameters = match &config.params {
Some(path) => read_file(path)?,
None => Vec::new(),
};
if config.states.is_empty() {
anyhow::bail!("at least one --state is required unless --bundle is given");
}
let mut states = Vec::with_capacity(config.states.len());
for path in &config.states {
states.push(read_file(path)?);
}
let corpus = Corpus::from_states(states).deduplicated();
Ok((wasm, parameters, corpus))
}
}
fn read_file(path: &PathBuf) -> anyhow::Result<Vec<u8>> {
std::fs::read(path).with_context(|| format!("reading {}", path.display()))
}
fn describe_oracle_build_error(err: OracleBuildError) -> anyhow::Error {
match &err {
OracleBuildError::Runtime(_) => {
anyhow::anyhow!("the contract WASM failed to load into the verifier runtime: {err}")
}
OracleBuildError::Scratch(_) | OracleBuildError::Storage(_) => {
anyhow::anyhow!("could not set up the local verifier environment: {err}")
}
}
}
fn write_evidence(
dir: &PathBuf,
instance: ContractInstanceId,
parameters: &[u8],
outcomes: &[(ConformanceCase, PropertyOutcome)],
oracle: &mut RuntimeOracle,
state_candidates: &[Bytes],
delta_candidates: &[Bytes],
) -> anyhow::Result<EvidenceSummary> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating evidence directory {}", dir.display()))?;
let mut written = HashSet::new();
let mut oversized = 0usize;
let mut shrunk_from = 0usize;
let mut shrunk_to = 0usize;
for (case, outcome) in outcomes {
if !outcome.is_enforceable_violation() {
continue;
}
let (minimized, shrink) = minimize(
oracle,
case,
state_candidates,
delta_candidates,
&MinimizeConfig::default(),
);
shrunk_from += shrink.original_bytes;
shrunk_to += shrink.final_bytes;
let observed = verify_case(oracle, &minimized).violation().cloned();
let evidence =
ConformanceEvidence::new(instance, parameters.to_vec(), &minimized, observed);
if let Err(rejected) = evidence.check_bounds() {
oversized += 1;
eprintln!(
"warning: a {} finding could not be reduced to a shippable size ({rejected}); \
no evidence file written for it",
minimized.property
);
continue;
}
let id = evidence.id();
if !written.insert(id) {
continue;
}
let bytes =
bincode::serialize(&evidence).with_context(|| format!("encoding evidence {id}"))?;
let path = dir.join(format!("{id}.bin"));
std::fs::write(&path, bytes)
.with_context(|| format!("writing evidence to {}", path.display()))?;
}
Ok(EvidenceSummary {
directory: dir.display().to_string(),
files_written: written.len(),
findings_too_large: oversized,
input_bytes_before_shrinking: shrunk_from,
input_bytes_after_shrinking: shrunk_to,
})
}
#[derive(Serialize)]
struct EvidenceSummary {
directory: String,
files_written: usize,
findings_too_large: usize,
input_bytes_before_shrinking: usize,
input_bytes_after_shrinking: usize,
}
#[derive(Serialize)]
struct Report {
corpus_states: usize,
corpus_deltas: usize,
corpus_summaries: usize,
cases_run: usize,
holds: usize,
violations: usize,
enforceable_violations: usize,
diagnostic_violations: usize,
inconclusive: usize,
findings: Vec<Finding>,
inconclusive_reasons: Vec<InconclusiveReason>,
evidence: Option<EvidenceSummary>,
}
#[derive(Serialize, Clone)]
struct Finding {
property: String,
severity: &'static str,
detail: String,
left: String,
right: String,
}
#[derive(Serialize)]
struct InconclusiveReason {
reason: &'static str,
occurrences: usize,
}
impl Report {
fn build(
corpus: &Corpus,
outcomes: &[(ConformanceCase, PropertyOutcome)],
evidence: Option<EvidenceSummary>,
) -> Self {
let mut holds = 0usize;
let mut violations = 0usize;
let mut enforceable_violations = 0usize;
let mut diagnostic_violations = 0usize;
let mut inconclusive = 0usize;
let mut findings: Vec<Finding> = Vec::new();
let mut inconclusive_reasons: HashMap<&'static str, usize> = HashMap::new();
for (_, outcome) in outcomes {
match outcome {
PropertyOutcome::Holds => holds += 1,
PropertyOutcome::Violated(v) => {
violations += 1;
match v.severity {
Severity::Violation => enforceable_violations += 1,
Severity::Diagnostic => diagnostic_violations += 1,
}
findings.push(Finding {
property: v.property.as_str().to_string(),
severity: match v.severity {
Severity::Violation => "violation",
Severity::Diagnostic => "diagnostic",
},
detail: v.detail.clone(),
left: v.left.to_string(),
right: v.right.to_string(),
});
}
PropertyOutcome::Inconclusive(reason) => {
inconclusive += 1;
*inconclusive_reasons
.entry(inconclusive_label(reason))
.or_insert(0) += 1;
}
}
}
let mut inconclusive_reasons: Vec<InconclusiveReason> = inconclusive_reasons
.into_iter()
.map(|(reason, occurrences)| InconclusiveReason {
reason,
occurrences,
})
.collect();
inconclusive_reasons.sort_by(|a, b| b.occurrences.cmp(&a.occurrences));
Report {
corpus_states: corpus.states.len(),
corpus_deltas: corpus.deltas.len(),
corpus_summaries: corpus.summaries.len(),
cases_run: outcomes.len(),
holds,
violations,
enforceable_violations,
diagnostic_violations,
inconclusive,
findings,
inconclusive_reasons,
evidence,
}
}
fn print_human(&self) {
println!(
"conformance: {} state(s), {} delta(s), {} summary/summaries in the corpus",
self.corpus_states, self.corpus_deltas, self.corpus_summaries
);
println!(
"conformance: {} case(s) run \u{2014} {} held, {} violation(s) ({} enforceable, {} diagnostic-only), {} inconclusive",
self.cases_run,
self.holds,
self.violations,
self.enforceable_violations,
self.diagnostic_violations,
self.inconclusive
);
if !self.findings.is_empty() {
println!("\nfindings:");
for (f, count) in group_findings(&self.findings) {
let cases = if count == 1 {
"1 case".to_string()
} else {
format!("{count} cases")
};
println!(
" [{}] {} ({cases}): {}\n example \u{2014} left: {}; right: {}",
f.severity, f.property, f.detail, f.left, f.right
);
}
}
if !self.inconclusive_reasons.is_empty() {
println!(
"\ninconclusive ({} total, not failures \u{2014} see freenet::conformance::Inconclusive):",
self.inconclusive
);
for r in &self.inconclusive_reasons {
println!(" {}: {}", r.reason, r.occurrences);
}
}
if let Some(evidence) = &self.evidence {
println!(
"\nwrote {} evidence file(s) to {}",
evidence.files_written, evidence.directory
);
}
if self.enforceable_violations == 0 {
println!("\nno enforceable violations found.");
if self.diagnostic_violations > 0 {
println!(
"({} diagnostic finding(s) above are efficiency notes, not merge-law breaks, and do not fail this command.)",
self.diagnostic_violations
);
}
}
}
}
fn group_findings(findings: &[Finding]) -> Vec<(&Finding, usize)> {
let mut groups: Vec<(&Finding, usize)> = Vec::new();
for f in findings {
match groups
.iter_mut()
.find(|(g, _)| g.property == f.property && g.detail == f.detail)
{
Some((_, count)) => *count += 1,
None => groups.push((f, 1)),
}
}
groups
}
fn inconclusive_label(reason: &Inconclusive) -> &'static str {
match reason {
Inconclusive::InputNotValid => "input not valid",
Inconclusive::RelatedRequired => "requires related contract state",
Inconclusive::ContractError(_) => "contract error",
Inconclusive::NoOutputState => "update produced no output state",
Inconclusive::ResourceLimit(_) => "resource limit hit",
Inconclusive::RoundLimit => "reconciliation round budget exhausted",
Inconclusive::MalformedCase(_) => "malformed case",
_ => "other",
}
}
#[cfg(test)]
mod stdout_purity_pin {
fn load_inputs_body() -> &'static str {
let src = include_str!("conformance.rs");
let start = src
.find("fn load_inputs(")
.expect("load_inputs not found in conformance.rs");
let after = &src[start..];
let open = after.find('{').expect("load_inputs has no body");
let mut depth = 0usize;
for (offset, ch) in after[open..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return &after[..open + offset + 1];
}
}
_ => {}
}
}
panic!("load_inputs' body is not brace-balanced");
}
fn code_only() -> String {
load_inputs_body()
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
fn stdout_macros_only() -> String {
code_only().replace("eprintln!", "").replace("eprint!", "")
}
#[test]
fn load_inputs_never_writes_to_stdout() {
let body = stdout_macros_only();
assert!(
!body.contains("println!") && !body.contains("print!("),
"load_inputs writes to stdout, which lands ahead of the --json document \
and corrupts it for every consumer that parses stdout"
);
}
#[test]
fn the_bundle_note_still_reaches_the_reader() {
let body = code_only();
assert!(
body.contains("eprintln!"),
"the bundle note is no longer surfaced at all; a corpus whose related \
state was refused would replay as a clean bill of health"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use freenet::conformance::Violation;
#[test]
fn evidence_resolves_by_instance_so_parameters_cannot_be_confused() {
let store = tempfile::tempdir().expect("temp dir");
let code = b"\0asm-stand-in-bytes".to_vec();
let encoded = ContractCode::from(code.clone())
.to_bytes_versioned(freenet_stdlib::prelude::APIVersion::Version0_0_1)
.expect("encode versioned");
std::fs::write(
store
.path()
.join(CodeHash::from_code(&code).encode())
.with_extension("wasm"),
&encoded,
)
.expect("write store entry");
let params = vec![1, 2, 3];
let instance_for = |p: Vec<u8>| {
ContractInstanceId::from_params_and_code(
freenet_stdlib::prelude::Parameters::from(p),
ContractCode::from(code.clone()),
)
};
let case = ConformanceCase::new(
ConformanceProperty::StateCommutativity,
vec![Bytes::from(vec![1u8]), Bytes::from(vec![2u8])],
);
let right = ConformanceEvidence::new(instance_for(params.clone()), params, &case, None);
assert_eq!(
find_code_for_instance(store.path(), &right).expect("should resolve"),
code,
"evidence naming this instance must resolve to its code"
);
let wrong =
ConformanceEvidence::new(instance_for(vec![9, 9, 9]), vec![1, 2, 3], &case, None);
let err = find_code_for_instance(store.path(), &wrong)
.expect_err("a different instance must not resolve to this contract");
assert!(
err.to_string().contains("is instance"),
"error should name the instance it could not find, got: {err}"
);
}
#[test]
fn code_is_resolved_from_a_contract_store_the_way_the_node_writes_it() {
let store = tempfile::tempdir().expect("temp dir");
let code = b"\0asm-not-really-but-bytes-are-bytes".to_vec();
let encoded = ContractCode::from(code.clone())
.to_bytes_versioned(freenet_stdlib::prelude::APIVersion::Version0_0_1)
.expect("encode versioned");
let path = store
.path()
.join(CodeHash::from_code(&code).encode())
.with_extension("wasm");
std::fs::write(&path, &encoded).expect("write store entry");
assert!(
encoded.len() > code.len(),
"the stored form must carry a header, or this test proves nothing \
about stripping one"
);
let bundle = ReplayBundle::new(code.clone(), Vec::new());
let resolved = find_code_in_store(store.path(), &bundle).expect("resolve from store");
assert_eq!(resolved, code, "resolved code must be the raw WASM");
let absent = ReplayBundle::new(b"different code entirely".to_vec(), Vec::new());
let err = find_code_in_store(store.path(), &absent)
.expect_err("a contract absent from the store must not resolve");
assert!(
err.to_string()
.contains("no code for the bundle's contract"),
"error should name the miss, got: {err}"
);
}
#[test]
fn every_property_name_round_trips() {
for property in ConformanceProperty::ALL {
let parsed = parse_properties(&[property.as_str().to_string()])
.unwrap_or_else(|e| panic!("{}: {e}", property.as_str()));
assert_eq!(parsed, vec![*property]);
}
}
#[test]
fn unknown_property_name_lists_valid_names_in_error() {
let err = parse_properties(&["not_a_real_property".to_string()])
.expect_err("unknown property name should fail to parse");
let message = err.to_string();
assert!(message.contains("not_a_real_property"));
assert!(message.contains(ConformanceProperty::StateCommutativity.as_str()));
}
fn digest() -> freenet::conformance::OutputDigest {
freenet::conformance::OutputDigest::of(b"x")
}
fn violation_of(property: ConformanceProperty) -> Violation {
Violation {
property,
severity: property.severity(),
left: digest(),
right: digest(),
detail: "test".to_string(),
}
}
#[test]
fn exit_code_treats_diagnostic_violation_as_success() {
let outcome =
PropertyOutcome::Violated(violation_of(ConformanceProperty::DeltaIdempotence));
assert_eq!(exit_code([&outcome]), 0);
}
#[test]
fn exit_code_treats_enforceable_violation_as_failure() {
let outcome =
PropertyOutcome::Violated(violation_of(ConformanceProperty::StateCommutativity));
assert_eq!(exit_code([&outcome]), 1);
}
#[test]
fn exit_code_is_zero_for_holds_and_inconclusive() {
let holds = PropertyOutcome::Holds;
let inconclusive = PropertyOutcome::Inconclusive(Inconclusive::RoundLimit);
assert_eq!(exit_code([&holds, &inconclusive]), 0);
}
fn finding(property: &str, detail: &str, left: &str, right: &str) -> Finding {
Finding {
property: property.to_string(),
severity: "violation",
detail: detail.to_string(),
left: left.to_string(),
right: right.to_string(),
}
}
#[test]
fn group_findings_collapses_same_property_and_detail_with_different_digests() {
let a = finding(
"state_commutativity",
"merge(A, B) must equal merge(B, A)",
"2 bytes, blake3:aaaaaa",
"2 bytes, blake3:bbbbbb",
);
let b = finding(
"state_commutativity",
"merge(A, B) must equal merge(B, A)",
"1 bytes, blake3:cccccc",
"2 bytes, blake3:dddddd",
);
let findings = vec![a, b];
let groups = group_findings(&findings);
assert_eq!(
groups.len(),
1,
"same property + detail must collapse into one group regardless of digests"
);
assert_eq!(groups[0].1, 2);
}
#[test]
fn group_findings_keeps_distinct_property_or_detail_separate() {
let a = finding("state_commutativity", "detail A", "l1", "r1");
let b = finding("state_associativity", "detail A", "l2", "r2");
let c = finding("state_commutativity", "detail B", "l3", "r3");
let findings = vec![a, b, c];
let groups = group_findings(&findings);
assert_eq!(groups.len(), 3);
assert!(groups.iter().all(|(_, count)| *count == 1));
}
}