pub mod audience;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Write as _;
use serde::Serialize;
use crate::audit::Audit;
use crate::principles::registry::{Level, REQUIREMENTS, SPEC_VERSION};
use crate::types::{AuditGroup, AuditLayer, AuditResult, AuditStatus};
pub const SCHEMA_VERSION: &str = "0.7";
pub const BADGE_ELIGIBILITY_FLOOR_PCT: u32 = 70;
pub const BADGE_BASE_URL: &str = "https://anc.dev";
#[derive(Serialize)]
pub struct Scorecard {
pub schema_version: &'static str,
pub results: Vec<AuditResultView>,
pub summary: Summary,
pub coverage_summary: CoverageSummary,
pub audience: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audience_reason: Option<String>,
pub audit_profile: Option<String>,
pub spec_version: &'static str,
pub tool: ToolInfo,
pub anc: AncInfo,
pub run: RunInfo,
pub target: TargetInfo,
pub badge: BadgeInfo,
}
#[derive(Serialize)]
pub struct BadgeInfo {
pub eligible: bool,
pub score_pct: u32,
pub embed_markdown: Option<String>,
pub scorecard_url: Option<String>,
pub badge_url: Option<String>,
pub convention_url: &'static str,
}
impl BadgeInfo {
pub fn text_hint(&self) -> Option<String> {
let embed = self.embed_markdown.as_deref()?;
Some(format!(
"\n🏆 Score: {}% — your tool qualifies for the agent-native badge.\n\
\x20 Embed in your README:\n\
\x20 {embed}\n\
\x20 Convention: {}/badge\n",
self.score_pct, BADGE_BASE_URL,
))
}
}
pub fn compute_badge(results: &[AuditResult], tool_name: &str) -> BadgeInfo {
let pct = score_pct(results);
let trimmed = tool_name.trim();
let has_slug = !trimmed.is_empty();
let eligible = has_slug && pct >= BADGE_ELIGIBILITY_FLOOR_PCT;
let scorecard_url = has_slug.then(|| format!("{BADGE_BASE_URL}/score/{trimmed}"));
let badge_url = has_slug.then(|| format!("{BADGE_BASE_URL}/badge/{trimmed}.svg"));
let embed_markdown = if eligible {
Some(format!(
"[]({BADGE_BASE_URL}/score/{trimmed})"
))
} else {
None
};
BadgeInfo {
eligible,
score_pct: pct,
embed_markdown,
scorecard_url,
badge_url,
convention_url: "https://anc.dev/badge",
}
}
const W_MUST: f64 = 1.0;
const W_SHOULD: f64 = 1.0;
const W_MAY: f64 = 1.0;
fn tier_weight(level: Option<Level>) -> f64 {
match level {
Some(Level::Should) => W_SHOULD,
Some(Level::May) => W_MAY,
Some(Level::Must) | None => W_MUST,
}
}
fn status_credit(status: &AuditStatus) -> Option<f64> {
match status {
AuditStatus::Pass => Some(1.0),
AuditStatus::Warn(_) => Some(0.5),
AuditStatus::Fail(_) | AuditStatus::OptOut(_) => Some(0.0),
AuditStatus::NotApplicable(_) | AuditStatus::Skip(_) | AuditStatus::Error(_) => None,
}
}
fn score_pct(results: &[AuditResult]) -> u32 {
let mut weighted_credit = 0.0_f64;
let mut weight_sum = 0.0_f64;
for r in results {
if r.layer != AuditLayer::Behavioral {
continue;
}
let Some(credit) = status_credit(&r.status) else {
continue;
};
let weight = tier_weight(crate::principles::registry::find(&r.id).map(|req| req.level));
weighted_credit += weight * credit;
weight_sum += weight;
}
if weight_sum == 0.0 {
0
} else {
((weighted_credit / weight_sum) * 100.0).round() as u32
}
}
#[derive(Serialize)]
pub struct ToolInfo {
pub name: String,
pub binary: Option<String>,
pub version: Option<String>,
}
#[derive(Serialize)]
pub struct AncInfo {
pub version: &'static str,
}
#[derive(Serialize)]
pub struct RunInfo {
pub invocation: String,
pub started_at: String,
pub duration_ms: u64,
pub platform: PlatformInfo,
}
#[derive(Serialize)]
pub struct PlatformInfo {
pub os: &'static str,
pub arch: &'static str,
}
#[derive(Serialize)]
pub struct TargetInfo {
pub kind: String,
pub path: Option<String>,
pub command: Option<String>,
}
#[derive(Serialize)]
pub struct LevelCounts {
pub total: usize,
pub verified: usize,
}
#[derive(Serialize)]
pub struct CoverageSummary {
pub must: LevelCounts,
pub should: LevelCounts,
pub may: LevelCounts,
}
#[derive(Serialize, Debug)]
pub struct Summary {
pub total: usize,
pub pass: usize,
pub warn: usize,
pub fail: usize,
pub opt_out: usize,
pub n_a: usize,
pub skip: usize,
pub error: usize,
}
#[derive(Serialize)]
pub struct AuditResultView {
pub id: String,
pub label: String,
pub group: String,
pub layer: String,
pub status: String,
pub evidence: Option<String>,
pub confidence: String,
pub tier: Option<String>,
pub audit_id: String,
}
impl AuditResultView {
#[allow(dead_code)]
pub fn from_result(r: &AuditResult) -> Self {
Self::from_row(r, &r.id)
}
pub fn from_row(r: &AuditResult, audit_id: &str) -> Self {
let (status, evidence) = match &r.status {
AuditStatus::Pass => ("pass".to_string(), None),
AuditStatus::Warn(e) => ("warn".to_string(), Some(e.clone())),
AuditStatus::Fail(e) => ("fail".to_string(), Some(e.clone())),
AuditStatus::OptOut(e) => ("opt_out".to_string(), Some(e.clone())),
AuditStatus::NotApplicable(e) => ("n_a".to_string(), Some(e.clone())),
AuditStatus::Skip(e) => ("skip".to_string(), Some(e.clone())),
AuditStatus::Error(e) => ("error".to_string(), Some(e.clone())),
};
let group = serde_json::to_value(r.group)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| format!("{:?}", r.group));
let layer = serde_json::to_value(r.layer)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| format!("{:?}", r.layer));
let confidence = serde_json::to_value(r.confidence)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| format!("{:?}", r.confidence));
let tier =
crate::principles::registry::find(&r.id).map(|req| req.level.as_str().to_string());
AuditResultView {
id: r.id.clone(),
label: r.label.clone(),
group,
layer,
status,
evidence,
confidence,
tier,
audit_id: audit_id.to_string(),
}
}
}
fn build_summary(results: &[AuditResult]) -> Summary {
Summary {
total: results.len(),
pass: results
.iter()
.filter(|r| matches!(r.status, AuditStatus::Pass))
.count(),
warn: results
.iter()
.filter(|r| matches!(r.status, AuditStatus::Warn(_)))
.count(),
fail: results
.iter()
.filter(|r| matches!(r.status, AuditStatus::Fail(_)))
.count(),
opt_out: results
.iter()
.filter(|r| matches!(r.status, AuditStatus::OptOut(_)))
.count(),
n_a: results
.iter()
.filter(|r| matches!(r.status, AuditStatus::NotApplicable(_)))
.count(),
skip: results
.iter()
.filter(|r| matches!(r.status, AuditStatus::Skip(_)))
.count(),
error: results
.iter()
.filter(|r| matches!(r.status, AuditStatus::Error(_)))
.count(),
}
}
fn group_display(group: &AuditGroup) -> &'static str {
match group {
AuditGroup::P1 => "P1 — Non-Interactive by Default",
AuditGroup::P2 => "P2 — Structured Output",
AuditGroup::P3 => "P3 — Progressive Help",
AuditGroup::P4 => "P4 — Actionable Errors",
AuditGroup::P5 => "P5 — Safe Retries",
AuditGroup::P6 => "P6 — Composable Structure",
AuditGroup::P7 => "P7 — Bounded Responses",
AuditGroup::P8 => "P8 — Discoverable Skill Bundles",
AuditGroup::CodeQuality => "Code Quality",
AuditGroup::ProjectStructure => "Project Structure",
}
}
fn group_order(group: &AuditGroup) -> u8 {
match group {
AuditGroup::P1 => 1,
AuditGroup::P2 => 2,
AuditGroup::P3 => 3,
AuditGroup::P4 => 4,
AuditGroup::P5 => 5,
AuditGroup::P6 => 6,
AuditGroup::P7 => 7,
AuditGroup::P8 => 8,
AuditGroup::CodeQuality => 9,
AuditGroup::ProjectStructure => 10,
}
}
#[derive(Clone, Copy, Default)]
pub struct TextOptions {
pub raw: bool,
pub color: bool,
}
pub fn format_text(
results: &[AuditResult],
quiet: bool,
badge: Option<&BadgeInfo>,
opts: TextOptions,
) -> String {
if opts.raw {
return format_text_raw(results);
}
let mut out = String::new();
let mut grouped: BTreeMap<u8, (AuditGroup, Vec<&AuditResult>)> = BTreeMap::new();
for r in results {
let order = group_order(&r.group);
grouped
.entry(order)
.or_insert_with(|| (r.group, Vec::new()))
.1
.push(r);
}
for (group, audits) in grouped.values() {
if !quiet {
let _ = writeln!(out, "\n{}", group_display(group));
}
for r in audits {
let prefix = match &r.status {
AuditStatus::Pass => {
if quiet {
continue;
}
"PASS"
}
AuditStatus::Warn(_) => "WARN",
AuditStatus::Fail(_) => "FAIL",
AuditStatus::OptOut(_) => {
if quiet {
continue;
}
"OPT "
}
AuditStatus::NotApplicable(_) => {
if quiet {
continue;
}
"N/A "
}
AuditStatus::Skip(_) => {
if quiet {
continue;
}
"SKIP"
}
AuditStatus::Error(_) => "ERR ",
};
let painted =
crate::color::paint(crate::color::status_style(prefix, opts.color), prefix);
let tier_suffix = crate::principles::registry::find(&r.id)
.map(|req| format!(" ({})", req.level.as_str()))
.unwrap_or_default();
let _ = writeln!(out, " [{painted}] {} ({}){tier_suffix}", r.label, r.id);
match &r.status {
AuditStatus::Warn(e) | AuditStatus::Fail(e) | AuditStatus::Error(e) => {
for line in e.lines() {
let _ = writeln!(out, " {line}");
}
}
AuditStatus::Skip(reason)
| AuditStatus::OptOut(reason)
| AuditStatus::NotApplicable(reason)
if !quiet =>
{
let _ = writeln!(out, " {reason}");
}
_ => {}
}
}
}
let s = build_summary(results);
let _ = writeln!(
out,
"\n{} audits: {} pass, {} warn, {} fail, {} opt_out, {} n_a, {} skip, {} error",
s.total, s.pass, s.warn, s.fail, s.opt_out, s.n_a, s.skip, s.error
);
if let Some(hint) = badge.and_then(BadgeInfo::text_hint) {
out.push_str(&hint);
}
out
}
fn format_text_raw(results: &[AuditResult]) -> String {
let mut out = String::with_capacity(results.len() * 32);
for r in results {
let token = match &r.status {
AuditStatus::Pass => "PASS",
AuditStatus::Warn(_) => "WARN",
AuditStatus::Fail(_) => "FAIL",
AuditStatus::OptOut(_) => "OPT_OUT",
AuditStatus::NotApplicable(_) => "N_A",
AuditStatus::Skip(_) => "SKIP",
AuditStatus::Error(_) => "ERR",
};
let _ = writeln!(out, "{}\t{token}", r.id);
}
out
}
pub fn fan_out_per_row(
raw: &[AuditResult],
catalog: &[Box<dyn Audit>],
) -> Vec<(AuditResult, String)> {
let covers_by_id: HashMap<&str, &'static [&'static str]> =
catalog.iter().map(|c| (c.id(), c.covers())).collect();
let mut out: Vec<(AuditResult, String)> = Vec::with_capacity(raw.len());
for r in raw {
let covers = covers_by_id.get(r.id.as_str()).copied().unwrap_or(&[]);
if covers.is_empty() {
out.push((r.clone(), r.id.clone()));
continue;
}
for row_id in covers {
let mut row = r.clone();
row.id = (*row_id).to_string();
out.push((row, r.id.clone()));
}
}
out
}
pub fn propagate_antecedents(rows: &mut [(AuditResult, String)], raw: &[AuditResult]) {
use crate::principles::registry::{Applicability, find};
let raw_by_id: HashMap<&str, &AuditStatus> =
raw.iter().map(|r| (r.id.as_str(), &r.status)).collect();
for (row, _audit_id) in rows.iter_mut() {
let Some(req) = find(&row.id) else { continue };
let Applicability::Conditional { antecedent, .. } = req.applicability else {
continue;
};
let Some(ante) = antecedent else { continue };
let Some(ante_status) = raw_by_id.get(ante.audit_id) else {
continue;
};
let new_status = match ante_status {
AuditStatus::Pass | AuditStatus::Warn(_) | AuditStatus::Fail(_) => continue,
AuditStatus::OptOut(reason) | AuditStatus::NotApplicable(reason) => {
AuditStatus::NotApplicable(format!(
"antecedent `{}` is {}: {reason}",
ante.audit_id,
short_status_name(ante_status),
))
}
AuditStatus::Skip(reason) => AuditStatus::Skip(format!(
"antecedent `{}` could not be measured: {reason}",
ante.audit_id,
)),
AuditStatus::Error(reason) => {
AuditStatus::Error(format!("antecedent `{}` errored: {reason}", ante.audit_id,))
}
};
row.status = new_status;
}
}
fn short_status_name(s: &AuditStatus) -> &'static str {
match s {
AuditStatus::Pass => "pass",
AuditStatus::Warn(_) => "warn",
AuditStatus::Fail(_) => "fail",
AuditStatus::OptOut(_) => "opt_out",
AuditStatus::NotApplicable(_) => "n_a",
AuditStatus::Skip(_) => "skip",
AuditStatus::Error(_) => "error",
}
}
pub fn build_row_results(
raw: &[AuditResult],
catalog: &[Box<dyn Audit>],
) -> Vec<(AuditResult, String)> {
let mut rows = fan_out_per_row(raw, catalog);
propagate_antecedents(&mut rows, raw);
rows
}
pub struct RunMetadata {
pub tool: ToolInfo,
pub anc: AncInfo,
pub run: RunInfo,
pub target: TargetInfo,
}
pub fn build_scorecard(
raw_results: &[AuditResult],
ran_audits: &[Box<dyn Audit>],
audience: Option<String>,
audit_profile: Option<String>,
metadata: RunMetadata,
) -> Scorecard {
let row_results = build_row_results(raw_results, ran_audits);
let audience_reason = if audience.is_some() {
None
} else {
audience::classify_reason(raw_results).map(|s| s.to_string())
};
let RunMetadata {
tool,
anc,
run,
target,
} = metadata;
let per_row_only: Vec<AuditResult> = row_results.iter().map(|(r, _)| r.clone()).collect();
let badge = compute_badge(&per_row_only, &tool.name);
Scorecard {
schema_version: SCHEMA_VERSION,
results: row_results
.iter()
.map(|(r, audit_id)| AuditResultView::from_row(r, audit_id))
.collect(),
summary: build_summary(&per_row_only),
coverage_summary: build_coverage_summary(raw_results, ran_audits),
audience,
audience_reason,
audit_profile,
spec_version: SPEC_VERSION,
tool,
anc,
run,
target,
badge,
}
}
pub fn format_json(
raw_results: &[AuditResult],
ran_audits: &[Box<dyn Audit>],
audience: Option<String>,
audit_profile: Option<String>,
metadata: RunMetadata,
) -> String {
let scorecard = build_scorecard(raw_results, ran_audits, audience, audit_profile, metadata);
serde_json::to_string_pretty(&scorecard).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
}
fn build_coverage_summary(
results: &[AuditResult],
ran_audits: &[Box<dyn Audit>],
) -> CoverageSummary {
let covers_by_id: HashMap<&str, &'static [&'static str]> =
ran_audits.iter().map(|c| (c.id(), c.covers())).collect();
let mut verified: HashSet<&'static str> = HashSet::new();
for r in results {
if audience::is_audit_profile_suppression(&r.status) {
continue;
}
if let Some(ids) = covers_by_id.get(r.id.as_str()) {
verified.extend(ids.iter().copied());
}
}
let mut must = LevelCounts {
total: 0,
verified: 0,
};
let mut should = LevelCounts {
total: 0,
verified: 0,
};
let mut may = LevelCounts {
total: 0,
verified: 0,
};
for req in REQUIREMENTS {
let bucket = match req.level {
Level::Must => &mut must,
Level::Should => &mut should,
Level::May => &mut may,
};
bucket.total += 1;
if verified.contains(req.id) {
bucket.verified += 1;
}
}
CoverageSummary { must, should, may }
}
pub fn exit_code(results: &[AuditResult]) -> i32 {
let has_fail_or_error = results
.iter()
.any(|r| matches!(r.status, AuditStatus::Fail(_) | AuditStatus::Error(_)));
let has_warn = results
.iter()
.any(|r| matches!(r.status, AuditStatus::Warn(_)));
if has_fail_or_error {
2
} else if has_warn {
1
} else {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AuditGroup, AuditLayer, AuditResult, AuditStatus, Confidence};
fn make_result(id: &str, status: AuditStatus, group: AuditGroup) -> AuditResult {
AuditResult {
id: id.to_string(),
label: format!("Test {id}"),
group,
layer: AuditLayer::Behavioral,
status,
confidence: Confidence::High,
}
}
fn fixture_metadata() -> RunMetadata {
RunMetadata {
tool: ToolInfo {
name: "fixture-tool".into(),
binary: None,
version: None,
},
anc: AncInfo {
version: "0.0.0-test",
},
run: RunInfo {
invocation: "anc audit .".into(),
started_at: "1970-01-01T00:00:00Z".into(),
duration_ms: 0,
platform: PlatformInfo {
os: "test-os",
arch: "test-arch",
},
},
target: TargetInfo {
kind: "project".into(),
path: Some(".".into()),
command: None,
},
}
}
#[test]
fn test_format_json_valid() {
let results = vec![
make_result("c1", AuditStatus::Pass, AuditGroup::P1),
make_result("c2", AuditStatus::Fail("bad".into()), AuditGroup::P2),
];
let json = format_json(&results, &[], None, None, fixture_metadata());
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["schema_version"], "0.7");
assert_eq!(parsed["summary"]["total"], 2);
assert_eq!(parsed["summary"]["pass"], 1);
assert_eq!(parsed["summary"]["fail"], 1);
assert_eq!(parsed["results"][0]["status"], "pass");
assert!(parsed["results"][0]["evidence"].is_null());
assert_eq!(parsed["results"][0]["confidence"], "high");
assert_eq!(parsed["results"][1]["status"], "fail");
assert_eq!(parsed["results"][1]["evidence"], "bad");
assert_eq!(parsed["results"][1]["confidence"], "high");
assert!(parsed["coverage_summary"]["must"]["total"].is_number());
assert!(parsed["coverage_summary"]["should"]["total"].is_number());
assert!(parsed["coverage_summary"]["may"]["total"].is_number());
assert!(parsed["audience"].is_null());
assert!(parsed["audit_profile"].is_null());
let spec = parsed["spec_version"]
.as_str()
.expect("spec_version is a string");
assert!(!spec.is_empty(), "spec_version must not be empty");
}
#[test]
fn medium_confidence_serializes_as_medium() {
let mut r = make_result("c3", AuditStatus::Warn("soft".into()), AuditGroup::P6);
r.confidence = Confidence::Medium;
let view = AuditResultView::from_result(&r);
assert_eq!(view.confidence, "medium");
}
#[test]
fn coverage_summary_counts_verified_requirements() {
use crate::audit::Audit;
use crate::project::Project;
use crate::types::AuditLayer;
struct FakeAudit {
id: &'static str,
covers: &'static [&'static str],
}
impl Audit for FakeAudit {
fn id(&self) -> &str {
self.id
}
fn label(&self) -> &'static str {
"fake"
}
fn group(&self) -> AuditGroup {
AuditGroup::P1
}
fn layer(&self) -> AuditLayer {
AuditLayer::Behavioral
}
fn applicable(&self, _p: &Project) -> bool {
true
}
fn run(&self, _p: &Project) -> anyhow::Result<AuditResult> {
unreachable!()
}
fn covers(&self) -> &'static [&'static str] {
self.covers
}
}
let results = vec![make_result("verifier-a", AuditStatus::Pass, AuditGroup::P1)];
let audits: Vec<Box<dyn Audit>> = vec![Box::new(FakeAudit {
id: "verifier-a",
covers: &["p1-must-no-interactive"],
})];
let summary = build_coverage_summary(&results, &audits);
assert_eq!(summary.must.verified, 1);
assert_eq!(summary.should.verified, 0);
assert_eq!(summary.may.verified, 0);
assert!(summary.must.total >= 1);
}
#[test]
fn coverage_summary_excludes_audit_profile_suppressed_audits() {
use crate::audit::Audit;
use crate::principles::registry::SUPPRESSION_EVIDENCE_PREFIX;
use crate::project::Project;
use crate::types::AuditLayer;
struct FakeAudit {
id: &'static str,
covers: &'static [&'static str],
}
impl Audit for FakeAudit {
fn id(&self) -> &str {
self.id
}
fn label(&self) -> &'static str {
"fake"
}
fn group(&self) -> AuditGroup {
AuditGroup::P1
}
fn layer(&self) -> AuditLayer {
AuditLayer::Behavioral
}
fn applicable(&self, _p: &Project) -> bool {
true
}
fn run(&self, _p: &Project) -> anyhow::Result<AuditResult> {
unreachable!()
}
fn covers(&self) -> &'static [&'static str] {
self.covers
}
}
let results = vec![
make_result("verifier-ran", AuditStatus::Pass, AuditGroup::P1),
make_result(
"verifier-suppressed",
AuditStatus::Skip(format!("{SUPPRESSION_EVIDENCE_PREFIX}human-tui")),
AuditGroup::P1,
),
];
let audits: Vec<Box<dyn Audit>> = vec![
Box::new(FakeAudit {
id: "verifier-ran",
covers: &["p1-must-no-interactive"],
}),
Box::new(FakeAudit {
id: "verifier-suppressed",
covers: &["p1-should-tty-detection"],
}),
];
let summary = build_coverage_summary(&results, &audits);
assert_eq!(
summary.must.verified, 1,
"only the non-suppressed verifier's requirement should count; \
suppressed Skips MUST NOT inflate coverage_summary.verified",
);
assert_eq!(summary.should.verified, 0);
}
#[test]
fn test_exit_code_all_pass() {
let results = vec![
make_result("c1", AuditStatus::Pass, AuditGroup::P1),
make_result("c2", AuditStatus::Skip("n/a".into()), AuditGroup::P2),
];
assert_eq!(exit_code(&results), 0);
}
#[test]
fn test_exit_code_warn() {
let results = vec![
make_result("c1", AuditStatus::Pass, AuditGroup::P1),
make_result("c2", AuditStatus::Warn("meh".into()), AuditGroup::P2),
];
assert_eq!(exit_code(&results), 1);
}
#[test]
fn test_exit_code_fail() {
let results = vec![
make_result("c1", AuditStatus::Fail("bad".into()), AuditGroup::P1),
make_result("c2", AuditStatus::Warn("meh".into()), AuditGroup::P2),
];
assert_eq!(exit_code(&results), 2);
}
#[test]
fn test_exit_code_error() {
let results = vec![make_result(
"c1",
AuditStatus::Error("boom".into()),
AuditGroup::P1,
)];
assert_eq!(exit_code(&results), 2);
}
#[test]
fn test_audit_result_view_conversion() {
let r = make_result(
"test-id",
AuditStatus::Warn("warning msg".into()),
AuditGroup::P3,
);
let view = AuditResultView::from_result(&r);
assert_eq!(view.id, "test-id");
assert_eq!(view.status, "warn");
assert_eq!(view.evidence.as_deref(), Some("warning msg"));
assert_eq!(view.layer, "behavioral");
}
#[test]
fn test_audit_result_view_pass_has_no_evidence() {
let r = make_result("pass-id", AuditStatus::Pass, AuditGroup::P1);
let view = AuditResultView::from_result(&r);
assert_eq!(view.status, "pass");
assert!(view.evidence.is_none());
}
#[test]
fn format_json_emits_audience_when_all_signals_present() {
use crate::scorecard::audience::{SIGNAL_AUDIT_IDS, classify};
let results: Vec<AuditResult> = SIGNAL_AUDIT_IDS
.iter()
.map(|id| make_result(id, AuditStatus::Pass, AuditGroup::P1))
.collect();
let audience = classify(&results);
let json = format_json(&results, &[], audience, None, fixture_metadata());
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["audience"], "agent-optimized");
assert!(parsed["audit_profile"].is_null());
assert_eq!(parsed["schema_version"], "0.7");
}
#[test]
fn format_json_emits_human_primary_when_signals_warn() {
use crate::scorecard::audience::{SIGNAL_AUDIT_IDS, classify};
let results: Vec<AuditResult> = SIGNAL_AUDIT_IDS
.iter()
.enumerate()
.map(|(i, id)| {
let status = if i < 3 {
AuditStatus::Warn(format!("missing {id}"))
} else {
AuditStatus::Pass
};
make_result(id, status, AuditGroup::P1)
})
.collect();
let audience = classify(&results);
let json = format_json(&results, &[], audience, None, fixture_metadata());
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["audience"], "human-primary");
}
#[test]
fn format_json_audience_null_when_signals_missing() {
use crate::scorecard::audience::classify;
let results = vec![make_result(
"p1-env-flags-source",
AuditStatus::Pass,
AuditGroup::P1,
)];
let audience = classify(&results);
let json = format_json(&results, &[], audience, None, fixture_metadata());
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert!(parsed["audience"].is_null());
}
#[test]
fn format_json_echoes_audit_profile() {
let results = vec![make_result("c1", AuditStatus::Pass, AuditGroup::P1)];
let json = format_json(
&results,
&[],
None,
Some("human-tui".into()),
fixture_metadata(),
);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["audit_profile"], "human-tui");
}
#[test]
fn format_json_audience_reason_insufficient_signal() {
let results = vec![make_result(
"p1-env-flags-source",
AuditStatus::Pass,
AuditGroup::P1,
)];
let json = format_json(&results, &[], None, None, fixture_metadata());
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert!(parsed["audience"].is_null());
assert_eq!(parsed["audience_reason"], "insufficient_signal");
}
#[test]
fn format_json_audience_reason_omitted_when_audience_labeled() {
use crate::scorecard::audience::{SIGNAL_AUDIT_IDS, classify};
let results: Vec<AuditResult> = SIGNAL_AUDIT_IDS
.iter()
.map(|id| make_result(id, AuditStatus::Pass, AuditGroup::P1))
.collect();
let audience = classify(&results);
let json = format_json(&results, &[], audience, None, fixture_metadata());
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["audience"], "agent-optimized");
assert!(
parsed.get("audience_reason").is_none(),
"audience_reason key should be absent when audience is labeled, got {}",
parsed["audience_reason"],
);
}
#[test]
fn format_json_audience_reason_suppressed() {
use crate::principles::registry::SUPPRESSION_EVIDENCE_PREFIX;
use crate::scorecard::audience::{SIGNAL_AUDIT_IDS, classify};
let results: Vec<AuditResult> = SIGNAL_AUDIT_IDS
.iter()
.enumerate()
.map(|(i, id)| {
let status = if i == 0 {
AuditStatus::Skip(format!("{SUPPRESSION_EVIDENCE_PREFIX}human-tui"))
} else {
AuditStatus::Pass
};
make_result(id, status, AuditGroup::P1)
})
.collect();
let audience = classify(&results);
let json = format_json(
&results,
&[],
audience,
Some("human-tui".into()),
fixture_metadata(),
);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert!(parsed["audience"].is_null());
assert_eq!(parsed["audience_reason"], "suppressed");
}
#[test]
fn exit_code_drops_when_audit_profile_suppresses_a_would_have_failed_audit() {
use crate::principles::registry::SUPPRESSION_EVIDENCE_PREFIX;
let baseline = vec![
make_result("c-pass", AuditStatus::Pass, AuditGroup::P1),
make_result(
"c-would-fail",
AuditStatus::Fail("violates MUST".into()),
AuditGroup::P1,
),
];
assert_eq!(exit_code(&baseline), 2, "baseline: a Fail → exit 2");
let suppressed = vec![
make_result("c-pass", AuditStatus::Pass, AuditGroup::P1),
make_result(
"c-would-fail",
AuditStatus::Skip(format!("{SUPPRESSION_EVIDENCE_PREFIX}human-tui")),
AuditGroup::P1,
),
];
assert_eq!(
exit_code(&suppressed),
0,
"suppression by audit_profile must lower the exit code — \
Fail → Skip is intentional masking per plan R4",
);
}
#[test]
fn scorecard_level_enum_values_are_kebab_case() {
use crate::scorecard::audience::{SIGNAL_AUDIT_IDS, classify};
let results: Vec<AuditResult> = SIGNAL_AUDIT_IDS
.iter()
.map(|id| make_result(id, AuditStatus::Pass, AuditGroup::P1))
.collect();
let audience = classify(&results);
let json = format_json(
&results,
&[],
audience,
Some("human-tui".into()),
fixture_metadata(),
);
assert!(
json.contains("\"audience\": \"agent-optimized\""),
"audience must serialize as kebab-case 'agent-optimized', got:\n{json}",
);
assert!(
!json.contains("\"agent_optimized\""),
"audience must NOT render as snake_case 'agent_optimized' — \
kebab-case unified with audit_profile in v0.1.3",
);
assert!(
!json.contains("\"human_primary\""),
"audience must NOT render as snake_case 'human_primary'",
);
assert!(
json.contains("\"audit_profile\": \"human-tui\""),
"audit_profile must serialize as kebab-case 'human-tui', got:\n{json}",
);
assert!(
!json.contains("\"human_tui\""),
"audit_profile must NOT render as snake_case 'human_tui' — \
would desync from the --audit-profile flag value shape",
);
}
#[test]
fn schema_v05_emits_every_documented_key() {
let results = vec![make_result("c1", AuditStatus::Pass, AuditGroup::P1)];
let metadata = RunMetadata {
tool: ToolInfo {
name: "demo".into(),
binary: None,
version: None,
},
anc: AncInfo {
version: "0.0.1-test",
},
run: RunInfo {
invocation: "anc audit .".into(),
started_at: "2026-04-29T16:00:00Z".into(),
duration_ms: 42,
platform: PlatformInfo {
os: "linux",
arch: "x86_64",
},
},
target: TargetInfo {
kind: "project".into(),
path: Some("/tmp/x".into()),
command: None,
},
};
let json = format_json(&results, &[], None, None, metadata);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
for path in [
"schema_version",
"results",
"summary",
"coverage_summary",
"audience",
"audit_profile",
"spec_version",
] {
assert!(
parsed.get(path).is_some(),
"0.1-0.3 key `{path}` must remain present in 0.5",
);
}
assert_eq!(parsed["schema_version"], "0.7");
for path in [
"tool.name",
"tool.binary",
"tool.version",
"anc.version",
"run.invocation",
"run.started_at",
"run.duration_ms",
"run.platform.os",
"run.platform.arch",
"target.kind",
"target.path",
"target.command",
"badge.eligible",
"badge.score_pct",
"badge.embed_markdown",
"badge.scorecard_url",
"badge.badge_url",
"badge.convention_url",
] {
let mut node = &parsed;
for segment in path.split('.') {
node = node
.get(segment)
.unwrap_or_else(|| panic!("0.5 key `{path}` missing — segment `{segment}`"));
}
}
assert_eq!(parsed["tool"]["name"], "demo");
assert_eq!(parsed["anc"]["version"], "0.0.1-test");
assert_eq!(parsed["run"]["invocation"], "anc audit .");
assert_eq!(parsed["run"]["duration_ms"], 42);
assert_eq!(parsed["run"]["platform"]["os"], "linux");
assert_eq!(parsed["target"]["kind"], "project");
assert_eq!(parsed["target"]["path"], "/tmp/x");
assert!(parsed["tool"]["version"].is_null());
assert!(parsed["tool"]["binary"].is_null());
assert!(parsed["target"]["command"].is_null());
}
#[test]
fn compute_badge_eligible_when_all_pass_and_slug_present() {
let results = vec![
make_result("c1", AuditStatus::Pass, AuditGroup::P1),
make_result("c2", AuditStatus::Pass, AuditGroup::P2),
make_result("c3", AuditStatus::Pass, AuditGroup::P3),
];
let badge = compute_badge(&results, "navi");
assert!(badge.eligible);
assert_eq!(badge.score_pct, 100);
assert_eq!(
badge.embed_markdown.as_deref(),
Some("[](https://anc.dev/score/navi)"),
);
assert_eq!(
badge.scorecard_url.as_deref(),
Some("https://anc.dev/score/navi"),
);
assert_eq!(
badge.badge_url.as_deref(),
Some("https://anc.dev/badge/navi.svg"),
);
assert_eq!(badge.convention_url, "https://anc.dev/badge");
}
#[test]
fn compute_badge_below_floor_emits_urls_but_no_embed() {
let results = vec![
make_result("c1", AuditStatus::Pass, AuditGroup::P1),
make_result("c2", AuditStatus::Fail("a".into()), AuditGroup::P2),
make_result("c3", AuditStatus::Fail("b".into()), AuditGroup::P3),
make_result("c4", AuditStatus::Fail("c".into()), AuditGroup::P4),
make_result("c5", AuditStatus::Fail("d".into()), AuditGroup::P5),
];
let badge = compute_badge(&results, "needs-work");
assert!(!badge.eligible);
assert_eq!(badge.score_pct, 20);
assert!(
badge.embed_markdown.is_none(),
"below the floor: embed_markdown must be None per the do-not-nag rule",
);
assert!(badge.scorecard_url.is_some());
assert!(badge.badge_url.is_some());
}
#[test]
fn compute_badge_at_floor_is_eligible() {
let results = vec![
make_result("c1", AuditStatus::Pass, AuditGroup::P1),
make_result("c2", AuditStatus::Pass, AuditGroup::P2),
make_result("c3", AuditStatus::Pass, AuditGroup::P3),
make_result("c4", AuditStatus::Pass, AuditGroup::P4),
make_result("c5", AuditStatus::Pass, AuditGroup::P5),
make_result("c6", AuditStatus::Pass, AuditGroup::P6),
make_result("c7", AuditStatus::Pass, AuditGroup::P7),
make_result("c8", AuditStatus::Fail("one".into()), AuditGroup::P8),
make_result("c9", AuditStatus::Fail("two".into()), AuditGroup::P8),
make_result("c10", AuditStatus::Fail("three".into()), AuditGroup::P8),
];
let badge = compute_badge(&results, "edge-case");
assert!(badge.eligible, "score == floor must qualify");
assert_eq!(badge.score_pct, 70);
assert!(badge.embed_markdown.is_some());
}
#[test]
fn compute_badge_skips_excluded_from_denominator() {
let results = vec![
make_result("c1", AuditStatus::Pass, AuditGroup::P1),
make_result(
"c2",
AuditStatus::Skip("not applicable".into()),
AuditGroup::P2,
),
make_result("c3", AuditStatus::Error("boom".into()), AuditGroup::P3),
];
let badge = compute_badge(&results, "skipper");
assert_eq!(badge.score_pct, 100);
assert!(badge.eligible);
}
#[test]
fn compute_badge_no_scoring_data_is_ineligible() {
let results = vec![
make_result("c1", AuditStatus::Skip("filtered".into()), AuditGroup::P1),
make_result("c2", AuditStatus::Skip("filtered".into()), AuditGroup::P2),
];
let badge = compute_badge(&results, "ghost");
assert_eq!(badge.score_pct, 0);
assert!(!badge.eligible);
assert!(badge.embed_markdown.is_none());
}
#[test]
fn compute_badge_empty_slug_is_ineligible_even_at_perfect_score() {
let results = vec![make_result("c1", AuditStatus::Pass, AuditGroup::P1)];
let badge = compute_badge(&results, "");
assert_eq!(badge.score_pct, 100);
assert!(!badge.eligible);
assert!(badge.embed_markdown.is_none());
assert!(badge.scorecard_url.is_none());
assert!(badge.badge_url.is_none());
assert_eq!(badge.convention_url, "https://anc.dev/badge");
}
#[test]
fn badge_text_hint_present_when_eligible() {
let badge = compute_badge(
&[make_result("c1", AuditStatus::Pass, AuditGroup::P1)],
"demo",
);
let hint = badge.text_hint().expect("eligible run must produce hint");
assert!(
hint.contains("Score: 100%"),
"hint should announce the score, got: {hint}",
);
assert!(
hint.contains("https://anc.dev/badge/demo.svg"),
"hint should embed the canonical badge URL, got: {hint}",
);
assert!(
hint.contains("https://anc.dev/score/demo"),
"hint should link to the per-tool scorecard page, got: {hint}",
);
assert!(
hint.contains("https://anc.dev/badge"),
"hint should reference the convention page, got: {hint}",
);
}
#[test]
fn badge_text_hint_absent_when_below_floor() {
let badge = compute_badge(
&[
make_result("c1", AuditStatus::Fail("a".into()), AuditGroup::P1),
make_result("c2", AuditStatus::Fail("b".into()), AuditGroup::P2),
],
"needs-work",
);
assert!(badge.text_hint().is_none());
}
#[test]
fn format_text_appends_hint_when_badge_eligible() {
let results = vec![make_result("c1", AuditStatus::Pass, AuditGroup::P1)];
let badge = compute_badge(&results, "demo");
let text = format_text(&results, false, Some(&badge), TextOptions::default());
assert!(
text.contains("qualifies for the agent-native badge"),
"format_text must append the badge hint when eligible:\n{text}",
);
assert!(
text.contains("https://anc.dev/badge/demo.svg"),
"embedded URL must use the tool slug:\n{text}",
);
}
#[test]
fn format_text_omits_hint_when_below_floor() {
let results = vec![
make_result("c1", AuditStatus::Fail("a".into()), AuditGroup::P1),
make_result("c2", AuditStatus::Fail("b".into()), AuditGroup::P2),
];
let badge = compute_badge(&results, "needs-work");
let text = format_text(&results, false, Some(&badge), TextOptions::default());
assert!(
!text.contains("agent-native badge"),
"below-floor runs must not nag:\n{text}",
);
}
#[test]
fn format_text_without_badge_arg_is_unchanged() {
let results = vec![make_result("c1", AuditStatus::Pass, AuditGroup::P1)];
let text = format_text(&results, false, None, TextOptions::default());
assert!(!text.contains("agent-native badge"));
}
#[test]
fn format_text_raw_emits_id_tab_status_per_line() {
let results = vec![
make_result("c1", AuditStatus::Pass, AuditGroup::P1),
make_result("c2", AuditStatus::Warn("watch this".into()), AuditGroup::P2),
make_result("c3", AuditStatus::Fail("broken".into()), AuditGroup::P3),
make_result("c4", AuditStatus::Skip("n/a".into()), AuditGroup::P4),
];
let opts = TextOptions {
raw: true,
color: false,
};
let text = format_text(&results, false, None, opts);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines, vec!["c1\tPASS", "c2\tWARN", "c3\tFAIL", "c4\tSKIP"]);
}
#[test]
fn format_text_color_wraps_status_prefix() {
let results = vec![make_result("c1", AuditStatus::Pass, AuditGroup::P1)];
let opts = TextOptions {
raw: false,
color: true,
};
let text = format_text(&results, false, None, opts);
assert!(
text.contains('\u{1b}'),
"color=true should embed ANSI escapes around the PASS prefix:\n{text}",
);
}
#[test]
fn scorecard_emits_badge_block() {
let results = vec![make_result("c1", AuditStatus::Pass, AuditGroup::P1)];
let metadata = RunMetadata {
tool: ToolInfo {
name: "navi".into(),
binary: Some("navi".into()),
version: Some("0.1.0".into()),
},
anc: AncInfo {
version: "0.0.0-test",
},
run: RunInfo {
invocation: "anc audit .".into(),
started_at: "1970-01-01T00:00:00Z".into(),
duration_ms: 0,
platform: PlatformInfo {
os: "test-os",
arch: "test-arch",
},
},
target: TargetInfo {
kind: "project".into(),
path: Some(".".into()),
command: None,
},
};
let json = format_json(&results, &[], None, None, metadata);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["badge"]["eligible"], true);
assert_eq!(parsed["badge"]["score_pct"], 100);
assert_eq!(
parsed["badge"]["embed_markdown"],
"[](https://anc.dev/score/navi)"
);
assert_eq!(
parsed["badge"]["scorecard_url"],
"https://anc.dev/score/navi"
);
assert_eq!(
parsed["badge"]["badge_url"],
"https://anc.dev/badge/navi.svg"
);
assert_eq!(parsed["badge"]["convention_url"], "https://anc.dev/badge");
}
fn make_raw(id: &str, status: AuditStatus) -> AuditResult {
make_result(id, status, AuditGroup::P2)
}
fn make_raw_on(id: &str, status: AuditStatus, layer: AuditLayer) -> AuditResult {
AuditResult {
layer,
..make_raw(id, status)
}
}
struct FakeAudit {
id: &'static str,
covers: &'static [&'static str],
}
impl crate::audit::Audit for FakeAudit {
fn id(&self) -> &str {
self.id
}
fn label(&self) -> &'static str {
"fake"
}
fn group(&self) -> AuditGroup {
AuditGroup::P2
}
fn layer(&self) -> AuditLayer {
AuditLayer::Behavioral
}
fn applicable(&self, _p: &crate::project::Project) -> bool {
true
}
fn run(&self, _p: &crate::project::Project) -> anyhow::Result<AuditResult> {
unreachable!()
}
fn covers(&self) -> &'static [&'static str] {
self.covers
}
}
#[test]
fn fan_out_emits_one_row_per_covered_requirement() {
let raw = vec![make_raw(
"p3-version",
AuditStatus::Warn("short alias missing".into()),
)];
let catalog: Vec<Box<dyn crate::audit::Audit>> = vec![Box::new(FakeAudit {
id: "p3-version",
covers: &["p3-must-version", "p3-should-version-short"],
})];
let rows = fan_out_per_row(&raw, &catalog);
assert_eq!(rows.len(), 2);
let ids: Vec<&str> = rows.iter().map(|(r, _)| r.id.as_str()).collect();
assert!(ids.contains(&"p3-must-version"));
assert!(ids.contains(&"p3-should-version-short"));
for (r, audit_id) in &rows {
assert_eq!(
audit_id, "p3-version",
"audit_id provenance lost on row {}",
r.id
);
assert!(
matches!(r.status, AuditStatus::Warn(_)),
"probe status must propagate to every covered row pre-propagation",
);
}
}
#[test]
fn fan_out_emits_passthrough_for_audits_without_covers() {
let raw = vec![make_raw("orphan-audit", AuditStatus::Pass)];
let catalog: Vec<Box<dyn crate::audit::Audit>> = vec![Box::new(FakeAudit {
id: "orphan-audit",
covers: &[],
})];
let rows = fan_out_per_row(&raw, &catalog);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0.id, "orphan-audit");
assert_eq!(rows[0].1, "orphan-audit");
}
#[test]
fn propagation_passes_through_when_antecedent_is_pass_warn_fail() {
let raw = vec![
make_raw("p2-json-output", AuditStatus::Pass),
make_raw("p2-schema-print", AuditStatus::Fail("missing".into())),
];
let mut rows = vec![(
make_raw("p2-must-schema-print", AuditStatus::Fail("missing".into())),
"p2-schema-print".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
assert!(matches!(rows[0].0.status, AuditStatus::Fail(_)));
}
#[test]
fn propagation_collapses_consequent_when_antecedent_is_opt_out() {
let raw = vec![
make_raw(
"p2-json-output",
AuditStatus::OptOut("no --output flag".into()),
),
make_raw("p2-schema-print", AuditStatus::Pass),
];
let mut rows = vec![(
make_raw("p2-must-schema-print", AuditStatus::Pass),
"p2-schema-print".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
match &rows[0].0.status {
AuditStatus::NotApplicable(reason) => {
assert!(
reason.contains("p2-json-output") && reason.contains("opt_out"),
"evidence should cite the antecedent + its status, got: {reason}",
);
}
other => panic!("expected NotApplicable, got {other:?}"),
}
}
#[test]
fn propagation_collapses_consequent_when_antecedent_is_n_a() {
let raw = vec![
make_raw(
"p2-json-output",
AuditStatus::NotApplicable("upstream n/a".into()),
),
make_raw("p2-schema-print", AuditStatus::Pass),
];
let mut rows = vec![(
make_raw("p2-must-schema-print", AuditStatus::Pass),
"p2-schema-print".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
assert!(matches!(rows[0].0.status, AuditStatus::NotApplicable(_)));
}
#[test]
fn propagation_inherits_skip_from_antecedent() {
let raw = vec![
make_raw("p2-json-output", AuditStatus::Skip("probe limit".into())),
make_raw("p2-schema-print", AuditStatus::Pass),
];
let mut rows = vec![(
make_raw("p2-must-schema-print", AuditStatus::Pass),
"p2-schema-print".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
assert!(matches!(rows[0].0.status, AuditStatus::Skip(_)));
}
#[test]
fn propagation_inherits_error_from_antecedent() {
let raw = vec![
make_raw("p2-json-output", AuditStatus::Error("probe crashed".into())),
make_raw("p2-schema-print", AuditStatus::Pass),
];
let mut rows = vec![(
make_raw("p2-must-schema-print", AuditStatus::Pass),
"p2-schema-print".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
assert!(matches!(rows[0].0.status, AuditStatus::Error(_)));
}
#[test]
fn propagation_leaves_universal_rows_untouched() {
let raw = vec![make_raw("p1-non-interactive", AuditStatus::Pass)];
let mut rows = vec![(
make_raw("p1-must-no-interactive", AuditStatus::Pass),
"p1-non-interactive".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
assert!(matches!(rows[0].0.status, AuditStatus::Pass));
}
#[test]
fn build_row_results_fans_out_then_propagates() {
let raw = vec![
make_raw(
"p2-json-output",
AuditStatus::OptOut("no --output flag".into()),
),
make_raw("p2-schema-print", AuditStatus::Pass),
];
let catalog: Vec<Box<dyn crate::audit::Audit>> = vec![
Box::new(FakeAudit {
id: "p2-json-output",
covers: &["p2-must-output-flag"],
}),
Box::new(FakeAudit {
id: "p2-schema-print",
covers: &["p2-must-schema-print"],
}),
];
let rows = build_row_results(&raw, &catalog);
let schema_row = rows
.iter()
.find(|(r, _)| r.id == "p2-must-schema-print")
.expect("schema-print requirement row present");
match &schema_row.0.status {
AuditStatus::NotApplicable(reason) => assert!(
reason.contains("p2-json-output") && reason.contains("opt_out"),
"consequent must collapse to n_a citing the antecedent, got: {reason}",
),
other => panic!("expected NotApplicable, got {other:?}"),
}
}
#[test]
fn exit_code_drops_to_zero_when_consequent_propagates_to_n_a() {
let raw = vec![
make_raw(
"p2-json-output",
AuditStatus::OptOut("no --output flag".into()),
),
make_raw("p2-schema-print", AuditStatus::Fail("no schema".into())),
];
assert_eq!(exit_code(&raw), 2, "raw probe Fail would exit 2");
let catalog: Vec<Box<dyn crate::audit::Audit>> = vec![
Box::new(FakeAudit {
id: "p2-json-output",
covers: &["p2-must-output-flag"],
}),
Box::new(FakeAudit {
id: "p2-schema-print",
covers: &["p2-must-schema-print"],
}),
];
let per_row: Vec<AuditResult> = build_row_results(&raw, &catalog)
.into_iter()
.map(|(r, _)| r)
.collect();
assert_eq!(
exit_code(&per_row),
0,
"consequent propagated to n_a must not lift the exit code",
);
}
#[test]
fn text_and_json_agree_on_a_propagated_conditional_row() {
let raw = vec![
make_raw(
"p2-json-output",
AuditStatus::OptOut("no --output flag".into()),
),
make_raw(
"p2-schema-print",
AuditStatus::Fail("no schema surface".into()),
),
];
let catalog: Vec<Box<dyn crate::audit::Audit>> = vec![
Box::new(FakeAudit {
id: "p2-json-output",
covers: &["p2-must-output-flag"],
}),
Box::new(FakeAudit {
id: "p2-schema-print",
covers: &["p2-must-schema-print"],
}),
];
let per_row: Vec<AuditResult> = build_row_results(&raw, &catalog)
.into_iter()
.map(|(r, _)| r)
.collect();
let text_badge = compute_badge(&per_row, "fixture-tool");
let text = format_text(&per_row, false, Some(&text_badge), TextOptions::default());
let scorecard = build_scorecard(&raw, &catalog, None, None, fixture_metadata());
let json_consequent = scorecard
.results
.iter()
.find(|v| v.id == "p2-must-schema-print")
.expect("consequent row present in JSON");
assert_eq!(json_consequent.status, "n_a");
assert!(
text.contains("[N/A ]") && text.contains("p2-must-schema-print"),
"text must render the consequent row as N/A:\n{text}",
);
assert!(
!text.contains("[FAIL]"),
"the only raw-Fail probe propagated to n_a; no row may render FAIL:\n{text}",
);
assert_eq!(
per_row.len(),
scorecard.results.len(),
"text row count must equal JSON results.len()",
);
assert_eq!(
text_badge.score_pct, scorecard.badge.score_pct,
"text badge score must equal JSON badge.score_pct",
);
}
#[test]
fn score_pct_counts_opt_out_in_denominator_excludes_n_a() {
let results = vec![
make_raw("c1", AuditStatus::Pass),
make_raw("c2", AuditStatus::OptOut("deliberate".into())),
make_raw("c3", AuditStatus::NotApplicable("conditional unmet".into())),
];
assert_eq!(score_pct(&results), 50);
let mixed = vec![
make_raw("c1", AuditStatus::Pass),
make_raw("c2", AuditStatus::Fail("violates".into())),
make_raw("c3", AuditStatus::OptOut("deliberate".into())),
make_raw("c4", AuditStatus::NotApplicable("conditional unmet".into())),
];
assert_eq!(score_pct(&mixed), 33);
}
#[test]
fn score_pct_warn_earns_half_credit() {
let results = vec![
make_raw("c1", AuditStatus::Pass),
make_raw("c2", AuditStatus::Warn("partial".into())),
];
assert_eq!(score_pct(&results), 75);
}
#[test]
fn score_pct_counts_behavioral_rows_only() {
let results = vec![
make_raw_on("b1", AuditStatus::Pass, AuditLayer::Behavioral),
make_raw_on("s1", AuditStatus::Fail("source".into()), AuditLayer::Source),
make_raw_on(
"p1",
AuditStatus::Fail("project".into()),
AuditLayer::Project,
),
];
assert_eq!(score_pct(&results), 100);
let non_behavioral = vec![
make_raw_on("s1", AuditStatus::Pass, AuditLayer::Source),
make_raw_on("p1", AuditStatus::Pass, AuditLayer::Project),
];
assert_eq!(score_pct(&non_behavioral), 0);
}
#[test]
fn score_pct_matches_scoring_md_worked_example() {
let mut results = Vec::new();
for i in 0..20 {
results.push(make_raw(&format!("pass-{i}"), AuditStatus::Pass));
}
for i in 0..7 {
results.push(make_raw(
&format!("warn-{i}"),
AuditStatus::Warn("partial".into()),
));
}
results.push(make_raw("opt", AuditStatus::OptOut("declined".into())));
results.push(make_raw(
"na",
AuditStatus::NotApplicable("antecedent unmet".into()),
));
for i in 0..14 {
results.push(make_raw(
&format!("skip-{i}"),
AuditStatus::Skip("unmeasured".into()),
));
}
assert_eq!(score_pct(&results), 84);
}
#[test]
fn summary_counts_seven_statuses_independently() {
let results = vec![
make_raw("a", AuditStatus::Pass),
make_raw("b", AuditStatus::Warn("w".into())),
make_raw("c", AuditStatus::Fail("f".into())),
make_raw("d", AuditStatus::OptOut("o".into())),
make_raw("e", AuditStatus::NotApplicable("n".into())),
make_raw("f", AuditStatus::Skip("s".into())),
make_raw("g", AuditStatus::Error("e".into())),
];
let s = build_summary(&results);
assert_eq!(s.total, 7);
assert_eq!(s.pass, 1);
assert_eq!(s.warn, 1);
assert_eq!(s.fail, 1);
assert_eq!(s.opt_out, 1);
assert_eq!(s.n_a, 1);
assert_eq!(s.skip, 1);
assert_eq!(s.error, 1);
}
#[test]
fn audit_result_view_carries_tier_and_audit_id() {
let r = make_raw("p3-must-version", AuditStatus::Pass);
let view = AuditResultView::from_row(&r, "p3-version");
assert_eq!(view.id, "p3-must-version");
assert_eq!(view.audit_id, "p3-version");
assert_eq!(view.tier.as_deref(), Some("must"));
}
#[test]
fn audit_result_view_tier_is_null_for_unknown_row_id() {
let r = make_raw("not-a-real-row-id", AuditStatus::Pass);
let view = AuditResultView::from_row(&r, "some-audit");
assert!(view.tier.is_none(), "got: {:?}", view.tier);
}
#[test]
fn opt_out_status_serializes_as_opt_out_in_json() {
let r = make_raw("c1", AuditStatus::OptOut("test reason".into()));
let view = AuditResultView::from_result(&r);
assert_eq!(view.status, "opt_out");
assert_eq!(view.evidence.as_deref(), Some("test reason"));
}
#[test]
fn n_a_status_serializes_as_n_a_in_json() {
let r = make_raw("c1", AuditStatus::NotApplicable("antecedent unmet".into()));
let view = AuditResultView::from_result(&r);
assert_eq!(view.status, "n_a");
assert_eq!(view.evidence.as_deref(), Some("antecedent unmet"));
}
#[test]
fn rt_propagation_is_idempotent() {
let raw = vec![
make_raw(
"p2-json-output",
AuditStatus::OptOut("no --output flag".into()),
),
make_raw("p2-schema-print", AuditStatus::Pass),
];
let mut rows = vec![(
make_raw("p2-must-schema-print", AuditStatus::Pass),
"p2-schema-print".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
let after_first: Vec<(String, String)> = rows
.iter()
.map(|(r, c)| {
(
serde_json::to_string(&r.status).expect("status serializes"),
c.clone(),
)
})
.collect();
propagate_antecedents(&mut rows, &raw);
let after_second: Vec<(String, String)> = rows
.iter()
.map(|(r, c)| {
(
serde_json::to_string(&r.status).expect("status serializes"),
c.clone(),
)
})
.collect();
assert_eq!(after_first, after_second, "propagation must be idempotent");
}
#[test]
fn rt_propagation_no_op_when_antecedent_did_not_run() {
let raw = vec![make_raw("p2-schema-print", AuditStatus::Pass)];
let mut rows = vec![(
make_raw("p2-must-schema-print", AuditStatus::Pass),
"p2-schema-print".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
assert!(
matches!(rows[0].0.status, AuditStatus::Pass),
"no antecedent in raw → row untouched, got: {:?}",
rows[0].0.status,
);
}
#[test]
fn rt_propagation_inherits_audit_profile_suppression_as_skip() {
use crate::principles::registry::SUPPRESSION_EVIDENCE_PREFIX;
let raw = vec![
make_raw(
"p2-json-output",
AuditStatus::Skip(format!("{SUPPRESSION_EVIDENCE_PREFIX}human-tui")),
),
make_raw("p2-schema-print", AuditStatus::Pass),
];
let mut rows = vec![(
make_raw("p2-must-schema-print", AuditStatus::Pass),
"p2-schema-print".to_string(),
)];
propagate_antecedents(&mut rows, &raw);
match &rows[0].0.status {
AuditStatus::Skip(reason) => {
assert!(
reason.contains("p2-json-output"),
"propagated Skip must cite the antecedent, got: {reason}",
);
assert!(
reason.contains("human-tui"),
"propagated Skip must preserve the suppression reason, got: {reason}",
);
}
other => panic!("expected Skip, got {other:?}"),
}
}
#[test]
fn rt_score_pct_only_n_a_returns_zero_without_panic() {
let results: Vec<AuditResult> = (0..100)
.map(|i| {
make_raw(
&format!("row-{i}"),
AuditStatus::NotApplicable("conditional unmet".into()),
)
})
.collect();
assert_eq!(score_pct(&results), 0);
}
#[test]
fn rt_score_pct_only_opt_out_returns_zero_without_panic() {
let results: Vec<AuditResult> = (0..50)
.map(|i| {
make_raw(
&format!("row-{i}"),
AuditStatus::OptOut("deliberate".into()),
)
})
.collect();
assert_eq!(score_pct(&results), 0);
}
#[test]
fn rt_score_pct_one_pass_amid_999_n_a_returns_100() {
let mut results: Vec<AuditResult> = (0..999)
.map(|i| {
make_raw(
&format!("row-{i}"),
AuditStatus::NotApplicable("conditional unmet".into()),
)
})
.collect();
results.push(make_raw("row-last", AuditStatus::Pass));
assert_eq!(score_pct(&results), 100);
}
#[test]
fn rt_score_pct_skip_and_error_still_excluded() {
let mut results = vec![make_raw("good", AuditStatus::Pass)];
for i in 0..100 {
results.push(make_raw(
&format!("s-{i}"),
AuditStatus::Skip("limit".into()),
));
results.push(make_raw(
&format!("e-{i}"),
AuditStatus::Error("boom".into()),
));
}
assert_eq!(score_pct(&results), 100);
}
#[test]
fn rt_evidence_with_control_chars_roundtrips_through_json() {
let hostile: &str = "line1\nline2\t\"quoted\"\\backslash\u{0007}bell";
let r = make_raw("c1", AuditStatus::Warn(hostile.to_string()));
let view = AuditResultView::from_result(&r);
let json = serde_json::to_string(&view).expect("view serializes");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("re-parses");
assert_eq!(
parsed["evidence"].as_str(),
Some(hostile),
"evidence must roundtrip through JSON without loss",
);
}
#[test]
fn rt_evidence_with_unicode_zero_width_and_rtl_roundtrips() {
let hostile = "left\u{202e}right\u{200b}invisible";
let r = make_raw("c1", AuditStatus::OptOut(hostile.to_string()));
let view = AuditResultView::from_result(&r);
let json = serde_json::to_string(&view).expect("view serializes");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("re-parses");
assert_eq!(parsed["evidence"].as_str(), Some(hostile));
assert_eq!(parsed["status"], "opt_out");
}
#[test]
fn rt_summary_total_equals_sum_of_per_status_counts() {
let statuses = vec![
AuditStatus::Pass,
AuditStatus::Pass,
AuditStatus::Warn("w".into()),
AuditStatus::Fail("f".into()),
AuditStatus::OptOut("o".into()),
AuditStatus::OptOut("o".into()),
AuditStatus::OptOut("o".into()),
AuditStatus::NotApplicable("n".into()),
AuditStatus::Skip("s".into()),
AuditStatus::Error("e".into()),
];
let results: Vec<AuditResult> = statuses
.into_iter()
.enumerate()
.map(|(i, s)| make_raw(&format!("c{i}"), s))
.collect();
let s = build_summary(&results);
assert_eq!(
s.total,
s.pass + s.warn + s.fail + s.opt_out + s.n_a + s.skip + s.error,
"summary.total must equal the sum of every per-status counter",
);
}
#[test]
fn rt_full_pipeline_n_a_excluded_from_summary_n_a_and_score() {
let raw = vec![
make_raw("p2-json-output", AuditStatus::OptOut("no flag".into())),
make_raw("p2-schema-print", AuditStatus::Pass),
make_raw("p1-non-interactive", AuditStatus::Pass),
];
let catalog: Vec<Box<dyn crate::audit::Audit>> = vec![
Box::new(FakeAudit {
id: "p2-json-output",
covers: &["p2-must-output-flag"],
}),
Box::new(FakeAudit {
id: "p2-schema-print",
covers: &["p2-must-schema-print"],
}),
Box::new(FakeAudit {
id: "p1-non-interactive",
covers: &["p1-must-no-interactive"],
}),
];
let mut rows = fan_out_per_row(&raw, &catalog);
propagate_antecedents(&mut rows, &raw);
let per_row: Vec<AuditResult> = rows.into_iter().map(|(r, _)| r).collect();
let s = build_summary(&per_row);
assert_eq!(s.opt_out, 1, "p2-must-output-flag → opt_out: got {s:?}");
assert_eq!(
s.n_a, 1,
"p2-must-schema-print → n_a via propagation: got {s:?}",
);
assert_eq!(score_pct(&per_row), 50);
}
}