use std::collections::BTreeMap;
use std::fmt::Write as _;
use serde::Serialize;
use crate::check::Check;
use crate::principles::registry::{Applicability, Level, REQUIREMENTS};
use crate::types::CheckLayer;
#[derive(Debug, Clone, Serialize)]
pub struct Verifier {
pub check_id: String,
pub layer: CheckLayer,
}
#[derive(Debug, Serialize)]
pub struct MatrixRow {
pub id: &'static str,
pub principle: u8,
pub level: Level,
pub summary: &'static str,
pub applicability: Applicability,
pub verifiers: Vec<Verifier>,
}
#[derive(Debug, Serialize)]
pub struct Matrix {
pub schema_version: &'static str,
pub generated_by: &'static str,
pub rows: Vec<MatrixRow>,
pub summary: MatrixSummary,
}
#[derive(Debug, Serialize)]
pub struct MatrixSummary {
pub total: usize,
pub covered: usize,
pub uncovered: usize,
pub dual_layer: usize,
pub must: LevelSummary,
pub should: LevelSummary,
pub may: LevelSummary,
}
#[derive(Debug, Serialize)]
pub struct LevelSummary {
pub total: usize,
pub covered: usize,
}
const SCHEMA_VERSION: &str = "1.0";
const GENERATED_BY: &str = "anc generate coverage-matrix";
pub fn build(checks: &[Box<dyn Check>]) -> Matrix {
let mut coverage: BTreeMap<&'static str, Vec<Verifier>> = BTreeMap::new();
for check in checks {
for req_id in check.covers() {
coverage.entry(req_id).or_default().push(Verifier {
check_id: check.id().to_string(),
layer: check.layer(),
});
}
}
let rows: Vec<MatrixRow> = REQUIREMENTS
.iter()
.map(|r| MatrixRow {
id: r.id,
principle: r.principle,
level: r.level,
summary: r.summary,
applicability: r.applicability,
verifiers: coverage.get(r.id).cloned().unwrap_or_default(),
})
.collect();
let summary = summarize(&rows);
Matrix {
schema_version: SCHEMA_VERSION,
generated_by: GENERATED_BY,
rows,
summary,
}
}
fn summarize(rows: &[MatrixRow]) -> MatrixSummary {
let mut must = LevelSummary {
total: 0,
covered: 0,
};
let mut should = LevelSummary {
total: 0,
covered: 0,
};
let mut may = LevelSummary {
total: 0,
covered: 0,
};
let mut covered = 0;
let mut dual_layer = 0;
for row in rows {
let bucket = match row.level {
Level::Must => &mut must,
Level::Should => &mut should,
Level::May => &mut may,
};
bucket.total += 1;
if !row.verifiers.is_empty() {
bucket.covered += 1;
covered += 1;
if row.verifiers.len() >= 2 {
dual_layer += 1;
}
}
}
MatrixSummary {
total: rows.len(),
covered,
uncovered: rows.len() - covered,
dual_layer,
must,
should,
may,
}
}
pub fn render_markdown(matrix: &Matrix) -> String {
let mut out = String::new();
let _ = writeln!(out, "# Coverage Matrix");
let _ = writeln!(out);
let _ = writeln!(
out,
"<!-- Generated by `{}` — do not edit by hand. Commit regenerated output alongside code changes. -->",
GENERATED_BY
);
let _ = writeln!(out);
let _ = writeln!(
out,
"This table maps every MUST, SHOULD, and MAY in the agent-native CLI spec to the `anc` checks that verify it."
);
let _ = writeln!(
out,
"When a requirement has no verifier, the cell reads **UNCOVERED** and the reader knows the scorecard cannot speak to it."
);
let _ = writeln!(out);
let s = &matrix.summary;
let _ = writeln!(out, "## Summary");
let _ = writeln!(out);
let _ = writeln!(
out,
"- **Total**: {} requirements ({} covered / {} uncovered)",
s.total, s.covered, s.uncovered
);
let _ = writeln!(
out,
"- **Dual-layer**: {} of {} covered requirements have verifiers in two layers (behavioral + source or project)",
s.dual_layer, s.covered
);
let _ = writeln!(
out,
"- **MUST**: {} of {} covered",
s.must.covered, s.must.total
);
let _ = writeln!(
out,
"- **SHOULD**: {} of {} covered",
s.should.covered, s.should.total
);
let _ = writeln!(
out,
"- **MAY**: {} of {} covered",
s.may.covered, s.may.total
);
let _ = writeln!(out);
let mut by_principle: BTreeMap<u8, Vec<&MatrixRow>> = BTreeMap::new();
for row in &matrix.rows {
by_principle.entry(row.principle).or_default().push(row);
}
for (principle, rows) in &by_principle {
let _ = writeln!(out, "## P{}: {}", principle, principle_name(*principle));
let _ = writeln!(out);
let _ = writeln!(
out,
"| ID | Level | Applicability | Verifier(s) | Summary |"
);
let _ = writeln!(out, "| --- | --- | --- | --- | --- |");
for row in rows {
let level = match row.level {
Level::Must => "MUST",
Level::Should => "SHOULD",
Level::May => "MAY",
};
let applicability = match row.applicability {
Applicability::Universal => "Universal".to_string(),
Applicability::Conditional(cond) => format!("If: {cond}"),
};
let verifiers = if row.verifiers.is_empty() {
"**UNCOVERED**".to_string()
} else {
row.verifiers
.iter()
.map(|v| format!("`{}` ({})", v.check_id, layer_label(v.layer)))
.collect::<Vec<_>>()
.join("<br>")
};
let _ = writeln!(
out,
"| `{}` | {} | {} | {} | {} |",
row.id,
level,
applicability,
verifiers,
escape_pipes(row.summary)
);
}
let _ = writeln!(out);
}
out
}
fn layer_label(layer: CheckLayer) -> &'static str {
match layer {
CheckLayer::Behavioral => "behavioral",
CheckLayer::Source => "source",
CheckLayer::Project => "project",
}
}
fn principle_name(principle: u8) -> &'static str {
match principle {
1 => "Non-Interactive by Default",
2 => "Structured, Parseable Output",
3 => "Progressive Help Discovery",
4 => "Fail Fast, Actionable Errors",
5 => "Safe Retries, Mutation Boundaries",
6 => "Composable, Predictable Command Structure",
7 => "Bounded, High-Signal Responses",
_ => "Unknown",
}
}
fn escape_pipes(s: &str) -> String {
s.replace('|', "\\|")
}
pub fn render_json(matrix: &Matrix) -> String {
serde_json::to_string_pretty(matrix).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
}
pub fn dangling_cover_ids(checks: &[Box<dyn Check>]) -> Vec<(String, String)> {
let mut dangling = Vec::new();
for check in checks {
for req_id in check.covers() {
if crate::principles::registry::find(req_id).is_none() {
dangling.push((check.id().to_string(), (*req_id).to_string()));
}
}
}
dangling
}
#[cfg(test)]
mod tests {
use super::*;
use crate::check::Check;
use crate::project::Project;
use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus, Confidence};
struct FakeCheck {
id: &'static str,
covers: &'static [&'static str],
}
impl Check for FakeCheck {
fn id(&self) -> &str {
self.id
}
fn group(&self) -> CheckGroup {
CheckGroup::P1
}
fn layer(&self) -> CheckLayer {
CheckLayer::Behavioral
}
fn applicable(&self, _project: &Project) -> bool {
true
}
fn run(&self, _project: &Project) -> anyhow::Result<CheckResult> {
Ok(CheckResult {
id: self.id.to_string(),
label: self.id.to_string(),
group: CheckGroup::P1,
layer: CheckLayer::Behavioral,
status: CheckStatus::Pass,
confidence: Confidence::High,
})
}
fn covers(&self) -> &'static [&'static str] {
self.covers
}
}
#[test]
fn build_marks_uncovered_rows_when_no_checks() {
let checks: Vec<Box<dyn Check>> = vec![];
let matrix = build(&checks);
assert_eq!(matrix.rows.len(), REQUIREMENTS.len());
assert!(matrix.rows.iter().all(|r| r.verifiers.is_empty()));
assert_eq!(matrix.summary.covered, 0);
assert_eq!(matrix.summary.uncovered, REQUIREMENTS.len());
}
#[test]
fn build_links_check_to_requirement() {
let checks: Vec<Box<dyn Check>> = vec![Box::new(FakeCheck {
id: "fake-check",
covers: &["p1-must-no-interactive"],
})];
let matrix = build(&checks);
let row = matrix
.rows
.iter()
.find(|r| r.id == "p1-must-no-interactive")
.expect("requirement row");
assert_eq!(row.verifiers.len(), 1);
assert_eq!(row.verifiers[0].check_id, "fake-check");
}
#[test]
fn render_markdown_contains_summary_and_uncovered_marker() {
let checks: Vec<Box<dyn Check>> = vec![];
let matrix = build(&checks);
let md = render_markdown(&matrix);
assert!(md.contains("# Coverage Matrix"));
assert!(md.contains("## Summary"));
assert!(md.contains("**UNCOVERED**"));
assert!(md.contains("P1: Non-Interactive by Default"));
}
#[test]
fn render_json_is_valid_json() {
let checks: Vec<Box<dyn Check>> = vec![];
let matrix = build(&checks);
let json = render_json(&matrix);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["schema_version"], SCHEMA_VERSION);
assert!(parsed["rows"].is_array());
}
#[test]
fn dangling_cover_ids_detects_typo() {
let checks: Vec<Box<dyn Check>> = vec![Box::new(FakeCheck {
id: "typo-check",
covers: &["p1-must-no-interactivx"], })];
let dangling = dangling_cover_ids(&checks);
assert_eq!(dangling.len(), 1);
assert_eq!(dangling[0].0, "typo-check");
}
#[test]
fn dangling_cover_ids_empty_for_valid_refs() {
let checks: Vec<Box<dyn Check>> = vec![Box::new(FakeCheck {
id: "valid-check",
covers: &["p1-must-no-interactive", "p1-should-tty-detection"],
})];
assert!(dangling_cover_ids(&checks).is_empty());
}
}