use std::io;
use std::path::Path;
use crate::query::json::J;
use super::identity::{OccurrenceSpan, SymbolIdentity};
use super::manifest::Case;
use super::pin::Pin;
use super::uncertainty::{ContextHealth, Uncertainty};
#[derive(Debug, Clone, PartialEq)]
pub struct ObservedFact {
pub identity: SymbolIdentity,
pub occurrence: OccurrenceSpan,
pub state: Uncertainty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservedDiagnostic {
pub severity: String,
pub code: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObservedCase {
pub context: ContextHealth,
pub facts: Vec<ObservedFact>,
pub diagnostics: Vec<ObservedDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CaseVerdict {
Pass,
Fail {
reasons: Vec<String>,
},
}
impl CaseVerdict {
pub fn is_pass(&self) -> bool {
matches!(self, CaseVerdict::Pass)
}
}
fn fact_matches(
expected_identity: &SymbolIdentity,
expected_occurrence: &OccurrenceSpan,
observed: &ObservedFact,
) -> bool {
&observed.identity == expected_identity && &observed.occurrence == expected_occurrence
}
fn identity_asserted(identity: &SymbolIdentity, observed: &ObservedCase) -> bool {
observed.facts.iter().any(|f| {
&f.identity == identity
&& matches!(
f.state,
Uncertainty::Confirmed | Uncertainty::Candidate { .. }
)
})
}
pub fn evaluate_case(case: &Case, observed: &ObservedCase) -> CaseVerdict {
let mut reasons = Vec::new();
if !case.present.is_empty() && observed.facts.is_empty() {
reasons.push(format!(
"case '{}': empty analyzer result against a positive obligation",
case.id
));
}
for expected in &case.present {
let matched = observed.facts.iter().any(|f| {
fact_matches(&expected.identity, &expected.occurrence, f)
&& f.state.label() == expected.state
});
if !matched {
reasons.push(format!(
"case '{}': expected present fact '{}' at {}:{} not observed",
case.id,
expected.identity.member,
expected.occurrence.file,
expected.occurrence.start_line
));
}
}
for expected in &case.absent {
if identity_asserted(&expected.identity, observed) {
reasons.push(format!(
"case '{}': expected-absent fact '{}' was asserted",
case.id, expected.identity.member
));
}
}
for expected in &case.unresolved {
let observed_state = observed
.facts
.iter()
.find(|f| fact_matches(&expected.identity, &expected.occurrence, f))
.map(|f| f.state.label());
match observed_state {
Some(label) if label == expected.state => {}
Some("confirmed") => reasons.push(format!(
"case '{}': fact '{}' expected {} but was promoted to confirmed",
case.id, expected.identity.member, expected.state
)),
Some(other) => reasons.push(format!(
"case '{}': fact '{}' expected {} but observed {}",
case.id, expected.identity.member, expected.state, other
)),
None => reasons.push(format!(
"case '{}': expected unresolved fact '{}' not observed at all",
case.id, expected.identity.member
)),
}
}
for expected in &case.diagnostics {
let matched = observed
.diagnostics
.iter()
.any(|d| d.severity == expected.severity && d.code == expected.code);
if !matched {
reasons.push(format!(
"case '{}': expected diagnostic '{}' ({}) not observed",
case.id, expected.code, expected.severity
));
}
}
if observed.context.label() != case.context {
reasons.push(format!(
"case '{}': expected context '{}', observed '{}'",
case.id,
case.context,
observed.context.label()
));
}
if reasons.is_empty() {
CaseVerdict::Pass
} else {
CaseVerdict::Fail { reasons }
}
}
pub fn denominator_shrunk(observed_denominator: u32, declared_case_count: u32) -> bool {
observed_denominator < declared_case_count
}
pub fn ratio_or_undefined(passed: u32, denominator: u32) -> Option<f64> {
if denominator == 0 {
None
} else {
Some(f64::from(passed) / f64::from(denominator))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lane {
SyntaxOnly,
Enriched,
}
impl Lane {
pub fn label(self) -> &'static str {
match self {
Lane::SyntaxOnly => "syntax-only",
Lane::Enriched => "enriched",
}
}
}
#[derive(Debug, Clone)]
pub struct PinnedInputHeader {
pub contract: String,
pub corpus_revision: String,
pub producer_versions: Vec<(String, String)>,
pub resolved_context: String,
pub commands: Vec<String>,
pub pin: Pin,
}
#[derive(Debug, Clone)]
pub struct CaseOutcome {
pub case_id: String,
pub verdict: CaseVerdict,
}
#[derive(Debug, Clone)]
pub struct TruthReport {
pub lane: Lane,
pub header: PinnedInputHeader,
pub outcomes: Vec<CaseOutcome>,
}
impl TruthReport {
pub fn passed(&self) -> u32 {
self.outcomes.iter().filter(|o| o.verdict.is_pass()).count() as u32
}
pub fn denominator(&self) -> u32 {
self.outcomes.len() as u32
}
}
pub fn report_to_json(report: &TruthReport) -> String {
let mut outcomes = report.outcomes.clone();
outcomes.sort_by(|a, b| a.case_id.cmp(&b.case_id));
let ratio = ratio_or_undefined(report.passed(), report.denominator());
J::Obj(vec![
("contract", J::Str(report.header.contract.clone())),
("lane", J::Str(report.lane.label().to_string())),
(
"pinnedInputs",
J::Obj(vec![
(
"corpusRevision",
J::Str(report.header.corpus_revision.clone()),
),
(
"producerVersions",
J::Arr(
report
.header
.producer_versions
.iter()
.map(|(name, version)| {
J::Obj(vec![
("producer", J::Str(name.clone())),
("version", J::Str(version.clone())),
])
})
.collect(),
),
),
(
"resolvedContext",
J::Str(report.header.resolved_context.clone()),
),
(
"commands",
J::Arr(
report
.header
.commands
.iter()
.map(|c| J::Str(c.clone()))
.collect(),
),
),
(
"pin",
J::Obj(vec![
("contract", J::Str(report.header.pin.contract.clone())),
("digestHex", J::Str(report.header.pin.digest_hex.clone())),
]),
),
]),
),
(
"outcomes",
J::Arr(
outcomes
.iter()
.map(|o| {
let reasons = match &o.verdict {
CaseVerdict::Pass => Vec::new(),
CaseVerdict::Fail { reasons } => reasons.clone(),
};
J::Obj(vec![
("caseId", J::Str(o.case_id.clone())),
("pass", J::Bool(o.verdict.is_pass())),
("reasons", J::Arr(reasons.into_iter().map(J::Str).collect())),
])
})
.collect(),
),
),
("passed", J::UInt(u64::from(report.passed()))),
("denominator", J::UInt(u64::from(report.denominator()))),
(
"ratio",
match ratio {
Some(r) => J::RawNum(format!("{r:.6}")),
None => J::Str("undefined".to_string()),
},
),
])
.to_json_string()
}
pub fn lane_artifact_filename(lane: Lane) -> &'static str {
match lane {
Lane::SyntaxOnly => "syntax-only.json",
Lane::Enriched => "enriched.json",
}
}
pub fn write_lane_artifact(report: &TruthReport, dir: &Path) -> io::Result<()> {
let path = dir.join(lane_artifact_filename(report.lane));
std::fs::write(path, report_to_json(report))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::truth::identity::CompilationIdentity;
use crate::truth::manifest::{ExpectedPresentFact, Provenance};
fn identity(member: &str) -> SymbolIdentity {
SymbolIdentity {
assembly: "Fixture".to_string(),
declaring_type: "Fixture.Cases".to_string(),
member: member.to_string(),
generic_arity: 0,
overload_signature: format!("{member}()"),
compilation: CompilationIdentity {
project: "Cases".to_string(),
tfm: "net8.0".to_string(),
configuration: "Release".to_string(),
},
}
}
fn occurrence(line: u32) -> OccurrenceSpan {
OccurrenceSpan {
file: "src/Cases.cs".to_string(),
start_line: line,
start_col: 1,
end_line: line,
end_col: 20,
}
}
fn positive_case() -> Case {
Case {
id: "positive-a".to_string(),
scenario_family: "shared-language-semantics".to_string(),
language: "csharp".to_string(),
profiles: vec!["csharp-net8.0-sdk".to_string()],
prerequisites: vec![],
fact_contract: "semantic-truth-v1".to_string(),
source: vec!["src/Cases.cs".to_string()],
context: "complete".to_string(),
present: vec![ExpectedPresentFact {
identity: identity("Load"),
occurrence: occurrence(10),
state: "confirmed".to_string(),
}],
absent: vec![],
unresolved: vec![],
diagnostics: vec![],
provenance: Provenance::Reviewed,
}
}
#[test]
fn empty_output_fails_a_positive_obligation() {
let observed = ObservedCase {
context: ContextHealth::Complete,
facts: vec![],
diagnostics: vec![],
};
let verdict = evaluate_case(&positive_case(), &observed);
assert!(!verdict.is_pass());
}
#[test]
fn satisfying_only_the_absent_list_cannot_pass_a_case() {
let case = Case {
absent: vec![super::super::manifest::ExpectedAbsentFact {
identity: identity("NeverCalled"),
}],
..positive_case()
};
let observed = ObservedCase {
context: ContextHealth::Complete,
facts: vec![],
diagnostics: vec![],
};
assert!(!evaluate_case(&case, &observed).is_pass());
}
#[test]
fn a_matching_observation_passes() {
let observed = ObservedCase {
context: ContextHealth::Complete,
facts: vec![ObservedFact {
identity: identity("Load"),
occurrence: occurrence(10),
state: Uncertainty::Confirmed,
}],
diagnostics: vec![],
};
assert!(evaluate_case(&positive_case(), &observed).is_pass());
}
#[test]
fn a_partial_context_can_never_be_reported_complete() {
let observed = ObservedCase {
context: ContextHealth::Partial {
reason: "one project missing".to_string(),
},
facts: vec![ObservedFact {
identity: identity("Load"),
occurrence: occurrence(10),
state: Uncertainty::Confirmed,
}],
diagnostics: vec![],
};
let verdict = evaluate_case(&positive_case(), &observed);
assert!(
!verdict.is_pass(),
"a partial context must not satisfy a case expecting complete"
);
}
#[test]
fn zero_denominator_ratio_is_undefined_not_perfect() {
assert_eq!(ratio_or_undefined(0, 0), None);
assert_eq!(ratio_or_undefined(3, 4), Some(0.75));
}
fn header() -> PinnedInputHeader {
PinnedInputHeader {
contract: "semantic-truth-v1".to_string(),
corpus_revision: "fixtures/csharp-truth@test".to_string(),
producer_versions: vec![("scout-semantic".to_string(), "0.6.0".to_string())],
resolved_context: "complete".to_string(),
commands: vec!["devscout map".to_string()],
pin: crate::truth::pin::compute_pin("semantic-truth-v1", b"test", &[]),
}
}
#[test]
fn the_report_header_carries_the_pin() {
let json = report_to_json(&TruthReport {
lane: Lane::SyntaxOnly,
header: header(),
outcomes: vec![],
});
assert!(json.contains("\"pin\":{"));
assert!(json.contains(&format!("\"digestHex\":\"{}\"", header().pin.digest_hex)));
}
#[test]
fn a_failing_outcomes_reasons_are_in_the_record() {
let report = TruthReport {
lane: Lane::SyntaxOnly,
header: header(),
outcomes: vec![CaseOutcome {
case_id: "a-case".to_string(),
verdict: CaseVerdict::Fail {
reasons: vec!["expected present fact 'Load' not observed".to_string()],
},
}],
};
let json = report_to_json(&report);
assert!(json.contains("expected present fact 'Load' not observed"));
}
#[test]
fn two_runs_from_the_same_inputs_are_byte_identical() {
let build = || TruthReport {
lane: Lane::SyntaxOnly,
header: header(),
outcomes: vec![
CaseOutcome {
case_id: "b-case".to_string(),
verdict: CaseVerdict::Pass,
},
CaseOutcome {
case_id: "a-case".to_string(),
verdict: CaseVerdict::Fail {
reasons: vec!["missing".to_string()],
},
},
],
};
let first = report_to_json(&build());
let second = report_to_json(&build());
assert_eq!(first, second);
assert!(first.find("\"a-case\"").unwrap() < first.find("\"b-case\"").unwrap());
}
#[test]
fn the_two_lanes_are_never_summed() {
let syntax = TruthReport {
lane: Lane::SyntaxOnly,
header: header(),
outcomes: vec![CaseOutcome {
case_id: "a".to_string(),
verdict: CaseVerdict::Pass,
}],
};
let enriched = TruthReport {
lane: Lane::Enriched,
header: header(),
outcomes: vec![CaseOutcome {
case_id: "a".to_string(),
verdict: CaseVerdict::Fail { reasons: vec![] },
}],
};
let syntax_json = report_to_json(&syntax);
let enriched_json = report_to_json(&enriched);
assert_ne!(syntax_json, enriched_json);
assert!(syntax_json.contains("\"lane\":\"syntax-only\""));
assert!(enriched_json.contains("\"lane\":\"enriched\""));
}
}