stemedb/applications/aphoria/src/handlers/extractors.rs
jordan 157dbbb9eb feat: Complete Aphoria Phase 8-9 + UAT suite (90/90 tests passing)
## Phase 8: Enterprise Extractor Improvements 
- 14 security extractors (TLS, JWT, SQL injection, XSS, etc.)
- 10 framework-specific extractors (Spring, Django, Rails, etc.)
- Config file security detection (YAML, TOML)

## Phase 9: Autonomous Extractor Generation 
- Shadow mode executor with TP/FP tracking
- Graduation pipeline with confidence thresholds
- Auto-rollback on regression detection
- Cross-project pattern syncing

## UAT Suite Complete (14 scripts, 90 tests)
- test-core-detection.sh (6 tests)
- test-declarative-extractors.sh (5 tests)
- test-domain-frameworks.sh (5 tests)
- test-domain-unreal.sh (3 tests)
- test-llm-extraction.sh (6 tests)
- test-eval-harness.sh (5 tests)
- test-cross-language.sh (3 tests)
- test-precommit-performance.sh (4 tests)
- test-output-formats.sh (8 tests)
- test-drift-detection.sh (6 tests)
- test-exit-codes.sh (12 tests)
+ 3 more scripts

## Other Changes
- Updated roadmap to mark Phase 8-9 complete
- Added .gitignore entries for build artifacts
- Updated pre-commit: 800 line limit, exclude tests/data/cmd

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:50:55 -07:00

654 lines
21 KiB
Rust

