## Root Cause Claims file was in applications/aphoria/.aphoria/ but all commands looked for .aphoria/claims.toml relative to project root. Additionally, .aphoria/ was fully gitignored, preventing version control of claims. ## Changes ### Path Fixes - Move claims.toml from applications/aphoria/.aphoria/ to .aphoria/ at project root - Update .gitignore: .aphoria/ → .aphoria/* with !.aphoria/claims.toml exception - Now claims can be version controlled while keys remain secret ### Verify Integration (Scanner) - scanner.rs: Load claims from ClaimsFile and call verify_claims() - ScanResult: Add verify field with VerifyReport - Report formatters: Add claim verification sections showing PASS/CONFLICT/MISSING ### Clippy Fix - report/json.rs: Replace filter().map().expect() with filter_map() ## Verification - aphoria scan . → Shows claim verification with verdicts - aphoria verify run → Per-claim verification results - aphoria verify map → Extractor coverage mapping (7/10 claims = 70%) - aphoria claims list → Reads from project root - aphoria claims create → Writes to project root - All tests pass (1120+ aphoria tests) - clippy --workspace passes ## Impact Both primary use cases now work: 1. Day-to-day (commit-time): Skills can read/create claims via CLI 2. Audit (scan-time): Scanner verifies code against authored claims Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
548 lines
19 KiB
Rust
548 lines
19 KiB
Rust
//! Claim coverage metrics engine.
|
|
//!
|
|
//! Computes per-module coverage: how many observations are claimed,
|
|
//! how many claims are verified, what's uncovered. Uses `verify_claims()`
|
|
//! as the source of truth for claim-observation matching.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::types::authored_claim::AuthoredClaim;
|
|
use crate::types::Observation;
|
|
use crate::verify::{tail_path, verify_claims, AuditVerdict, VerifyReport};
|
|
|
|
/// Per-module coverage metrics.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ModuleCoverage {
|
|
/// Module path (e.g., "wallet/atomics", "tls").
|
|
pub module_path: String,
|
|
/// Files belonging to this module.
|
|
pub files: Vec<String>,
|
|
/// Total observations found by extractors in this module.
|
|
pub observation_count: usize,
|
|
/// Active authored claims covering this module.
|
|
pub claim_count: usize,
|
|
/// Observations matched by at least one claim.
|
|
pub claimed_observations: usize,
|
|
/// Observations with no covering claim.
|
|
pub unclaimed_observations: usize,
|
|
/// Claims with no matching observation (MISSING verdicts).
|
|
pub missing_claims: usize,
|
|
/// Claim density: claim_count / observation_count (0.0 if no observations).
|
|
pub density: f32,
|
|
}
|
|
|
|
/// Full coverage report for a project.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CoverageReport {
|
|
/// Project name.
|
|
pub project: String,
|
|
/// Per-module metrics, sorted by module path.
|
|
pub modules: Vec<ModuleCoverage>,
|
|
/// Aggregate summary.
|
|
pub summary: CoverageSummary,
|
|
}
|
|
|
|
/// Aggregate coverage summary.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CoverageSummary {
|
|
/// Total observations across all modules.
|
|
pub total_observations: usize,
|
|
/// Total active claims.
|
|
pub total_claims: usize,
|
|
/// Percentage of observations covered by claims.
|
|
pub claimed_percentage: f32,
|
|
/// Count of observations with no covering claim.
|
|
pub unclaimed_count: usize,
|
|
/// Number of modules that have at least one claim.
|
|
pub modules_with_claims: usize,
|
|
/// Number of modules with zero claims.
|
|
pub modules_without_claims: usize,
|
|
}
|
|
|
|
/// Derive a module path from a file path.
|
|
///
|
|
/// Takes the first 2 directory segments after stripping common prefixes like `src/`.
|
|
/// Examples:
|
|
/// - `src/wallet/atomics/sync.rs` → `wallet/atomics`
|
|
/// - `src/tls/config.rs` → `tls`
|
|
/// - `config.toml` → `(root)`
|
|
fn derive_module(file_path: &str) -> String {
|
|
let path = file_path
|
|
.strip_prefix("src/")
|
|
.or_else(|| file_path.strip_prefix("lib/"))
|
|
.unwrap_or(file_path);
|
|
|
|
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
|
|
|
// Take directory segments only (skip the filename)
|
|
let dir_segments: Vec<&str> = if segments.len() > 1 {
|
|
segments[..segments.len() - 1].to_vec()
|
|
} else {
|
|
return "(root)".to_string();
|
|
};
|
|
|
|
// Take up to 2 directory segments
|
|
let module_depth = dir_segments.len().min(2);
|
|
if module_depth == 0 {
|
|
"(root)".to_string()
|
|
} else {
|
|
dir_segments[..module_depth].join("/")
|
|
}
|
|
}
|
|
|
|
/// Derive a module path from a claim's concept_path.
|
|
///
|
|
/// Uses `tail_path` to get the last 2 segments, then takes the first segment
|
|
/// as the module. For claims without a valid tail path, uses the full concept_path.
|
|
fn derive_module_from_claim(concept_path: &str) -> String {
|
|
if let Some(tp) = tail_path(concept_path) {
|
|
// tail_path gives us "penultimate/last" — use the penultimate as module
|
|
if let Some(slash) = tp.find('/') {
|
|
tp[..slash].to_string()
|
|
} else {
|
|
tp
|
|
}
|
|
} else {
|
|
// Fallback: strip scheme, use what we have
|
|
let path = concept_path.find("://").map(|i| &concept_path[i + 3..]).unwrap_or(concept_path);
|
|
path.to_string()
|
|
}
|
|
}
|
|
|
|
/// Compute coverage metrics from claims, observations, and verification results.
|
|
pub fn compute_coverage(
|
|
claims: &[AuthoredClaim],
|
|
observations: &[Observation],
|
|
project_name: &str,
|
|
) -> CoverageReport {
|
|
let report = verify_claims(claims, observations);
|
|
compute_coverage_from_report(claims, observations, &report, project_name)
|
|
}
|
|
|
|
/// Compute coverage from pre-computed verification report.
|
|
///
|
|
/// Useful when the caller already has a `VerifyReport` and doesn't want
|
|
/// to re-run verification.
|
|
pub fn compute_coverage_from_report(
|
|
claims: &[AuthoredClaim],
|
|
observations: &[Observation],
|
|
report: &VerifyReport,
|
|
project_name: &str,
|
|
) -> CoverageReport {
|
|
// Group observations by module (from file path)
|
|
let mut obs_by_module: BTreeMap<String, Vec<&Observation>> = BTreeMap::new();
|
|
for obs in observations {
|
|
let module = derive_module(&obs.file);
|
|
obs_by_module.entry(module).or_default().push(obs);
|
|
}
|
|
|
|
// Build claim-to-module mapping from verification results.
|
|
// For claims with matching observations (Pass/Conflict), derive the module
|
|
// from the observation's file path so claims land in the same bucket as
|
|
// their observations. For Missing claims, fall back to concept_path.
|
|
let mut claim_to_module: std::collections::HashMap<String, String> =
|
|
std::collections::HashMap::new();
|
|
let mut claimed_tails: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
let mut missing_claim_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
|
for result in &report.results {
|
|
match result.verdict {
|
|
AuditVerdict::Pass | AuditVerdict::Conflict => {
|
|
if let Some(ref claim) = result.claim {
|
|
if let Some(tp) = tail_path(&claim.concept_path) {
|
|
claimed_tails.insert(tp);
|
|
}
|
|
// Derive module from the first matching observation's file path
|
|
if let Some(obs) = result.matching_observations.first() {
|
|
claim_to_module.insert(claim.id.clone(), derive_module(&obs.file));
|
|
}
|
|
}
|
|
}
|
|
AuditVerdict::Missing => {
|
|
if let Some(ref claim) = result.claim {
|
|
missing_claim_ids.insert(claim.id.clone());
|
|
}
|
|
}
|
|
AuditVerdict::Unclaimed => {}
|
|
}
|
|
}
|
|
|
|
// Group claims by module, using observation-derived module when available
|
|
let mut claims_by_module: BTreeMap<String, Vec<&AuthoredClaim>> = BTreeMap::new();
|
|
for claim in claims {
|
|
if claim.status == crate::types::ClaimStatus::Active {
|
|
let module = claim_to_module
|
|
.get(&claim.id)
|
|
.cloned()
|
|
.unwrap_or_else(|| derive_module_from_claim(&claim.concept_path));
|
|
claims_by_module.entry(module).or_default().push(claim);
|
|
}
|
|
}
|
|
|
|
// Collect all module names from both observations and claims
|
|
let mut all_modules: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
|
for key in obs_by_module.keys() {
|
|
all_modules.insert(key.clone());
|
|
}
|
|
for key in claims_by_module.keys() {
|
|
all_modules.insert(key.clone());
|
|
}
|
|
|
|
// Build per-module coverage
|
|
let mut modules = Vec::new();
|
|
let mut total_observations = 0usize;
|
|
let mut total_claimed = 0usize;
|
|
let mut total_unclaimed = 0usize;
|
|
let mut modules_with_claims = 0usize;
|
|
let mut modules_without_claims = 0usize;
|
|
|
|
for module in &all_modules {
|
|
let obs_list = obs_by_module.get(module);
|
|
let claim_list = claims_by_module.get(module);
|
|
|
|
let observation_count = obs_list.map(|v| v.len()).unwrap_or(0);
|
|
let claim_count = claim_list.map(|v| v.len()).unwrap_or(0);
|
|
|
|
// Count how many observations in this module are claimed
|
|
let claimed_obs = obs_list
|
|
.map(|obs| {
|
|
obs.iter()
|
|
.filter(|o| {
|
|
tail_path(&o.concept_path)
|
|
.map(|tp| claimed_tails.contains(&tp))
|
|
.unwrap_or(false)
|
|
})
|
|
.count()
|
|
})
|
|
.unwrap_or(0);
|
|
|
|
let unclaimed_obs = observation_count.saturating_sub(claimed_obs);
|
|
|
|
// Count missing claims in this module
|
|
let missing_in_module = claim_list
|
|
.map(|cls| cls.iter().filter(|c| missing_claim_ids.contains(&c.id)).count())
|
|
.unwrap_or(0);
|
|
|
|
let density =
|
|
if observation_count > 0 { claim_count as f32 / observation_count as f32 } else { 0.0 };
|
|
|
|
// Collect unique files in this module
|
|
let files: Vec<String> = obs_list
|
|
.map(|obs| {
|
|
let mut file_set: std::collections::BTreeSet<String> =
|
|
std::collections::BTreeSet::new();
|
|
for o in obs {
|
|
file_set.insert(o.file.clone());
|
|
}
|
|
file_set.into_iter().collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
if claim_count > 0 {
|
|
modules_with_claims += 1;
|
|
} else {
|
|
modules_without_claims += 1;
|
|
}
|
|
|
|
total_observations += observation_count;
|
|
total_claimed += claimed_obs;
|
|
total_unclaimed += unclaimed_obs;
|
|
|
|
modules.push(ModuleCoverage {
|
|
module_path: module.clone(),
|
|
files,
|
|
observation_count,
|
|
claim_count,
|
|
claimed_observations: claimed_obs,
|
|
unclaimed_observations: unclaimed_obs,
|
|
missing_claims: missing_in_module,
|
|
density,
|
|
});
|
|
}
|
|
|
|
let active_claims =
|
|
claims.iter().filter(|c| c.status == crate::types::ClaimStatus::Active).count();
|
|
|
|
let claimed_percentage = if total_observations > 0 {
|
|
(total_claimed as f32 / total_observations as f32) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
CoverageReport {
|
|
project: project_name.to_string(),
|
|
modules,
|
|
summary: CoverageSummary {
|
|
total_observations,
|
|
total_claims: active_claims,
|
|
claimed_percentage,
|
|
unclaimed_count: total_unclaimed,
|
|
modules_with_claims,
|
|
modules_without_claims,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Format coverage report as a terminal table.
|
|
pub fn format_coverage_table(report: &CoverageReport, sort_by: &str) -> String {
|
|
let mut out = String::new();
|
|
|
|
out.push_str(&format!("Aphoria Coverage: {}\n\n", report.project));
|
|
|
|
if report.modules.is_empty() {
|
|
out.push_str("No observations or claims found.\n");
|
|
return out;
|
|
}
|
|
|
|
let mut modules = report.modules.clone();
|
|
match sort_by {
|
|
"unclaimed" => {
|
|
modules.sort_by(|a, b| b.unclaimed_observations.cmp(&a.unclaimed_observations))
|
|
}
|
|
"observations" => modules.sort_by(|a, b| b.observation_count.cmp(&a.observation_count)),
|
|
"density" => modules.sort_by(|a, b| {
|
|
b.density
|
|
.partial_cmp(&a.density)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
.then_with(|| b.observation_count.cmp(&a.observation_count))
|
|
}),
|
|
_ => {} // default: alphabetical (already sorted by BTreeMap)
|
|
}
|
|
|
|
let mut table = comfy_table::Table::new();
|
|
table.set_header(vec![
|
|
"Module",
|
|
"Claims",
|
|
"Observations",
|
|
"Claimed",
|
|
"Unclaimed",
|
|
"Missing",
|
|
"Density",
|
|
]);
|
|
|
|
for m in &modules {
|
|
table.add_row(vec![
|
|
m.module_path.clone(),
|
|
m.claim_count.to_string(),
|
|
m.observation_count.to_string(),
|
|
m.claimed_observations.to_string(),
|
|
m.unclaimed_observations.to_string(),
|
|
m.missing_claims.to_string(),
|
|
format!("{:.1}%", m.density * 100.0),
|
|
]);
|
|
}
|
|
|
|
out.push_str(&table.to_string());
|
|
out.push_str(&format!(
|
|
"\n\nSummary: {} claims, {} observations, {:.1}% claimed, {} unclaimed",
|
|
report.summary.total_claims,
|
|
report.summary.total_observations,
|
|
report.summary.claimed_percentage,
|
|
report.summary.unclaimed_count,
|
|
));
|
|
out.push_str(&format!(
|
|
"\nModules: {} with claims, {} without claims",
|
|
report.summary.modules_with_claims, report.summary.modules_without_claims,
|
|
));
|
|
|
|
out
|
|
}
|
|
|
|
/// Format coverage report as JSON.
|
|
pub fn format_coverage_json(report: &CoverageReport) -> String {
|
|
serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string())
|
|
}
|
|
|
|
/// Format coverage report as markdown.
|
|
pub fn format_coverage_markdown(report: &CoverageReport) -> String {
|
|
let mut out = String::new();
|
|
|
|
out.push_str(&format!("# Aphoria Coverage: {}\n\n", report.project));
|
|
|
|
out.push_str("## Summary\n\n");
|
|
out.push_str(&format!(
|
|
"- **Claims:** {}\n- **Observations:** {}\n- **Claimed:** {:.1}%\n- **Unclaimed:** {}\n- **Modules with claims:** {}\n- **Modules without claims:** {}\n\n",
|
|
report.summary.total_claims,
|
|
report.summary.total_observations,
|
|
report.summary.claimed_percentage,
|
|
report.summary.unclaimed_count,
|
|
report.summary.modules_with_claims,
|
|
report.summary.modules_without_claims,
|
|
));
|
|
|
|
if report.modules.is_empty() {
|
|
out.push_str("No observations or claims found.\n");
|
|
return out;
|
|
}
|
|
|
|
out.push_str("## Modules\n\n");
|
|
out.push_str("| Module | Claims | Observations | Claimed | Unclaimed | Missing | Density |\n");
|
|
out.push_str("|--------|--------|--------------|---------|-----------|---------|----------|\n");
|
|
|
|
for m in &report.modules {
|
|
out.push_str(&format!(
|
|
"| {} | {} | {} | {} | {} | {} | {:.1}% |\n",
|
|
m.module_path,
|
|
m.claim_count,
|
|
m.observation_count,
|
|
m.claimed_observations,
|
|
m.unclaimed_observations,
|
|
m.missing_claims,
|
|
m.density * 100.0,
|
|
));
|
|
}
|
|
|
|
// Highlight modules with 0 claims
|
|
let uncovered: Vec<&ModuleCoverage> =
|
|
report.modules.iter().filter(|m| m.claim_count == 0 && m.observation_count > 0).collect();
|
|
|
|
if !uncovered.is_empty() {
|
|
out.push_str("\n## Coverage Gaps\n\n");
|
|
out.push_str("These modules have observations but no authored claims:\n\n");
|
|
for m in uncovered {
|
|
out.push_str(&format!(
|
|
"- **{}** ({} unclaimed observations)\n",
|
|
m.module_path, m.unclaimed_observations,
|
|
));
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::authored_claim::{AuthoredValue, ClaimStatus, ComparisonMode};
|
|
use stemedb_core::types::ObjectValue;
|
|
|
|
fn make_claim(id: &str, concept_path: &str, category: &str) -> AuthoredClaim {
|
|
AuthoredClaim {
|
|
id: id.to_string(),
|
|
concept_path: concept_path.to_string(),
|
|
predicate: "test".to_string(),
|
|
value: AuthoredValue::Text("test".to_string()),
|
|
comparison: ComparisonMode::Equals,
|
|
provenance: "test".to_string(),
|
|
invariant: "test".to_string(),
|
|
consequence: "test".to_string(),
|
|
authority_tier: "expert".to_string(),
|
|
evidence: vec![],
|
|
category: category.to_string(),
|
|
status: ClaimStatus::Active,
|
|
supersedes: None,
|
|
created_by: "tester".to_string(),
|
|
created_at: "2026-02-08".to_string(),
|
|
updated_at: None,
|
|
}
|
|
}
|
|
|
|
fn make_obs(concept_path: &str, file: &str) -> Observation {
|
|
Observation {
|
|
concept_path: concept_path.to_string(),
|
|
predicate: "test".to_string(),
|
|
value: ObjectValue::Text("test".to_string()),
|
|
file: file.to_string(),
|
|
line: 1,
|
|
matched_text: "test".to_string(),
|
|
confidence: 1.0,
|
|
description: "test".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_derive_module() {
|
|
assert_eq!(derive_module("src/wallet/atomics/sync.rs"), "wallet/atomics");
|
|
assert_eq!(derive_module("src/tls/config.rs"), "tls");
|
|
assert_eq!(derive_module("config.toml"), "(root)");
|
|
assert_eq!(derive_module("src/main.rs"), "(root)");
|
|
assert_eq!(derive_module("src/auth/jwt/token.rs"), "auth/jwt");
|
|
}
|
|
|
|
#[test]
|
|
fn test_derive_module_from_claim() {
|
|
assert_eq!(derive_module_from_claim("project/wallet/atomics/ordering"), "atomics");
|
|
assert_eq!(derive_module_from_claim("code://rust/core/imports/tokio"), "imports");
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_coverage_empty() {
|
|
let report = compute_coverage(&[], &[], "test");
|
|
assert_eq!(report.summary.total_observations, 0);
|
|
assert_eq!(report.summary.total_claims, 0);
|
|
assert_eq!(report.summary.claimed_percentage, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_coverage_with_matches() {
|
|
let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")];
|
|
let observations = vec![
|
|
make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs"),
|
|
make_obs("code://rust/project/tls/config", "src/tls/config.rs"),
|
|
];
|
|
|
|
let report = compute_coverage(&claims, &observations, "test");
|
|
assert_eq!(report.summary.total_claims, 1);
|
|
assert_eq!(report.summary.total_observations, 2);
|
|
assert!(report.summary.unclaimed_count > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_coverage_table_output() {
|
|
let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")];
|
|
let observations =
|
|
vec![make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs")];
|
|
let report = compute_coverage(&claims, &observations, "myproject");
|
|
let table = format_coverage_table(&report, "name");
|
|
assert!(table.contains("Aphoria Coverage: myproject"));
|
|
assert!(table.contains("Summary:"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_coverage_json_output() {
|
|
let report = compute_coverage(&[], &[], "test");
|
|
let json = format_coverage_json(&report);
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
|
|
assert_eq!(parsed["project"], "test");
|
|
}
|
|
|
|
#[test]
|
|
fn test_coverage_markdown_output() {
|
|
let report = compute_coverage(&[], &[], "test");
|
|
let md = format_coverage_markdown(&report);
|
|
assert!(md.starts_with("# Aphoria Coverage: test"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_deprecated_claims_excluded() {
|
|
let mut claim = make_claim("c1", "project/atomics/ordering", "safety");
|
|
claim.status = ClaimStatus::Deprecated;
|
|
let report = compute_coverage(&[claim], &[], "test");
|
|
assert_eq!(report.summary.total_claims, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_claims_map_to_observation_modules() {
|
|
// Claim concept_path and observation concept_path share tail "atomics/ordering"
|
|
let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")];
|
|
let observations =
|
|
vec![make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs")];
|
|
|
|
let report = compute_coverage(&claims, &observations, "test");
|
|
|
|
// The claim should land in "wallet/atomics" (from observation file path),
|
|
// NOT "atomics" (from concept_path tail). This means the module should
|
|
// have both a claim and an observation with non-zero density.
|
|
let wallet_mod = report.modules.iter().find(|m| m.module_path == "wallet/atomics");
|
|
assert!(wallet_mod.is_some(), "Expected wallet/atomics module");
|
|
let Some(wallet_mod) = wallet_mod else {
|
|
panic!("wallet/atomics module not found");
|
|
};
|
|
assert_eq!(wallet_mod.claim_count, 1);
|
|
assert_eq!(wallet_mod.observation_count, 1);
|
|
assert!(wallet_mod.density > 0.0, "density should be non-zero");
|
|
}
|
|
}
|