stemedb/applications/aphoria/src/extractors/self_audit.rs
jml 6430ff0fd6 fix(aphoria): move claims.toml to project root and fix verify integration
## 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>
2026-02-08 11:09:57 +00:00

294 lines
9.7 KiB
Rust

//! Self-audit meta-extractor for dogfooding Aphoria on its own codebase.
//!
//! Produces observations about Aphoria's own code patterns:
//! - Bridge tier assignments
//! - Parent hash usage
//! - Lifecycle stage skipping
//! - `.unwrap()` / `.expect()` usage count
use regex::Regex;
use stemedb_core::types::ObjectValue;
use super::Extractor;
use crate::types::{Language, Observation};
/// Meta-extractor that audits Aphoria's own code patterns.
///
/// Opt-in only (like `dep_versions`). Registered with the name `self_audit`.
pub struct SelfAuditExtractor {
/// Matches: .unwrap() or .expect() calls
unwrap_pattern: Regex,
/// Matches: SourceClass:: usage for tier assignment
source_class_pattern: Regex,
/// Matches: parent_hash: None
parent_hash_none: Regex,
/// Matches: LifecycleStage::Approved
lifecycle_approved: Regex,
}
impl Default for SelfAuditExtractor {
fn default() -> Self {
Self::new()
}
}
impl SelfAuditExtractor {
/// Create a new self-audit extractor.
///
/// # Panics
/// Panics if any regex pattern is invalid (programmer error).
#[allow(clippy::expect_used)]
pub fn new() -> Self {
Self {
unwrap_pattern: Regex::new(r"\.(unwrap|expect)\(").expect("valid regex"),
source_class_pattern: Regex::new(r"SourceClass::\w+").expect("valid regex"),
parent_hash_none: Regex::new(r"parent_hash:\s*None").expect("valid regex"),
lifecycle_approved: Regex::new(r"LifecycleStage::Approved").expect("valid regex"),
}
}
}
impl Extractor for SelfAuditExtractor {
fn name(&self) -> &str {
"self_audit"
}
fn languages(&self) -> &[Language] {
&[Language::Rust]
}
fn extract(
&self,
path_segments: &[String],
content: &str,
_language: Language,
file: &str,
) -> Vec<Observation> {
let mut observations = Vec::new();
// Count unwrap/expect usage
let mut unwrap_count: usize = 0;
let lines: Vec<&str> = content.lines().collect();
let mut in_test_module = false;
for (line_num, line) in lines.iter().enumerate() {
let line_number = line_num + 1;
// Track #[cfg(test)] module boundaries
if line.contains("#[cfg(test)]") {
in_test_module = true;
}
// Skip test modules entirely
if in_test_module {
// Still check for bridge patterns below, but don't count unwraps
} else if self.unwrap_pattern.is_match(line) {
// Check if the enclosing function has #[allow(clippy::unwrap_used)]
// or #[allow(clippy::expect_used)].
// Scan backwards to the fn boundary, then check attributes above it.
let mut allowed = false;
let mut found_fn = false;
for prev in (0..line_num).rev() {
let prev_line = lines[prev].trim();
if prev_line.is_empty() {
if found_fn {
break; // blank line above fn means attributes are done
}
continue;
}
if prev_line.contains("#[allow(clippy::unwrap_used)]")
|| prev_line.contains("#[allow(clippy::expect_used)]")
{
allowed = true;
break;
}
// Mark that we found the fn boundary
if !found_fn
&& (prev_line.starts_with("fn ")
|| prev_line.starts_with("pub fn ")
|| prev_line.contains(" fn "))
{
found_fn = true;
continue; // check attributes above fn
}
// If we're past the fn and hit non-attribute lines, stop
if found_fn && !prev_line.starts_with('#') {
break;
}
}
if !allowed {
unwrap_count += 1;
}
}
// Detect SourceClass assignments in bridge code
if file.contains("bridge") {
if let Some(m) = self.source_class_pattern.find(line) {
observations.push(super::traits::build_claim(
path_segments,
&["bridge", "tier_assignment"],
"default_tier",
ObjectValue::Text(m.as_str().to_string()),
file,
line_number,
m.as_str(),
0.9,
"Bridge tier assignment pattern",
));
}
}
// Detect parent_hash: None patterns in bridge code
if file.contains("bridge") && self.parent_hash_none.is_match(line) {
observations.push(super::traits::build_claim(
path_segments,
&["bridge", "parent_hash"],
"always_none",
ObjectValue::Boolean(true),
file,
line_number,
"parent_hash: None",
0.9,
"Parent hash always set to None",
));
}
// Detect LifecycleStage::Approved skipping Pending
if file.contains("bridge") && self.lifecycle_approved.is_match(line) {
observations.push(super::traits::build_claim(
path_segments,
&["bridge", "lifecycle"],
"skips_pending",
ObjectValue::Boolean(true),
file,
line_number,
"LifecycleStage::Approved",
0.9,
"Lifecycle stage skips Pending, goes directly to Approved",
));
}
}
// Emit a single summary observation for unwrap count
if !file.contains("test") {
#[allow(clippy::cast_precision_loss)]
observations.push(super::traits::build_claim(
path_segments,
&["production", "error_handling"],
"unwrap_count",
ObjectValue::Number(unwrap_count as f64),
file,
1,
&format!("{unwrap_count} unwrap/expect calls"),
1.0,
"Count of .unwrap()/.expect() calls in production code",
));
}
observations
}
fn verifiable_predicates(&self) -> Vec<(&str, &str)> {
vec![
("bridge/tier_assignment", "default_tier"),
("bridge/parent_hash", "always_none"),
("bridge/lifecycle", "skips_pending"),
("production/error_handling", "unwrap_count"),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detects_unwrap() {
let ext = SelfAuditExtractor::new();
let content = r#"
fn main() {
let x = foo().unwrap();
let y = bar().expect("should work");
}
"#;
let obs = ext.extract(
&["rust".to_string(), "aphoria".to_string()],
content,
Language::Rust,
"src/main.rs",
);
let unwrap_obs: Vec<_> = obs.iter().filter(|o| o.predicate == "unwrap_count").collect();
assert_eq!(unwrap_obs.len(), 1);
assert_eq!(unwrap_obs[0].value, ObjectValue::Number(2.0));
}
#[test]
fn test_skips_allowed_unwrap() {
let ext = SelfAuditExtractor::new();
let content = r#"
#[allow(clippy::unwrap_used)]
fn allowed() {
let x = foo().unwrap();
}
fn not_allowed() {
let y = bar().unwrap();
}
"#;
let obs = ext.extract(
&["rust".to_string(), "aphoria".to_string()],
content,
Language::Rust,
"src/main.rs",
);
let unwrap_obs: Vec<_> = obs.iter().filter(|o| o.predicate == "unwrap_count").collect();
assert_eq!(unwrap_obs.len(), 1);
// The allowed one should be skipped, only the non-allowed one counted
assert_eq!(unwrap_obs[0].value, ObjectValue::Number(1.0));
}
#[test]
fn test_bridge_detection() {
let ext = SelfAuditExtractor::new();
let content = r#"
fn build_assertion() {
let source_class = SourceClass::Community;
let parent_hash: None;
let lifecycle = LifecycleStage::Approved;
}
"#;
let obs = ext.extract(
&["rust".to_string(), "aphoria".to_string()],
content,
Language::Rust,
"src/bridge.rs",
);
assert!(obs.iter().any(|o| o.predicate == "default_tier"));
assert!(obs.iter().any(|o| o.predicate == "skips_pending"));
}
#[test]
fn test_no_bridge_obs_for_non_bridge() {
let ext = SelfAuditExtractor::new();
let content = "let source_class = SourceClass::Community;\n";
let obs = ext.extract(&["rust".to_string()], content, Language::Rust, "src/other.rs");
assert!(!obs.iter().any(|o| o.predicate == "default_tier"));
}
#[test]
fn test_skips_test_files_for_unwrap() {
let ext = SelfAuditExtractor::new();
let content = "let x = foo().unwrap();\n";
let obs =
ext.extract(&["rust".to_string()], content, Language::Rust, "src/tests/verify.rs");
// Test files should not produce unwrap_count observations
let unwrap_obs: Vec<_> = obs.iter().filter(|o| o.predicate == "unwrap_count").collect();
assert!(unwrap_obs.is_empty());
}
}