//! Extractor command handlers (stats, candidates, review, promote, auto-promote, versioning)
use std::process::ExitCode;
use aphoria::{
learning::learning_store_dir,
promotion::{compute_metrics_delta, ChangelogEntry, VersionStore},
AphoriaConfig, LocalPatternStore, ShadowStore,
};
use crate::cli::ExtractorCommands;
use super::utils::truncate_for_display;
pub async fn handle_extractor_command(
command: ExtractorCommands,
config: &AphoriaConfig,
) -> ExitCode {
// Open pattern store
let store_dir = learning_store_dir();
let store = match LocalPatternStore::new(&store_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open pattern store: {e}");
return ExitCode::from(3);
}
};
match command {
ExtractorCommands::Stats => handle_extractor_stats(&store, config),
ExtractorCommands::Candidates { verbose } => {
handle_extractor_candidates(&store, config, verbose)
}
ExtractorCommands::Review { limit, auto } => {
handle_extractor_review(&store, config, limit, auto).await
}
ExtractorCommands::Promote { pattern_id, force } => {
handle_extractor_promote(&store, config, &pattern_id, force).await
}
ExtractorCommands::AutoPromote { dry_run, min_confidence, min_projects } => {
handle_auto_promote(&store, config, dry_run, min_confidence, min_projects).await
}
ExtractorCommands::ShadowStatus { verbose } => {
super::shadow::handle_shadow_status(config, verbose)
}
ExtractorCommands::Feedback { test, limit } => {
super::shadow::handle_shadow_feedback(config, &test, limit)
}
ExtractorCommands::Graduate { test, force } => {
super::shadow::handle_shadow_graduate(config, &test, force)
}
ExtractorCommands::Rollback { test, reason } => {
super::shadow::handle_shadow_rollback(config, &test, &reason)
}
ExtractorCommands::AutoCheck => super::shadow::handle_shadow_auto_check(config),
ExtractorCommands::Versions { name } => handle_versions(&name, config),
ExtractorCommands::Compare { name, version_a, version_b } => {
handle_compare(&name, version_a, version_b, config)
}
ExtractorCommands::RollbackVersion { name, version, reason } => {
handle_rollback_version(&name, version, &reason, config)
}
}
}
fn handle_extractor_stats(store: &LocalPatternStore, config: &AphoriaConfig) -> ExitCode {
use aphoria::PromotionPipeline;
let pipeline = match PromotionPipeline::new(store, None, &config.learning.promotion, None) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to create pipeline: {e}");
return ExitCode::from(3);
}
};
let stats = pipeline.stats();
println!("Pattern Learning Statistics");
println!("===========================");
println!();
println!("Total patterns: {}", stats.total_patterns);
println!("Eligible for promotion: {}", stats.eligible_patterns);
println!("Already promoted: {}", stats.promoted_patterns);
println!("Pending review: {}", stats.pending_review);
println!();
if stats.eligible_patterns > 0 {
println!("Eligible pattern averages:");
println!(" Confidence: {:.2}", stats.avg_confidence);
println!(" Projects: {:.1}", stats.avg_projects);
}
println!();
println!("Promotion thresholds (from config):");
println!(" Min projects: {}", config.learning.promotion.min_projects);
println!(" Min confidence: {:.2}", config.learning.promotion.min_confidence);
println!(" Auto-promote: {}", config.learning.promotion.auto_promote);
ExitCode::SUCCESS
}
fn handle_extractor_candidates(
store: &LocalPatternStore,
config: &AphoriaConfig,
verbose: bool,
) -> ExitCode {
use aphoria::PromotionPipeline;
let pipeline = match PromotionPipeline::new(store, None, &config.learning.promotion, None) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to create pipeline: {e}");
return ExitCode::from(3);
}
};
let candidates = pipeline.get_candidates();
if candidates.is_empty() {
println!("No patterns eligible for promotion.");
println!();
println!("Patterns become eligible when:");
println!(" - Seen in {}+ projects", config.learning.promotion.min_projects);
println!(" - Average confidence >= {:.2}", config.learning.promotion.min_confidence);
return ExitCode::SUCCESS;
}
println!("Patterns eligible for promotion ({} total):\n", candidates.len());
println!("{:<36} {:>8} {:>6} Subject", "Pattern ID", "Projects", "Conf");
println!("{}", "-".repeat(70));
for pattern in &candidates {
let subject = if pattern.claim_template.subject_template.len() > 25 {
format!("{}...", &pattern.claim_template.subject_template[..22])
} else {
pattern.claim_template.subject_template.clone()
};
println!(
"{:<36} {:>8} {:>6.2} {}",
pattern.id,
pattern.project_count(),
pattern.avg_confidence,
subject
);
if verbose {
println!(" Language: {}", pattern.language);
println!(" Example: {}", truncate_for_display(&pattern.example_code, 60));
println!(" Normalized: {}", pattern.normalized_pattern);
println!();
}
}
println!();
println!("To promote a pattern, run:");
println!(" aphoria extractors promote <PATTERN_ID>");
println!();
println!("For interactive review:");
println!(" aphoria extractors review");
ExitCode::SUCCESS
}
async fn handle_extractor_review(
store: &LocalPatternStore,
config: &AphoriaConfig,
limit: Option<usize>,
auto: bool,
) -> ExitCode {
use aphoria::{llm::GeminiClient, InteractiveReviewer, PromotionPipeline};
// Create LLM client
let client = match GeminiClient::new(&config.llm) {
Ok(Some(c)) => c,
Ok(None) => {
eprintln!("LLM not configured. Cannot generate regex patterns.");
eprintln!();
eprintln!("To configure LLM, add this to your aphoria.toml:");
eprintln!();
eprintln!(" [llm]");
eprintln!(" enabled = true");
eprintln!(" api_key_env = \"GEMINI_API_KEY\"");
return ExitCode::from(3);
}
Err(e) => {
eprintln!("Failed to create LLM client: {e}");
return ExitCode::from(3);
}
};
let output_dir = config.learning.promotion.output_dir.clone();
let pipeline = match PromotionPipeline::new(
store,
Some(&client),
&config.learning.promotion,
Some(output_dir),
) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to create pipeline: {e}");
return ExitCode::from(3);
}
};
let mut reviewer = InteractiveReviewer::new(&pipeline).with_auto_approve(auto);
if let Some(n) = limit {
reviewer = reviewer.with_limit(n);
}
match reviewer.run() {
Ok(result) => {
println!();
println!("Review session complete:");
println!(" Approved: {}", result.approved);
println!(" Rejected: {}", result.rejected);
println!(" Regenerated: {}", result.regenerated);
println!(" Skipped: {}", result.skipped);
if !result.promoted_files.is_empty() {
println!();
println!("Promoted extractors written to:");
for path in &result.promoted_files {
println!(" {}", path.display());
}
}
if !result.errors.is_empty() {
println!();
println!("Errors:");
for err in &result.errors {
println!(" - {}", err);
}
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Review error: {e}");
ExitCode::from(3)
}
}
}
async fn handle_extractor_promote(
store: &LocalPatternStore,
config: &AphoriaConfig,
pattern_id: &str,
_force: bool,
) -> ExitCode {
use aphoria::{llm::GeminiClient, PromotionPipeline};
use uuid::Uuid;
// Parse pattern ID
let id = match Uuid::parse_str(pattern_id) {
Ok(id) => id,
Err(_) => {
eprintln!("Invalid pattern ID format. Expected UUID.");
return ExitCode::from(3);
}
};
// Create LLM client
let client = match GeminiClient::new(&config.llm) {
Ok(Some(c)) => c,
Ok(None) => {
eprintln!("LLM not configured. Cannot generate regex patterns.");
return ExitCode::from(3);
}
Err(e) => {
eprintln!("Failed to create LLM client: {e}");
return ExitCode::from(3);
}
};
let output_dir = config.learning.promotion.output_dir.clone();
let pipeline = match PromotionPipeline::new(
store,
Some(&client),
&config.learning.promotion,
Some(output_dir),
) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to create pipeline: {e}");
return ExitCode::from(3);
}
};
match pipeline.promote_by_id(&id) {
Ok(path) => {
println!("Pattern promoted successfully!");
println!(" Extractor written to: {}", path.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Promotion failed: {e}");
ExitCode::from(3)
}
}
}
async fn handle_auto_promote(
store: &LocalPatternStore,
config: &AphoriaConfig,
dry_run: bool,
min_confidence: Option<f32>,
min_projects: Option<usize>,
) -> ExitCode {
use aphoria::{llm::GeminiClient, PromotionPipeline};
// Build autonomous config with overrides
let mut auto_config = config.autonomous.clone();
if let Some(conf) = min_confidence {
auto_config.min_confidence = conf;
}
if let Some(proj) = min_projects {
auto_config.min_projects = proj;
}
// For dry run, temporarily enable autonomous mode
if dry_run {
auto_config.enabled = true;
}
// Check if autonomous promotion is enabled
if !auto_config.enabled && !dry_run {
println!("Autonomous promotion is disabled.");
println!();
println!("To enable, add this to your aphoria.toml:");
println!();
println!(" [autonomous]");
println!(" enabled = true");
println!(" min_confidence = 0.95");
println!(" min_projects = 10");
return ExitCode::SUCCESS;
}
// Create LLM client
let client = match GeminiClient::new(&config.llm) {
Ok(Some(c)) => c,
Ok(None) => {
eprintln!("LLM not configured. Cannot generate regex patterns.");
eprintln!();
eprintln!("To configure LLM, add this to your aphoria.toml:");
eprintln!();
eprintln!(" [llm]");
eprintln!(" enabled = true");
eprintln!(" api_key_env = \"GEMINI_API_KEY\"");
return ExitCode::from(3);
}
Err(e) => {
eprintln!("Failed to create LLM client: {e}");
return ExitCode::from(3);
}
};
let output_dir = config.learning.promotion.output_dir.clone();
let pipeline = match PromotionPipeline::new(
store,
Some(&client),
&config.learning.promotion,
Some(output_dir),
) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to create pipeline: {e}");
return ExitCode::from(3);
}
};
if dry_run {
// Preview mode: check what would be promoted without making changes
println!("Autonomous Promotion Preview (dry run)");
println!("======================================");
println!();
println!("Thresholds:");
println!(" Min confidence: {:.2}", auto_config.min_confidence);
println!(" Min projects: {}", auto_config.min_projects);
println!(" Zero failures: {}", auto_config.require_zero_failures);
println!(" Zero warnings: {}", auto_config.require_zero_warnings);
println!();
let candidates = pipeline.get_candidates();
if candidates.is_empty() {
println!("No patterns eligible for promotion.");
return ExitCode::SUCCESS;
}
let mut would_promote = 0;
let mut needs_review = 0;
for pattern in &candidates {
// Create a mock candidate to check eligibility
match pipeline.generate_candidate(pattern) {
Ok(candidate) => {
if candidate.should_auto_promote(&auto_config) {
would_promote += 1;
println!(
"[WOULD AUTO-PROMOTE] {} (conf: {:.2}, projects: {})",
pattern.id,
pattern.avg_confidence,
pattern.project_count()
);
} else {
needs_review += 1;
let blockers = candidate.auto_promotion_blockers(&auto_config);
println!("[NEEDS REVIEW] {} - {}", pattern.id, blockers.join(", "));
}
}
Err(e) => {
println!("[ERROR] {} - {}", pattern.id, e);
}
}
}
println!();
println!("Summary:");
println!(" Would auto-promote: {}", would_promote);
println!(" Needs review: {}", needs_review);
println!();
println!("To run for real, remove --dry-run flag.");
} else {
// Real mode: actually promote
println!("Running Autonomous Promotion");
println!("============================");
println!();
println!("Thresholds:");
println!(" Min confidence: {:.2}", auto_config.min_confidence);
println!(" Min projects: {}", auto_config.min_projects);
println!();
match pipeline.smart_auto_promote_all(&auto_config) {
Ok(result) => {
println!("Results:");
println!(" Auto-promoted: {}", result.auto_promoted);
println!(" Requires review: {}", result.requires_review);
println!(" Errors: {}", result.errors.len());
if !result.promoted_files.is_empty() {
println!();
println!("Promoted extractors written to:");
for path in &result.promoted_files {
println!(" {}", path.display());
}
}
if !result.errors.is_empty() {
println!();
println!("Errors:");
for err in &result.errors {
println!(" - {}", err);
}
}
// Print audit log location
let audit_dir = auto_config.get_audit_dir();
println!();
println!("Audit log: {}/autonomous-decisions.jsonl", audit_dir.display());
}
Err(e) => {
eprintln!("Auto-promotion failed: {e}");
return ExitCode::from(3);
}
}
}
ExitCode::SUCCESS
}
// ============================================================================
// Version Command Handlers
// ============================================================================
/// Handle the `extractors versions` command.
fn handle_versions(name: &str, config: &AphoriaConfig) -> ExitCode {
let extractors_dir = config.learning.promotion.output_dir.clone();
let version_store = match VersionStore::new(&extractors_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open version store: {e}");
return ExitCode::from(3);
}
};
let changelog = match version_store.read_changelog(name) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to read changelog for {}: {e}", name);
return ExitCode::from(3);
}
};
if changelog.entries.is_empty() {
println!("No version history found for '{}'.", name);
println!();
println!("Version history is created when extractors are promoted");
println!("using the versioned promotion system.");
return ExitCode::SUCCESS;
}
println!("Version History: {}", name);
println!("Current version: {}", changelog.current_version);
println!();
println!("{:<8} {:<12} Changes", "Version", "Date");
println!("{}", "-".repeat(60));
// Show entries newest first
for entry in changelog.entries.iter().rev() {
let changes = if entry.changes.len() > 40 {
format!("{}...", &entry.changes[..37])
} else {
entry.changes.clone()
};
println!("{:<8} {:<12} {}", entry.version, entry.date, changes);
if let Some(ref metrics) = entry.metrics {
println!(
" {:<12} Matches: {}, FP: {}",
"", metrics.matches, metrics.false_positives
);
}
}
println!();
println!("To compare versions:");
println!(" aphoria extractors compare {} -a 1 -b 2", name);
println!();
println!("To rollback to a previous version:");
println!(" aphoria extractors rollback-version {} --version 1 --reason \"...\"", name);
ExitCode::SUCCESS
}
/// Handle the `extractors compare` command.
fn handle_compare(name: &str, version_a: u32, version_b: u32, config: &AphoriaConfig) -> ExitCode {
// Open shadow store for metrics
let shadow_dir = config.shadow.get_shadow_dir();
let shadow_store = match ShadowStore::new(&shadow_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open shadow store: {e}");
return ExitCode::from(3);
}
};
println!("Comparison: {} v{} vs v{}", name, version_a, version_b);
println!();
match compute_metrics_delta(&shadow_store, name, version_a, version_b) {
Ok(Some(delta)) => {
println!("{:<20} {}", "Matches", delta.matches);
println!("{:<20} {}", "False Positives", delta.false_positives);
}
Ok(None) => {
println!("Insufficient metrics data available for comparison.");
println!();
println!("Metrics are collected during shadow mode testing.");
println!("Ensure the extractor has been through shadow mode with");
println!("sufficient feedback before comparing versions.");
}
Err(e) => {
eprintln!("Failed to compute metrics: {e}");
return ExitCode::from(3);
}
}
ExitCode::SUCCESS
}
/// Handle the `extractors rollback-version` command.
fn handle_rollback_version(
name: &str,
version: u32,
reason: &str,
config: &AphoriaConfig,
) -> ExitCode {
let extractors_dir = config.learning.promotion.output_dir.clone();
let version_store = match VersionStore::new(&extractors_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open version store: {e}");
return ExitCode::from(3);
}
};
// Check that the version exists
let versions = match version_store.list_versions(name) {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to list versions: {e}");
return ExitCode::from(3);
}
};
if !versions.contains(&version) {
eprintln!("Version {} not found for '{}'.", version, name);
if versions.is_empty() {
eprintln!("No archived versions available.");
} else {
eprintln!("Available versions: {:?}", versions);
}
return ExitCode::from(3);
}
// Perform the rollback
let path = match version_store.restore_version(name, version, &extractors_dir) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to restore version: {e}");
return ExitCode::from(3);
}
};
// Record rollback in changelog
let new_version = match version_store.next_version(name) {
Ok(v) => v,
Err(e) => {
eprintln!("Warning: Failed to determine new version number: {e}");
0
}
};
let rollback_entry =
ChangelogEntry::new(new_version, format!("Rollback to v{}: {}", version, reason));
if let Err(e) = version_store.append_changelog(name, rollback_entry) {
eprintln!("Warning: Failed to update changelog: {e}");
}
println!("Rolled back {} to v{}", name, version);
println!("Restored as: {}", path.display());
println!();
println!("Reason: {}", reason);
println!();
println!("A new changelog entry has been created documenting this rollback.");
ExitCode::SUCCESS
}