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>
369 lines
14 KiB
Rust
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"));
|
|
}
|
|
}
|