stemedb/applications/aphoria/src/report/markdown.rs
jml 3b5f88b4f0 feat(aphoria): implement claims architecture (A1-A5) with verify engine, corpus, coverage, and explain
Complete Aphoria claims system overhaul:
- A1: Rename ExtractedClaim to Observation (extractors produce observations, not claims)
- A2: Add AuthoredClaim with full provenance, invariants, and authority tiers
- A3: Verify engine comparing observations against authored claims, CLI + formatters
- A4: Corpus as first-class assertions with predicate indexing, authority lens, trust packs
- A5: Coverage analysis, explain/docs generation, self-audit extractor, claim suggester skill

Also includes: 42 extractors updated for Observation type, verifiable_predicates trait,
conflict detection with comparison modes, claims TOML persistence, Grafana dashboard,
backup/restore scripts, and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:11:47 +00:00

369 lines
14 KiB
Rust

//! Markdown output format for documentation and PR comments.
//!
//! Produces a full markdown document with summary table,
//! detailed conflict sections, and action items.
use super::{extract_leaf_concept, object_value_display, verdict_label, ReportFormatter};
use crate::types::{ScanResult, Verdict};
/// Markdown report formatter.
pub struct MarkdownReport;
impl ReportFormatter for MarkdownReport {
fn format(&self, result: &ScanResult) -> String {
let mut out = String::new();
// Title
out.push_str(&format!("# Aphoria Scan: {}\n\n", result.project));
// Summary
let drift_info = if result.has_drifts() {
format!(" | **{}** drifts", result.drift_count())
} else {
String::new()
};
out.push_str(&format!(
"**{}** files scanned | **{}** claims extracted | **{}** conflicts{}\n",
result.files_scanned,
result.claims_extracted,
result.conflicts.len(),
drift_info
));
if result.strict {
out.push_str("\n**Mode:** strict (BLOCK >= 0.50, FLAG >= 0.30)\n");
}
out.push('\n');
if result.conflicts.is_empty() && result.drifts.is_empty() {
out.push_str("No conflicts or drifts found.\n");
// Still show claims if requested, even with no conflicts
if let Some(claims) = &result.claims {
out.push_str("\n## Extracted Claims\n\n");
if claims.is_empty() {
out.push_str("No claims extracted.\n\n");
} else {
out.push_str("| Concept | Value | File | Line | Confidence |\n");
out.push_str("|---------|-------|------|------|------------|\n");
for claim in claims {
out.push_str(&format!(
"| `{}` | `{}` | `{}` | {} | {:.0}% |\n",
extract_leaf_concept(&claim.concept_path),
object_value_display(&claim.value),
claim.file,
claim.line,
claim.confidence * 100.0,
));
}
out.push('\n');
}
}
return out;
}
// Verdict badges
let blocks = result.count_by_verdict(Verdict::Block);
let flags = result.count_by_verdict(Verdict::Flag);
let drifts = result.drift_count();
if blocks > 0 {
out.push_str(&format!("**{blocks} BLOCK** "));
}
if flags > 0 {
out.push_str(&format!("**{flags} FLAG** "));
}
if drifts > 0 {
out.push_str(&format!("**{drifts} DRIFT** "));
}
out.push('\n');
out.push('\n');
// Summary table
out.push_str("| Verdict | Concept | Citation | File | Score |\n");
out.push_str("|---------|---------|----------|------|-------|\n");
for conflict in &result.conflicts {
let concept = extract_leaf_concept(&conflict.claim.concept_path);
// Get RFC/OWASP citation
let citation = conflict
.conflicts
.first()
.and_then(|s| s.rfc_citation.as_ref())
.map(|c| c.as_str())
.unwrap_or("-");
out.push_str(&format!(
"| {} | `{}` | {} | `{}:{}` | {:.2} |\n",
verdict_label(conflict.verdict),
concept,
citation,
conflict.claim.file,
conflict.claim.line,
conflict.conflict_score,
));
}
out.push('\n');
// Detailed sections for BLOCK and FLAG
let actionable: Vec<_> = result
.conflicts
.iter()
.filter(|c| c.verdict == Verdict::Block || c.verdict == Verdict::Flag)
.collect();
if !actionable.is_empty() {
out.push_str("## Details\n\n");
for conflict in actionable {
out.push_str(&format!(
"### {} `{}`\n\n",
verdict_label(conflict.verdict),
conflict.claim.concept_path
));
out.push_str(&format!(
"- **Your code:** {} (`{}:{}`)\n",
conflict.claim.description, conflict.claim.file, conflict.claim.line
));
for source in &conflict.conflicts {
let citation = source
.rfc_citation
.as_ref()
.map(|c| format!(" [{}]", c))
.unwrap_or_default();
out.push_str(&format!(
"- **{:?}** (Tier {}){}: `{}`\n",
source.source_class,
source.source_class.tier(),
citation,
object_value_display(&source.value),
));
// Show policy source if this came from a Trust Pack
if let Some(policy) = &source.policy_source {
let signer = policy.signer_name.as_deref().unwrap_or(&policy.issuer_hex);
out.push_str(&format!(
" - *Source: {} v{} ({})*\n",
policy.pack_name, policy.pack_version, signer
));
if let Some(contact) = &policy.contact {
out.push_str(&format!(" - *Contact: {}*\n", contact));
}
}
}
out.push_str(&format!("- **Score:** {:.2}\n", conflict.conflict_score));
// Show tier breakdown in debug mode
if let Some(breakdown) = &conflict.tier_breakdown {
let parts: Vec<String> = breakdown
.iter()
.map(|tb| format!("{:?} (Tier {})", tb.source_class, tb.tier))
.collect();
out.push_str(&format!("- **Authority:** {}\n", parts.join(" > ")));
}
if let Some(ack) = &conflict.acknowledged {
if ack.expired {
// Expired acknowledgment
out.push_str(&format!(
"- **Previous acknowledgment expired** {}\n",
ack.expires_at.as_deref().unwrap_or("(unknown date)")
));
out.push_str(&format!(
"- **Prior ack** by {} on {}: \"{}\"\n",
ack.by, ack.timestamp, ack.reason
));
if conflict.verdict == Verdict::Block {
out.push_str(
"- **Action:** Fix or run `aphoria ack <path> --reason \"...\"`\n",
);
} else {
out.push_str("- **Action:** Review recommended\n");
}
} else {
// Active acknowledgment
out.push_str(&format!(
"- **Acknowledged** by {} on {}: \"{}\"\n",
ack.by, ack.timestamp, ack.reason
));
if let Some(exp) = &ack.expires_at {
out.push_str(&format!("- **Expires:** {}\n", exp));
}
}
} else if conflict.verdict == Verdict::Block {
out.push_str(
"- **Action:** Fix or run `aphoria ack <path> --reason \"...\"`\n",
);
} else {
out.push_str("- **Action:** Review recommended\n");
}
out.push('\n');
}
}
// Drift section
if !result.drifts.is_empty() {
out.push_str("## Drift Detected\n\n");
out.push_str("| Concept | Current | Prior | File |\n");
out.push_str("|---------|---------|-------|------|\n");
for drift in &result.drifts {
out.push_str(&format!(
"| `{}` | `{}` | `{}` | `{}:{}` |\n",
extract_leaf_concept(&drift.claim.concept_path),
object_value_display(&drift.claim.value),
object_value_display(&drift.prior.value),
drift.claim.file,
drift.claim.line,
));
}
out.push('\n');
out.push_str("**Action:** Review these changes to ensure they were intentional.\n\n");
}
// Deprecated patterns section
if !result.deprecated_usages.is_empty() {
out.push_str("## Deprecated Patterns\n\n");
out.push_str("> ⚠️ **Migration Required:** The following patterns are deprecated and should be updated.\n\n");
out.push_str("| Pattern | File | Severity | Sunset |\n");
out.push_str("|---------|------|----------|--------|\n");
for usage in &result.deprecated_usages {
let sunset = usage
.days_until_sunset
.map(|d| if d < 0 { format!("OVERDUE ({}d)", -d) } else { format!("{}d", d) })
.unwrap_or_else(|| "-".to_string());
out.push_str(&format!(
"| `{}` | `{}:{}` | {} | {} |\n",
usage.pattern_name,
usage.file_path,
usage.line,
usage.severity(),
sunset,
));
}
out.push('\n');
// Details for each deprecated pattern
out.push_str("### Migration Details\n\n");
for usage in &result.deprecated_usages {
out.push_str(&format!("#### `{}`\n\n", usage.pattern_name));
out.push_str(&format!("- **Reason:** {}\n", usage.reason));
out.push_str(&format!("- **Location:** `{}:{}`\n", usage.file_path, usage.line));
if let Some(ref replacement) = usage.superseded_by {
out.push_str(&format!("- **Replacement:** `{}`\n", replacement));
}
if let Some(ref guide) = usage.migration_guide {
out.push_str(&format!("- **Migration Guide:** {}\n", guide));
}
out.push('\n');
}
}
// Extracted Claims section
if let Some(claims) = &result.claims {
out.push_str("## Extracted Claims\n\n");
if claims.is_empty() {
out.push_str("No claims extracted.\n\n");
} else {
out.push_str("| Concept | Value | File | Line | Confidence |\n");
out.push_str("|---------|-------|------|------|------------|\n");
for claim in claims {
out.push_str(&format!(
"| `{}` | `{}` | `{}` | {} | {:.0}% |\n",
extract_leaf_concept(&claim.concept_path),
object_value_display(&claim.value),
claim.file,
claim.line,
claim.confidence * 100.0,
));
}
out.push('\n');
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ConflictResult, ConflictingSource, Observation};
use stemedb_core::types::{ObjectValue, SourceClass};
#[test]
fn test_markdown_with_conflicts() {
let formatter = MarkdownReport;
let result = ScanResult {
project: "testproject".to_string(),
scan_id: "scan-md".to_string(),
files_scanned: 20,
claims_extracted: 4,
conflicts: vec![ConflictResult {
claim: Observation {
concept_path: "code://rust/test/cors/allow_origin".to_string(),
predicate: "config_value".to_string(),
value: ObjectValue::Text("*".to_string()),
file: "src/server.rs".to_string(),
line: 55,
matched_text: "allow_origin(\"*\")".to_string(),
confidence: 1.0,
description: "CORS wildcard allow-origin".to_string(),
},
conflicts: vec![ConflictingSource {
path: "owasp://cors/allow_origin".to_string(),
source_class: SourceClass::Clinical,
value: ObjectValue::Text("explicit_list".to_string()),
confidence: 1.0,
rfc_citation: Some("OWASP A05:2021".to_string()),
policy_source: None,
}],
conflict_score: 0.77,
verdict: Verdict::Block,
acknowledged: None,
trace: None,
tier_breakdown: None,
}],
drifts: vec![],
format: "markdown".to_string(),
debug: false,
strict: false,
observations_recorded: 0,
timing: None,
claims: None,
deprecated_usages: vec![],
};
let output = formatter.format(&result);
assert!(output.contains("# Aphoria Scan: testproject"));
assert!(output.contains("| BLOCK |"));
assert!(output.contains("## Details"));
assert!(output.contains("CORS wildcard"));
assert!(output.contains("`aphoria ack"));
}
#[test]
fn test_markdown_empty() {
let formatter = MarkdownReport;
let result = ScanResult::stub(&std::path::PathBuf::from("empty"), "markdown");
let output = formatter.format(&result);
assert!(output.contains("No conflicts or drifts found"));
}
}