diff --git a/apps/desktop/src-tauri/src/application/mod.rs b/apps/desktop/src-tauri/src/application/mod.rs new file mode 100644 index 00000000..c9868b1f --- /dev/null +++ b/apps/desktop/src-tauri/src/application/mod.rs @@ -0,0 +1 @@ +pub mod verification_service; diff --git a/apps/desktop/src-tauri/src/application/verification_service.rs b/apps/desktop/src-tauri/src/application/verification_service.rs new file mode 100644 index 00000000..0c8ebb7a --- /dev/null +++ b/apps/desktop/src-tauri/src/application/verification_service.rs @@ -0,0 +1,313 @@ +//! Tauri-independent application service for the native verification loop. +//! +//! Transport adapters supply one stable request identity. The service owns the +//! versioned command contract, correlates every progress event and terminal +//! receipt, and delegates verification semantics to the existing Rust engine. + +use serde::{Deserialize, Serialize}; + +use crate::commands::local_check::{ + preflight_local_check, run_local_check_with_progress, LocalCheckInput, + LocalCheckPreflightReceipt, LocalCheckReceipt, +}; + +pub const VERIFICATION_COMMAND_SCHEMA: &str = "codevetter.verification-command/v1"; +pub const VERIFICATION_PROGRESS_SCHEMA: &str = "codevetter.progress/v2"; +pub const VERIFICATION_CANCELLATION_SCHEMA: &str = "codevetter.verification-cancel/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerificationOperation { + Preflight, + Execute, +} + +#[derive(Debug, Clone)] +pub struct VerificationCommand { + pub schema_version: &'static str, + pub request_id: String, + pub operation: VerificationOperation, + pub input: LocalCheckInput, +} + +impl VerificationCommand { + pub fn new( + request_id: Option, + operation: VerificationOperation, + input: LocalCheckInput, + ) -> Result { + Ok(Self { + schema_version: VERIFICATION_COMMAND_SCHEMA, + request_id: resolve_request_id(request_id.as_deref())?, + operation, + input, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationProgress { + pub schema_version: String, + pub request_id: String, + pub sequence: u32, + pub stage: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationCancellation { + pub schema_version: String, + pub request_id: String, +} + +impl VerificationCancellation { + pub fn new(request_id: &str) -> Result { + validate_request_id(request_id)?; + Ok(Self { + schema_version: VERIFICATION_CANCELLATION_SCHEMA.into(), + request_id: request_id.into(), + }) + } +} + +#[derive(Debug, Clone)] +pub enum VerificationResult { + Preflight(Box), + Complete(Box), +} + +pub async fn run_verification_command( + command: VerificationCommand, + mut on_progress: F, +) -> Result +where + F: FnMut(VerificationProgress), +{ + if command.schema_version != VERIFICATION_COMMAND_SCHEMA { + return Err(format!( + "unsupported verification command schema `{}`", + command.schema_version + )); + } + validate_request_id(&command.request_id)?; + let mut sequence = 0_u32; + let mut emit = |stage: &str, state: &str| { + let event = VerificationProgress { + schema_version: VERIFICATION_PROGRESS_SCHEMA.into(), + request_id: command.request_id.clone(), + sequence, + stage: stage.into(), + state: state.into(), + }; + sequence = sequence.saturating_add(1); + on_progress(event); + }; + + match command.operation { + VerificationOperation::Preflight => { + emit("preflight", "running"); + let mut receipt = preflight_local_check(&command.input).await?; + receipt.request_id = Some(command.request_id.clone()); + emit("preflight", "completed"); + Ok(VerificationResult::Preflight(Box::new(receipt))) + } + VerificationOperation::Execute => { + let request_id = command.request_id.clone(); + let mut receipt = run_local_check_with_progress(command.input, |progress| { + emit(progress.stage, progress.state) + }) + .await?; + receipt.request_id = Some(request_id); + Ok(VerificationResult::Complete(Box::new(receipt))) + } + } +} + +pub fn resolve_request_id(request_id: Option<&str>) -> Result { + match request_id.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => { + validate_request_id(value)?; + Ok(value.to_string()) + } + None => Ok(uuid::Uuid::new_v4().to_string()), + } +} + +fn validate_request_id(request_id: &str) -> Result<(), String> { + if request_id.is_empty() || request_id.len() > 128 { + return Err("request id must contain between 1 and 128 characters".into()); + } + if !request_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err( + "request id may contain only ASCII letters, numbers, dash, underscore, dot, or colon" + .into(), + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + use crate::commands::local_check::{LocalCheckStatus, LocalCheckTarget, LocalCheckVerdict}; + + const LOCAL_CHECK_PARITY_FIXTURE: &str = + include_str!("../../tests/fixtures/surface-parity/local-check-v1.json"); + + fn git(repo: &Path, arguments: &[&str]) { + let output = std::process::Command::new("git") + .args(arguments) + .current_dir(repo) + .output() + .expect("git command"); + assert!( + output.status.success(), + "git {:?}: {}", + arguments, + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn request_identity_is_bounded_and_generated_when_absent() { + assert_eq!( + resolve_request_id(Some("native.review:fixture-1")).expect("valid request"), + "native.review:fixture-1" + ); + assert!( + uuid::Uuid::parse_str(&resolve_request_id(None).expect("generated request")).is_ok() + ); + assert!(resolve_request_id(Some("../unsafe path")).is_err()); + assert!(resolve_request_id(Some(&"x".repeat(129))).is_err()); + } + + #[test] + fn progress_contract_carries_request_identity_and_order() { + let progress = VerificationProgress { + schema_version: VERIFICATION_PROGRESS_SCHEMA.into(), + request_id: "native-review-fixture".into(), + sequence: 3, + stage: "correctness".into(), + state: "running".into(), + }; + let value = serde_json::to_value(&progress).expect("progress JSON"); + assert_eq!(value["schema_version"], VERIFICATION_PROGRESS_SCHEMA); + assert_eq!(value["request_id"], "native-review-fixture"); + assert_eq!(value["sequence"], 3); + let cancellation = + VerificationCancellation::new("native-review-fixture").expect("cancellation contract"); + assert_eq!( + cancellation.schema_version, + VERIFICATION_CANCELLATION_SCHEMA + ); + assert_eq!(cancellation.request_id, progress.request_id); + } + + #[test] + fn authoritative_service_owns_the_shared_local_check_receipt_contract() { + let fixture: serde_json::Value = + serde_json::from_str(LOCAL_CHECK_PARITY_FIXTURE).expect("local-check parity fixture"); + let receipt: LocalCheckReceipt = + serde_json::from_value(fixture["canonical_receipt"].clone()) + .expect("canonical local-check receipt"); + let request = &fixture["request"]; + + assert_eq!(fixture["authority"]["rust"], "authoritative_service"); + assert_eq!(request["schema_version"], VERIFICATION_COMMAND_SCHEMA); + assert_eq!( + receipt.schema_version, + fixture["expected"]["receipt_schema"] + ); + assert_eq!( + receipt.request_id.as_deref(), + request["request_id"].as_str() + ); + assert_eq!(receipt.run_id, fixture["expected"]["run_id"]); + assert_eq!(receipt.verdict, LocalCheckVerdict::NoConfidence); + assert_eq!( + receipt.stages.performance.status, + LocalCheckStatus::NoConfidence + ); + assert!(receipt + .limitations + .iter() + .any(|value| value == fixture["expected"]["limitation"].as_str().unwrap())); + } + + #[tokio::test] + async fn preflight_runs_through_the_service_and_correlates_every_projection() { + let repo = tempfile::tempdir().expect("repository"); + git(repo.path(), &["init", "--initial-branch", "main"]); + git( + repo.path(), + &["config", "user.email", "fixture@example.test"], + ); + git(repo.path(), &["config", "user.name", "CodeVetter Fixture"]); + std::fs::create_dir_all(repo.path().join("test")).expect("test directory"); + std::fs::write(repo.path().join("source.js"), "export const value = 1;\n").expect("source"); + std::fs::write(repo.path().join("test/source.test.js"), "// fixture\n").expect("test"); + git(repo.path(), &["add", "."]); + git(repo.path(), &["commit", "-m", "base"]); + std::fs::write(repo.path().join("source.js"), "export const value = 2;\n") + .expect("changed source"); + git(repo.path(), &["add", "source.js"]); + git(repo.path(), &["commit", "-m", "change"]); + + let command = VerificationCommand::new( + Some("native-review-service-fixture".into()), + VerificationOperation::Preflight, + LocalCheckInput { + repo_path: repo.path().to_path_buf(), + change: "HEAD^...HEAD".into(), + task: "Preserve the source contract".into(), + standards_pack: None, + standards_context: None, + spec_paths: Vec::new(), + selected_requirement_ids: Vec::new(), + review_agent: "codex".into(), + test_target: Some(LocalCheckTarget { + adapter: "node-test".into(), + target: "test/source.test.js".into(), + name: None, + source: "explicit:fixture".into(), + }), + performance_target: None, + baseline_repo_path: None, + samples: 3, + warmups: 1, + timeout_ms: 30_000, + }, + ) + .expect("command"); + let mut progress = Vec::new(); + let result = run_verification_command(command, |event| progress.push(event)) + .await + .expect("service preflight"); + let VerificationResult::Preflight(receipt) = result else { + panic!("expected preflight receipt"); + }; + + assert_eq!( + receipt.request_id.as_deref(), + Some("native-review-service-fixture") + ); + assert_eq!(receipt.status, LocalCheckStatus::Ready); + assert_eq!(progress.len(), 2); + assert!(progress + .iter() + .all(|event| event.request_id == "native-review-service-fixture")); + assert_eq!( + progress + .iter() + .map(|event| event.sequence) + .collect::>(), + vec![0, 1] + ); + assert_eq!(progress[0].state, "running"); + assert_eq!(progress[1].state, "completed"); + } +} diff --git a/apps/desktop/src-tauri/src/capabilities.rs b/apps/desktop/src-tauri/src/capabilities.rs new file mode 100644 index 00000000..c8983987 --- /dev/null +++ b/apps/desktop/src-tauri/src/capabilities.rs @@ -0,0 +1,928 @@ +//! Canonical product-capability catalog shared by every CodeVetter surface. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashSet; + +pub const CAPABILITY_SCHEMA_VERSION: &str = "codevetter.capabilities.v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityStage { + Current, + Building, + Future, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Availability { + Available, + Building, + Planned, + Unavailable, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Authority { + None, + Read, + Execute, + ReadExecute, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Qualification { + Qualified, + Partial, + Unqualified, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SurfaceProjection { + pub availability: Availability, + pub authority: Authority, + pub entrypoints: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SurfaceMatrix { + pub ui: SurfaceProjection, + pub cli: SurfaceProjection, + pub agent: SurfaceProjection, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UnderlyingTool { + pub name: String, + pub role: String, + pub requirement: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Capability { + pub id: String, + pub name: String, + pub purpose: String, + pub stage: CapabilityStage, + pub surfaces: SurfaceMatrix, + pub underlying_tools: Vec, + pub data_boundary: String, + pub qualification: Qualification, + pub limitations: Vec, + pub next_step: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CapabilityRegistry { + pub schema_version: String, + pub authority: String, + pub capabilities: Vec, +} + +pub fn capability_registry() -> CapabilityRegistry { + let registry = CapabilityRegistry { + schema_version: CAPABILITY_SCHEMA_VERSION.to_string(), + authority: "codevetter-rust-core".to_string(), + capabilities: vec![ + capability( + "verification.local_check", + "Local verification", + "Bind one exact task and change to executable correctness, performance, review, and receipt evidence.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "Tauri Review", + "Native Review (acceptance binding, plan, execute, findings, proof map, intent diagnostic, recorded-QA artifacts, isolated fix/recheck, source, X-Ray, export)", + ], + ), + projection(Availability::Available, Authority::ReadExecute, &["codevetter check", "codevetter fix-packet", "codevetter fix", "codevetter xray"]), + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "MCP verification_get_receipt (read-only persisted receipt)", + "codevetter fix through an explicit local CLI consent boundary", + ], + ), + ), + &[ + tool("CodeVetter Rust core", "Owns planning, execution order, verdicts, and receipts", "bundled"), + tool("Configured coding-agent CLI", "Produces the model review stage", "optional local tool"), + ], + "Selected local repository; evidence stays on the Mac unless the configured agent provider is invoked.", + Qualification::Partial, + &["The Tauri-independent verification-command/v1 service correlates native/CLI commands, ordered progress/v2 events, request-scoped verification-cancel/v1 termination, and terminal receipts with one bounded request id.", "Cancellation is supervised at the process boundary but is not yet a canonical engine event.", "An unavailable runtime collector yields an explicit limitation rather than a passing claim.", "Native source opening uses the recorded repository-relative path; line positioning depends on the user's default editor.", "Fix execution is deliberately limited to one retained detached worktree. It never commits, merges, pushes, or modifies the selected checkout; discard requires a separate confirmation.", "The repository-scoped MCP server can read one persisted canonical local-check receipt by bounded run id, but it cannot start or cancel verification. Agent execution remains an explicit local CLI consent boundary."], + "Qualify a real isolated fix/recheck plus saved-flow post-fix rerun without duplicating execution authority in Review.", + ), + capability( + "verification.runtime_preview", + "Runtime preview verification", + "Exercise changed browser behavior against an exact preview and preserve executable evidence.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "Tauri Testing", + "Native Testing (secret-safe journey workspace + scope discovery + direct preview + warm + differential + scenarios + PR watcher)", + ], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "codevetter scope --consumer testing", + "codevetter trex", + "codevetter qa", + "codevetter warm", + "codevetter differential", + "codevetter scenario", + "codevetter watcher", + ], + ), + projection( + Availability::Available, + Authority::Read, + &[ + "MCP resolve_evidence_scope", + "MCP qa_workspace_inspect", + "MCP prepare_review verification_targets", + ], + ), + ), + &[ + tool("Playwright", "Runs browser journeys and captures runtime evidence", "project or bundled runtime"), + tool("CodeVetter Rust QA workspace", "Owns secret-safe saved workflow projection, repository spec discovery, explicit target handoff, and deterministic post-fix comparison setup", "bundled"), + ], + "Selected repository and explicitly supplied preview URL. Watcher polls may contact GitHub, execute project code and a configured agent, and post commit statuses only after foreground confirmation.", + Qualification::Partial, + &["A preview must already exist for direct preview verification; warm, differential, and scenario workflows instead require one repository-owned verify script and supported lockfile.", "Fixture execution is contract evidence, not production-change proof.", "Native and CLI consume one codevetter.qa-workspace/v1 receipt. It imports only non-secret legacy fields into a separate native preference, discovers repository Playwright specs without execution, passes the selected route and goal into the canonical T-REX receipt, and never restores preview network consent. The scoped MCP projection is read-only.", "Native and CLI scenario authoring share the incumbent Rust bridge: free/local generation creates only expiring candidates; validation and dry-run are non-persistent; acceptance is hash-bound and destination-selective. Differential evidence never creates pass evidence. Native PR watcher scheduling exists only for the current app lifetime; every foreground poll is explicitly confirmed and remains alive until new head-SHA receipts persist. The watcher fetches the immutable GitHub PR ref without changing the checkout, uses the repository-declared Node package manager, and resolves status authentication ephemerally from existing local authority. MCP target discovery remains read-only and never starts these runtimes.", "One repository-owned evidence-scope fixture now passes the authoritative Rust resolver, CLI projection, native supervised runner, and real read-only MCP protocol without semantic drift.", "A bounded live PR qualification proved exact-head materialization, pnpm frozen installation, repository-owned lint, conservative NEEDS_REVIEW classification, retained receipt identity, and a GitHub commit status without exposing a token."], + "Qualify one real saved-flow post-fix rerun and complete owner visual acceptance while keeping MCP inspection read-only.", + ), + capability( + "verification.performance", + "Performance verification", + "Compare bounded workloads and prevent unsupported optimization claims.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "Tauri Performance", + "Native Performance (scope discovery + exact workload)", + ], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "codevetter scope --consumer performance", + "codevetter performance", + "codevetter check --perf-adapter", + ], + ), + projection( + Availability::Available, + Authority::Read, + &["MCP resolve_evidence_scope", "MCP prepare_review verification_targets"], + ), + ), + &[tool("CodeVetter performance capsule", "Supervises samples, warmups, timeouts, and paired evidence", "bundled")], + "Explicit local workload and optional clean baseline checkout.", + Qualification::Partial, + &["Results are workload-specific.", "A missing clean baseline blocks paired-improvement claims.", "Native intent/change/codebase scope resolution, digest-validated recorded-run inspection, the diagnosis-to-paired campaign handoff, and periodic owned-process-tree RSS/process evidence are available. Sampling can miss peaks between 75 ms observations.", "The shared evidence-scope fixture passes Rust, CLI, native, and read-only MCP projections; only UI and CLI retain workload-execution authority.", "Native release launch, steady app RSS, bridge latency, 1,000-event progress throughput, cancellation, worker crash recovery, and five large-receipt decode/render surfaces pass explicit gates."], + "Refresh launch, settled RSS, responsiveness, energy, and long-session evidence on the exact current package with owner-approved foreground qualification.", + ), + capability( + "evidence.local_usage", + "Local agent usage", + "Inspect local token, cache, cost, model, and session evidence without conflating it with cloud quota telemetry.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::Read, + &[ + "Tauri Usage", + "Native Usage (ccusage plus separate indexed Devin history)", + ], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter usage"], + ), + projection(Availability::Planned, Authority::None, &[]), + ), + &[ + tool( + "ccusage 20.0.20", + "Normalizes offline Claude, Codex, and Grok local usage logs", + "bundled pinned sidecar", + ), + tool( + "CodeVetter Rust core", + "Owns provider boundaries, ccusage normalization, separate SQLite Devin history, caching, and stale/unavailable states", + "bundled", + ), + ], + "Local agent logs, optional read-only imported Codex roots, and indexed Devin sessions from the existing SQLite database; no provider credential or network access.", + Qualification::Partial, + &[ + "Indexed Devin sessions, generated/cache tokens, cost, and model rows follow 1w, 30d, 90d, and all-time windows through a separate Rust projection and are never included in ccusage totals.", + "Live provider quotas remain separate telemetry and are never inferred from local spend.", + "Native 1w, 30d, 90d, and all-time selection keeps ccusage chart, totals, models, and sessions aligned while the separate Devin desk follows the same selected window.", + ], + "Migrate live provider telemetry as a credential-safe separate projection, then expose the bounded report through scoped MCP.", + ), + capability( + "usage.history_roots", + "Additional Codex history roots", + "Restore Codex sessions stored outside the active CODEX_HOME without reading or deleting transcript content during configuration.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &["Tauri Usage settings", "Native Usage settings"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter history-roots"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core", + "Owns path normalization, Codex-home validation, deduplication, the 16-root bound, SQLite preference persistence, and the versioned receipt", + "bundled", + )], + "Absolute local directory identities and availability metadata only; configuration never reads transcript content and removal never deletes provider files.", + Qualification::Qualified, + &[ + "The active CODEX_HOME remains automatic and is not duplicated in the additional-root receipt.", + "A selected sessions or archived_sessions directory is normalized to its containing Codex home.", + "Reconciliation remains a separate explicit Usage action.", + "Agent and MCP surfaces receive no local history-root authority.", + ], + "Keep local history-root mutation out of agent authority and preserve the bounded receipt as usage importers evolve.", + ), + capability( + "configuration.native_settings", + "Native non-secret settings", + "Read and save declared local preferences without projecting credentials or provider tokens into Swift.", + CapabilityStage::Building, + surfaces( + projection( + Availability::Building, + Authority::ReadExecute, + &["Native Settings", "Native first-run onboarding"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter settings", "codevetter onboarding"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core", + "Owns the allowlist, validation, SQLite persistence, and versioned receipt", + "bundled", + )], + "Twenty-eight declared non-secret local preferences plus the shared onboarding completion flag and default adapter; github_token and all undeclared values are excluded from every receipt.", + Qualification::Partial, + &[ + "Integration credentials remain in their incumbent owner until secure native storage is separately qualified.", + "Native onboarding reuses the incumbent completion flag, checks executable presence without inspecting authentication, and changes only the declared default adapter plus completion state.", + "Ops read-only aggregate status now has a bounded shared contract; credential writes, live provider refresh, and webhook operations remain with the incumbent owner.", + "About now reports native version and bundle identity, and Sparkle is locally packaged but remains disabled until production signing, appcast, EdDSA, and installed-upgrade gates pass.", + ], + "Prove secure native credential ownership and production updater behavior separately without widening the non-secret settings receipt.", + ), + capability( + "operations.local_status", + "Local operations status", + "Inspect bounded local billing readiness, webhook readiness, and aggregate agent-run evidence without exposing credentials or contacting providers.", + CapabilityStage::Building, + surfaces( + projection( + Availability::Available, + Authority::Read, + &["Native Settings / Ops"], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter ops"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core and SQLite", + "Own the fixed time windows, configuration-presence projection, aggregate observability query, secret exclusion, and versioned receipt", + "bundled", + )], + "Local aggregate counts, rates, durations, and configuration-presence booleans for 7, 30, or 90 days. Credentials, webhook URLs, absolute paths, and provider responses never enter the receipt.", + Qualification::Partial, + &[ + "This surface never refreshes live provider billing or sends a webhook.", + "Credential and endpoint writes remain in the incumbent settings surface.", + "Indexed-session success remains an explicitly labelled aggregate proxy because the stored source has no failure signal.", + "Agent and MCP surfaces receive no operations authority.", + ], + "Transfer credential storage and live provider or webhook operations only after a separately reviewed secure-native contract is qualified.", + ), + capability( + "presentation.agent_island", + "Agent Island", + "Configure the optional native agent-status presentation without exposing provider content, credentials, or action authority.", + CapabilityStage::Building, + surfaces( + projection( + Availability::Building, + Authority::ReadExecute, + &["Tauri Agent Island runtime", "Native Agent Island settings"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter settings"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[ + tool( + "CodeVetter Rust core", + "Owns the twelve-setting allowlist, validation, SQLite persistence, and helper runtime authority", + "bundled", + ), + tool( + "AppKit and SwiftUI Agent Island helper", + "Owns non-activating presentation and local system speech only", + "bundled", + ), + ], + "Twelve non-secret presentation and speech preferences. Live session snapshots, prompts, output, commands, paths, provider responses, and credentials never enter the settings receipt.", + Qualification::Partial, + &[ + "The feature remains off by default.", + "Native UI, CLI, and the retained helper share the exact persisted preference keys, defaults, and options.", + "The new Evidence Workbench stores configuration only; it does not yet launch the helper or action live agent requests.", + "Agent and MCP surfaces receive no Agent Island authority.", + ], + "Integrate and requalify the supervised helper in the new native host before claiming live runtime parity.", + ), + capability( + "evidence.agent_memories", + "Local agent memories", + "Inspect bounded local agent instruction and memory sources without granting edit, deletion, credential, or agent authority.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::Read, + &["Tauri Memories", "Native Memories"], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter memories"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core", + "Owns source discovery, opaque identity, canonical path admission, redaction, byte and character bounds, and Git diff supervision", + "bundled", + )], + "Explicitly selected local agent memory content. Receipts expose display paths rather than absolute paths and apply line-based secret redaction before content leaves Rust.", + Qualification::Qualified, + &[ + "The source catalog is capped at 128 entries; one document is capped at 512 KiB and 120,000 output characters.", + "Redaction is heuristic, so displayed memory remains private operator data.", + "The native UI and CLI can list, read, search, copy, and inspect a redacted Git diff; neither can edit or delete a source.", + "MCP and agent surfaces receive no memory content or read authority.", + ], + "Preserve the read-only boundary while adding explicit source-format fixtures as new agent tools are supported.", + ), + capability( + "maintenance.session_retention", + "Session archive retention", + "Preview and explicitly maintain CodeVetter indexed session rows without deleting provider transcripts or source sessions.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &["Tauri Usage settings", "Native Usage settings"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter retention"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[tool( + "CodeVetter Rust core", + "Owns policy validation, protected-reference discovery, stable plan identity, fail-closed apply, checkpoint, and VACUUM", + "bundled", + )], + "Local CodeVetter archive and FTS rows only; provider transcripts, source sessions, and protected references are retained.", + Qualification::Qualified, + &[ + "Preview persists a plan receipt but deletes no archive rows.", + "Apply and VACUUM require explicit UI or CLI authority and are intentionally not exposed to agents.", + ], + "Keep destructive maintenance out of agent authority; add separate read-only recovery diagnostics when the history-root transfer is implemented.", + ), + capability( + "configuration.review_rubrics", + "Review rubric packs", + "Keep the exact review standards, active selection, prompt context, and usage attribution consistent across product and agent surfaces.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &["Tauri Rubrics", "Native Rubrics"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter rubrics", "codevetter check"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter check", "codevetter rubrics"], + ), + ), + &[tool( + "CodeVetter Rust core", + "Owns built-in definitions, validation, active selection, custom packs, usage attribution, and exact prompt rendering", + "bundled", + )], + "Non-secret rubric definitions and local review-attribution counts; no provider credentials or review evidence content.", + Qualification::Partial, + &[ + "The incumbent Tauri shell attempts the bounded WebView-local migration on every startup until Rust owns a canonical preference; opening Rubrics also retries and reports sync errors.", + "Built-in packs are immutable; custom packs can be created or replaced within declared bounds.", + ], + "Qualify the startup bridge against an installed upgrade with custom packs before retiring the Tauri rubric owner.", + ), + capability( + "machine.repository_mcp", + "Repository-scoped MCP", + "Expose bounded local history, graph, archaeology, and review-preparation context to agents without granting file-write or provider authority.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &["Tauri Agent MCP", "Native Agent MCP"], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter mcp"], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter-mcp stdio server"], + ), + ), + &[ + tool( + "CodeVetter MCP server", + "Serves repository-scoped resources and tools over local stdio", + "bundled companion executable", + ), + tool( + "CodeVetter Rust core", + "Owns scope enablement, redaction, limits, audit metadata, and client configuration", + "bundled", + ), + ], + "One explicitly selected, history-indexed local repository; operational audit rows never store arguments, prompts, queries, credentials, or evidence content.", + Qualification::Partial, + &[ + "Enabling requires an existing release-history index.", + "The server uses local stdio only and cannot write files, refresh indexes, call providers, or listen on the network.", + "The native local package gate bundles and smokes codevetter-mcp beside the app; Developer ID and notarized archive proof remain release gates.", + ], + "Add scoped MCP projections for remaining non-local-check receipt families and repeat companion qualification in the notarized production archive.", + ), + capability( + "evidence.tool_collectors", + "External evidence collectors", + "Attach narrowly scoped security and coverage receipts without treating tool presence as proof.", + CapabilityStage::Current, + surfaces( + projection(Availability::Planned, Authority::None, &[]), + projection(Availability::Available, Authority::ReadExecute, &["codevetter collect"]), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[ + tool("Gitleaks", "Scans the selected change for secret exposure", "optional local tool"), + tool("cargo-audit", "Checks Rust advisories using available local data", "optional local tool"), + tool("cargo-llvm-cov", "Captures Rust coverage evidence", "optional local tool"), + ], + "Explicit change range in the selected local repository; collectors receive only their declared inputs.", + Qualification::Partial, + &["The native glossary reports declared collectors and limitations but does not execute them.", "Collectors are not installed or network-enabled automatically.", "Unavailable offline data keeps the claim closed."], + "Add bounded collector receipt inspection to the native UI and scoped MCP without granting either surface collector-execution authority.", + ), + capability( + "repository.snapshot_scan", + "Deterministic repository snapshot", + "Create and inspect one local, bounded source, history, health, and topology snapshot without invoking a model.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::ReadExecute, + &[ + "Tauri Repo Unpack", + "Native Repo Unpack (scan + bounded inspectors)", + ], + ), + projection( + Availability::Available, + Authority::ReadExecute, + &["codevetter unpack --operation scan", "codevetter unpack --operation inspect"], + ), + projection( + Availability::Available, + Authority::Read, + &[ + "MCP graph and history tools over an explicitly enabled stored index", + ], + ), + ), + &[ + tool( + "CodeVetter Rust core", + "Owns the deterministic scan, bounded projection, persistence, and receipt", + "bundled", + ), + tool( + "Git", + "Supplies local revision and bounded history evidence when available", + "optional local tool", + ), + tool( + "rusqlite", + "Persists the canonical local snapshot", + "bundled", + ), + ], + "One explicitly selected local directory and the local SQLite evidence store; the scan does not call a provider or require network access.", + Qualification::Partial, + &[ + "The client receipt omits the raw full-file list while the bounded canonical snapshot remains local.", + "Topology, history, and deterministic health are navigation evidence, not executable verification.", + "Native model synthesis execution and cleanup remain migration gaps.", + ], + "Migrate the remaining synthesis and cleanup workflows while keeping every Rust receipt authoritative.", + ), + capability( + "repository.structural_graph", + "Structural repository graph", + "Navigate source-backed symbols, relationships, impact, and history without presenting topology as runtime proof.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::Read, + &[ + "Tauri Repo Unpack", + "Native Repo Unpack (bounded snapshot + canonical query desk)", + ], + ), + projection( + Availability::Available, + Authority::Read, + &[ + "codevetter unpack --operation query --query-domain graph --query-mode search|explain|impact|path", + "codevetter-graph", + ], + ), + projection(Availability::Available, Authority::Read, &["graph_query", "graph_impact", "graph_path"]), + ), + &[tool("Tree-sitter", "Extracts syntax-aware source identities across the qualified language set", "bundled")], + "Selected local repository and its local SQLite evidence store.", + Qualification::Qualified, + &[ + "Graph relationships are navigation evidence, not executable verification.", + "Native search, node explanation, impact, and directed path stay bounded and fail closed when the canonical structural index is unavailable.", + "Native retains one read-only search projection per worker, upgrades it in place with compact traversal edges only when required, hydrates bounded result evidence, rechecks live Git freshness and latest snapshot identity on every query, and falls back to the exact supervised one-shot CLI contract when the worker transport is unavailable.", + ], + "Add source-opening and richer graph filtering without moving ranking or traversal semantics into Swift.", + ), + capability( + "repository.history", + "Evidence-backed repository history", + "Explain bounded historical state and lineage using stable evidence identities and explicit gaps.", + CapabilityStage::Current, + surfaces( + projection( + Availability::Available, + Authority::Read, + &[ + "Tauri Repo Unpack", + "Native Repo Unpack (bounded snapshot + canonical query desk)", + ], + ), + projection( + Availability::Available, + Authority::Read, + &[ + "codevetter unpack --operation query --query-domain history --query-mode search|trace", + ], + ), + projection(Availability::Available, Authority::Read, &["history_search", "history_explain", "history_trace"]), + ), + &[tool("Git", "Supplies exact local revision and tag identity", "required local tool")], + "Authorized repository scope and read-only local SQLite evidence.", + Qualification::Qualified, + &[ + "Explanations remain bounded by indexed evidence and disclose missing causal proof.", + "Native history search and causal trace share the canonical Rust index, preserve evidenced versus qualified-lead links, and fail closed when temporal coverage is unavailable.", + "Repeated native history queries reuse the same scoped read-only worker and preserve the exact one-shot CLI fallback.", + ], + "Add native source lineage without duplicating temporal semantics in Swift.", + ), + capability( + "native.evidence_workbench", + "Native Evidence Workbench", + "Provide a fast, accessible macOS operating surface over the canonical Rust verification loop.", + CapabilityStage::Building, + surfaces( + projection(Availability::Building, Authority::ReadExecute, &["apps/macos"]), + projection(Availability::Unavailable, Authority::None, &[]), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[ + tool("AppKit", "Owns windows, menus, split views, keyboard behavior, and lifecycle", "Apple platform"), + tool("SwiftUI", "Composes feature, inspector, and settings views", "Apple platform"), + tool("CodeVetter Rust core", "Owns verification execution, persistence, verdicts, and canonical receipts", "bundled CLI and MCP companions"), + tool("Sparkle 2.9.6", "Owns signed update discovery, installation, and relaunch after production configuration", "exact Swift package; disabled in preview"), + tool("XcodeBuildMCP 2.7.0", "Provides reproducible project build and test automation", "development only"), + ], + "User-selected local repositories; no ambient credential authority. Sparkle remains inactive unless a production HTTPS appcast and EdDSA public key are present.", + Qualification::Partial, + &[ + "The native client does not replace Tauri until feature, output, performance, accessibility, visual, installed-upgrade, and owner gates pass.", + "The local package is hardened, non-sandboxed, and ad-hoc signed; Developer ID signing, notarization, production updater inputs, and rollback proof remain open.", + ], + "Close retained feature and owner-interaction gaps, refresh exact-package performance with owner-approved foreground qualification, then qualify a notarized installed upgrade before the owner retirement decision.", + ), + capability( + "evidence.local_runs", + "Verification run ledger", + "Inspect one bounded chronology of local-check, preview, T-Rex PR, synthetic QA, warm, differential, and audience evidence without rewriting originating receipts.", + CapabilityStage::Building, + surfaces( + projection( + Availability::Building, + Authority::Read, + &["Native Runs (building)"], + ), + projection( + Availability::Available, + Authority::Read, + &["codevetter runs"], + ), + projection(Availability::Unavailable, Authority::None, &[]), + ), + &[ + tool( + "CodeVetter Rust core", + "Owns receipt schemas and persistence", + "bundled", + ), + tool( + "rusqlite", + "Stores complete canonical receipts in the existing local database", + "bundled", + ), + ], + "Local SQLite database; list results are bounded to at most 100 receipts.", + Qualification::Partial, + &[ + "The ledger is read-only; watcher, QA, and audience workflow controls remain on their originating surfaces until those workspaces migrate.", + "The Swift host-render gate excludes window-server frame pacing and interactive scrolling.", + "Foreground XCUITest and owner interaction acceptance remain open.", + ], + "Complete foreground UI automation and owner interaction acceptance, then use the ledger as shared evidence infrastructure for Testing and Performance.", + ), + capability( + "runtime.hardened_isolation", + "Hardened execution isolation", + "Run untrusted project checks with stronger process, filesystem, resource, and network containment.", + CapabilityStage::Future, + surfaces( + projection(Availability::Planned, Authority::None, &[]), + projection(Availability::Planned, Authority::None, &[]), + projection(Availability::Planned, Authority::None, &[]), + ), + &[tool("Apple Containerization or measured alternative", "Candidate containment boundary", "not selected")], + "Not yet defined; no isolation claim is made.", + Qualification::Unqualified, + &["No production isolation backend has passed the runtime and compatibility gates."], + "Benchmark candidates against real CodeVetter workloads before selecting a dependency.", + ), + ], + }; + debug_assert!(validate_registry(®istry).is_ok()); + registry +} + +pub fn capability_registry_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://codevetter.com/schemas/capabilities.v1.json", + "title": "CodeVetter capability registry", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "authority", "capabilities"], + "properties": { + "schema_version": {"const": CAPABILITY_SCHEMA_VERSION}, + "authority": {"const": "codevetter-rust-core"}, + "capabilities": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "purpose", "stage", "surfaces", "underlying_tools", "data_boundary", "qualification", "limitations", "next_step"] + } + } + } + }) +} + +pub fn validate_registry(registry: &CapabilityRegistry) -> Result<(), String> { + if registry.schema_version != CAPABILITY_SCHEMA_VERSION { + return Err("Capability registry schema version is invalid".to_string()); + } + let mut ids = HashSet::new(); + for capability in ®istry.capabilities { + if capability.id.trim().is_empty() || !ids.insert(capability.id.as_str()) { + return Err(format!( + "Capability id '{}' is empty or duplicated", + capability.id + )); + } + if capability.name.trim().is_empty() + || capability.purpose.trim().is_empty() + || capability.data_boundary.trim().is_empty() + || capability.next_step.trim().is_empty() + { + return Err(format!("Capability '{}' is incomplete", capability.id)); + } + for projection in [ + &capability.surfaces.ui, + &capability.surfaces.cli, + &capability.surfaces.agent, + ] { + let visible = matches!( + projection.availability, + Availability::Available | Availability::Building + ); + if visible && projection.entrypoints.is_empty() { + return Err(format!( + "Capability '{}' has a visible surface without an entrypoint", + capability.id + )); + } + if !visible && projection.authority != Authority::None { + return Err(format!( + "Capability '{}' grants authority on an unavailable surface", + capability.id + )); + } + } + } + Ok(()) +} + +fn projection( + availability: Availability, + authority: Authority, + entrypoints: &[&str], +) -> SurfaceProjection { + SurfaceProjection { + availability, + authority, + entrypoints: entrypoints + .iter() + .map(|value| (*value).to_string()) + .collect(), + } +} + +fn surfaces( + ui: SurfaceProjection, + cli: SurfaceProjection, + agent: SurfaceProjection, +) -> SurfaceMatrix { + SurfaceMatrix { ui, cli, agent } +} + +fn tool(name: &str, role: &str, requirement: &str) -> UnderlyingTool { + UnderlyingTool { + name: name.to_string(), + role: role.to_string(), + requirement: requirement.to_string(), + } +} + +#[allow(clippy::too_many_arguments)] +fn capability( + id: &str, + name: &str, + purpose: &str, + stage: CapabilityStage, + surfaces: SurfaceMatrix, + underlying_tools: &[UnderlyingTool], + data_boundary: &str, + qualification: Qualification, + limitations: &[&str], + next_step: &str, +) -> Capability { + Capability { + id: id.to_string(), + name: name.to_string(), + purpose: purpose.to_string(), + stage, + surfaces, + underlying_tools: underlying_tools.to_vec(), + data_boundary: data_boundary.to_string(), + qualification, + limitations: limitations + .iter() + .map(|value| (*value).to_string()) + .collect(), + next_step: next_step.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_registry_is_complete_and_duplicate_safe() { + let registry = capability_registry(); + validate_registry(®istry).expect("valid registry"); + assert!(registry + .capabilities + .iter() + .any(|capability| capability.stage == CapabilityStage::Future)); + assert!(registry + .capabilities + .iter() + .all(|capability| !capability.underlying_tools.is_empty())); + } + + #[test] + fn schema_and_payload_share_the_exact_version() { + let schema = capability_registry_schema(); + assert_eq!( + schema["properties"]["schema_version"]["const"], + CAPABILITY_SCHEMA_VERSION + ); + assert_eq!( + capability_registry().schema_version, + CAPABILITY_SCHEMA_VERSION + ); + } + + #[test] + fn external_collectors_keep_surface_authority_explicit() { + let registry = capability_registry(); + let collectors = registry + .capabilities + .iter() + .find(|capability| capability.id == "evidence.tool_collectors") + .expect("external collector capability"); + + assert_eq!(collectors.surfaces.ui.availability, Availability::Planned); + assert_eq!(collectors.surfaces.ui.authority, Authority::None); + assert!(collectors.surfaces.ui.entrypoints.is_empty()); + assert_eq!(collectors.surfaces.cli.authority, Authority::ReadExecute); + assert_eq!(collectors.surfaces.agent.authority, Authority::None); + } +} diff --git a/apps/desktop/src-tauri/src/codevetter_cli_tests.rs b/apps/desktop/src-tauri/src/codevetter_cli_tests.rs new file mode 100644 index 00000000..05ab681e --- /dev/null +++ b/apps/desktop/src-tauri/src/codevetter_cli_tests.rs @@ -0,0 +1,1972 @@ +use super::*; +use codevetter_desktop::commands::local_check::{LocalCheckStage, LocalCheckStages}; +use codevetter_desktop::commands::synthetic_qa::{SyntheticQaRunResult, SyntheticQaTrace}; +use codevetter_desktop::commands::trex_preview::{ + TrexPreviewIdentity, TrexPreviewIdentityStatus, TrexPreviewRoute, TrexSourceReceipt, +}; + +const SURFACE_PARITY_FIXTURE: &str = + include_str!("../tests/fixtures/surface-parity/evidence-scope-v1.json"); +const LOCAL_CHECK_PARITY_FIXTURE: &str = + include_str!("../tests/fixtures/surface-parity/local-check-v1.json"); + +fn surface_parity_fixture() -> serde_json::Value { + serde_json::from_str(SURFACE_PARITY_FIXTURE).expect("surface parity fixture") +} + +fn local_check_parity_fixture() -> serde_json::Value { + serde_json::from_str(LOCAL_CHECK_PARITY_FIXTURE).expect("local-check parity fixture") +} + +fn fixture_receipt(verdict: TrexPreviewVerdict) -> TrexPreviewReceipt { + TrexPreviewReceipt { + schema_version: 1, + run_id: "trex-preview-cli-fixture".into(), + repo_path: "/tmp/widget".into(), + source: TrexSourceReceipt { + kind: TrexChangeKind::Range, + input: "main..HEAD".into(), + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + commits: vec!["b".repeat(40)], + changed_paths: vec!["src/pages/index.tsx".into()], + }, + preview: TrexPreviewIdentity { + status: TrexPreviewIdentityStatus::Claimed, + requested_url: "https://preview.example.com".into(), + final_url: "https://preview.example.com".into(), + revision: None, + evidence: "No supported revision header was returned.".into(), + }, + routes: vec![TrexPreviewRoute { + route: "/".into(), + reason: "Required root smoke".into(), + goal: None, + }], + journeys: vec![SyntheticQaRunResult { + loop_id: "generic-page-smoke".into(), + route: "/".into(), + goal: "smoke".into(), + pass: verdict != TrexPreviewVerdict::Failed, + notes: "fixture journey".into(), + screenshot_path: None, + artifacts: Vec::new(), + duration_ms: 12, + trace: SyntheticQaTrace { + final_url: "https://preview.example.com/".into(), + page_title: "Preview".into(), + console_errors: Vec::new(), + stage_timings_ms: Default::default(), + runner_rss_bytes: None, + }, + error: None, + runner_type: Some("chromiumoxide_builtin".into()), + }], + verdict, + summary: "Fixture summary.".into(), + limitations: vec!["Preview identity is claimed.".into()], + duration_ms: 42, + ran_at: "2026-07-29T00:00:00Z".into(), + } +} + +fn fixture_local_check(verdict: LocalCheckVerdict) -> LocalCheckReceipt { + let stage = |status| LocalCheckStage { + status, + duration_ms: 12, + target: None, + evidence: serde_json::json!({}), + limitations: Vec::new(), + }; + LocalCheckReceipt { + schema_version: "codevetter.local-check/v1".into(), + request_id: None, + run_id: "local-check-fixture".into(), + ran_at: "2026-08-24T00:00:00Z".into(), + repo_path: "/tmp/widget".into(), + task: "Preserve behavior".into(), + standards_pack: Some("product-safety".into()), + source: TrexSourceReceipt { + kind: TrexChangeKind::Range, + input: "main...HEAD".into(), + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + commits: vec!["b".repeat(40)], + changed_paths: vec!["src/parser.ts".into()], + }, + stages: LocalCheckStages { + review: stage(LocalCheckStatus::Completed), + correctness: stage(LocalCheckStatus::Passed), + performance: stage(LocalCheckStatus::Completed), + optimization: LocalCheckStage { + status: LocalCheckStatus::Ready, + duration_ms: 0, + target: None, + evidence: serde_json::json!({"candidate_command": "codevetter check --repo "}), + limitations: vec!["Candidate edits remain external.".into()], + }, + }, + spec_coverage: None, + verdict, + limitations: vec!["Candidate edits remain external.".into()], + } +} + +#[test] +fn parser_defaults_to_current_repo_and_requires_one_source() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Trex(arguments) = parse_arguments( + [ + "trex".into(), + "--range".into(), + "main..HEAD".into(), + "--preview".into(), + "https://preview.example.com".into(), + "--route".into(), + "/checkout".into(), + "--journey-goal".into(), + "Complete checkout".into(), + ], + cwd, + ) + .expect("arguments") else { + panic!("expected trex"); + }; + assert_eq!(arguments.repo_path, cwd); + assert_eq!(arguments.change_kind, TrexChangeKind::Range); + assert_eq!(arguments.target_route.as_deref(), Some("/checkout")); + assert_eq!(arguments.target_goal.as_deref(), Some("Complete checkout")); + assert_eq!(arguments.output, OutputMode::Human); + + assert!(parse_arguments( + [ + "trex".into(), + "--pr".into(), + "https://github.com/acme/widget/pull/1".into(), + "--range".into(), + "main..HEAD".into(), + "--preview".into(), + "https://preview.example.com".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "trex".into(), + "--preview".into(), + "https://preview.example.com".into(), + ], + cwd, + ) + .is_err()); + + let CliCommand::Unpack(compare) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "compare".into(), + "--repo".into(), + "/tmp/widget".into(), + "--base-commit".into(), + "1".repeat(40), + "--head-commit".into(), + "2".repeat(40), + "--json".into(), + ], + cwd, + ) + .expect("compare arguments") else { + panic!("expected unpack compare"); + }; + assert_eq!(compare.operation, UnpackOperation::Compare); + assert_eq!( + compare.base_commit.as_deref(), + Some("1111111111111111111111111111111111111111") + ); + assert_eq!( + compare.head_commit.as_deref(), + Some("2222222222222222222222222222222222222222") + ); + + let CliCommand::Unpack(export) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "export".into(), + "--report-id".into(), + "snapshot-1".into(), + "--format".into(), + "repo_memory_markdown".into(), + "--json".into(), + ], + cwd, + ) + .expect("export arguments") else { + panic!("expected unpack export"); + }; + assert_eq!(export.operation, UnpackOperation::Export); + assert_eq!(export.report_id.as_deref(), Some("snapshot-1")); + assert_eq!(export.format.as_deref(), Some("repo_memory_markdown")); + assert!(parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "export".into(), + "--report-id".into(), + "snapshot-1".into(), + "--format".into(), + "pdf".into(), + ], + cwd, + ) + .is_err()); + + assert!(parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "retry".into(), + "--pr-number".into(), + "42".into(), + ], + cwd, + ) + .is_err()); + let CliCommand::Watcher(retry) = parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "retry".into(), + "--pr-number".into(), + "42".into(), + "--confirm-run".into(), + ], + cwd, + ) + .expect("confirmed watcher retry") else { + panic!("expected watcher"); + }; + assert_eq!(retry.pr_number, Some(42)); + assert!(retry.confirm_run); +} + +#[test] +fn qa_parser_preserves_inspect_and_safe_workflow_fields() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Qa(inspect) = + parse_arguments(["qa".into(), "--json".into()], cwd).expect("qa inspect") + else { + panic!("expected qa"); + }; + assert_eq!(inspect.operation, QaOperation::Inspect); + assert_eq!(inspect.repo_path, cwd); + + let CliCommand::Qa(save) = parse_arguments( + [ + "qa".into(), + "--operation".into(), + "save-workflow".into(), + "--workflow-id".into(), + "checkout".into(), + "--workflow-name".into(), + "Checkout".into(), + "--loop-id".into(), + "checkout".into(), + "--runner".into(), + "repo_playwright".into(), + "--goal".into(), + "Complete checkout".into(), + "--target-route".into(), + "/checkout".into(), + ], + cwd, + ) + .expect("qa save") else { + panic!("expected qa"); + }; + assert_eq!(save.operation, QaOperation::SaveWorkflow); + assert_eq!(save.workflow_id.as_deref(), Some("checkout")); + assert!(parse_arguments( + [ + "qa".into(), + "--operation".into(), + "save-workflow".into(), + "--workflow-id".into(), + "incomplete".into(), + ], + cwd, + ) + .is_err()); + + let CliCommand::Unpack(query) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query".into(), + "--repo".into(), + "/tmp/widget".into(), + "--query-domain".into(), + "graph".into(), + "--query".into(), + "verification service".into(), + "--limit".into(), + "24".into(), + "--json".into(), + ], + cwd, + ) + .expect("query arguments") else { + panic!("expected unpack query"); + }; + assert_eq!(query.operation, UnpackOperation::Query); + assert_eq!(query.query_domain, Some(RepositoryQueryDomain::Graph)); + assert_eq!(query.query_mode, RepositoryQueryMode::Search); + assert_eq!(query.query.as_deref(), Some("verification service")); + assert_eq!(query.limit, 24); + let CliCommand::Unpack(path_query) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query".into(), + "--repo".into(), + "/tmp/widget".into(), + "--query-domain".into(), + "graph".into(), + "--query-mode".into(), + "path".into(), + "--query".into(), + "node:start".into(), + "--query-target".into(), + "node:end".into(), + "--json".into(), + ], + cwd, + ) + .expect("path query arguments") else { + panic!("expected unpack path query"); + }; + assert_eq!(path_query.query_mode, RepositoryQueryMode::Path); + assert_eq!(path_query.query_target.as_deref(), Some("node:end")); + let CliCommand::Unpack(trace_query) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query".into(), + "--repo".into(), + "/tmp/widget".into(), + "--query-domain".into(), + "history".into(), + "--query-mode".into(), + "trace".into(), + "--history-selector".into(), + "event".into(), + "--query".into(), + "event:verification".into(), + "--json".into(), + ], + cwd, + ) + .expect("trace query arguments") else { + panic!("expected unpack trace query"); + }; + assert_eq!(trace_query.query_mode, RepositoryQueryMode::Trace); + assert_eq!( + trace_query.history_selector, + Some(RepositoryHistorySelectorKind::Event) + ); + assert!(parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query".into(), + "--repo".into(), + "/tmp/widget".into(), + "--query".into(), + "missing domain".into(), + ], + cwd, + ) + .is_err()); + + let CliCommand::Unpack(worker) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "query-worker".into(), + "--json".into(), + ], + cwd, + ) + .expect("query worker arguments") else { + panic!("expected unpack query worker"); + }; + assert_eq!(worker.operation, UnpackOperation::QueryWorker); + assert!(parse_arguments( + ["unpack".into(), "--operation".into(), "query-worker".into(),], + cwd, + ) + .is_err()); +} + +#[test] +fn warm_parser_preserves_lifecycle_run_and_cleanup_authority() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Warm(run) = parse_arguments( + [ + "warm".into(), + "--operation".into(), + "run".into(), + "--run-id".into(), + "warm-native-1".into(), + "--detailed".into(), + "--json".into(), + ], + cwd, + ) + .expect("warm run") else { + panic!("expected warm command"); + }; + assert_eq!(run.repo_path, cwd); + assert_eq!(run.operation, WarmOperation::Run); + assert_eq!(run.run_id.as_deref(), Some("warm-native-1")); + assert!(run.detailed); + assert_eq!(run.output, OutputMode::Json); + + let CliCommand::Warm(cleanup) = parse_arguments( + [ + "warm".into(), + "--operation".into(), + "cleanup".into(), + "--dry-run".into(), + ], + cwd, + ) + .expect("warm cleanup preview") else { + panic!("expected warm cleanup"); + }; + assert!(cleanup.dry_run); + assert!( + parse_arguments(["warm".into(), "--operation".into(), "cleanup".into()], cwd,).is_err() + ); + assert!(parse_arguments( + [ + "warm".into(), + "--operation".into(), + "cleanup".into(), + "--dry-run".into(), + "--apply-cleanup".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn differential_parser_preserves_exact_pair_and_cleanup_authority() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Differential(arguments) = parse_arguments( + [ + "differential".into(), + "--operation".into(), + "run".into(), + "--run-id".into(), + "diff-native-1".into(), + "--reference".into(), + "main".into(), + "--candidate".into(), + "range".into(), + "--revision".into(), + "main...HEAD".into(), + "--json".into(), + ], + cwd, + ) + .expect("differential run") else { + panic!("expected differential command"); + }; + assert_eq!(arguments.operation, DifferentialOperation::Run); + assert_eq!(arguments.reference.as_deref(), Some("main")); + assert_eq!(arguments.candidate_kind.as_deref(), Some("range")); + assert_eq!(arguments.candidate_revision.as_deref(), Some("main...HEAD")); + assert!(parse_arguments( + [ + "differential".into(), + "--operation".into(), + "run".into(), + "--run-id".into(), + "diff-1".into(), + "--reference".into(), + "main".into(), + "--candidate".into(), + "range".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "differential".into(), + "--operation".into(), + "cleanup".into(), + "--dry-run".into(), + ], + cwd, + ) + .is_ok()); +} + +#[test] +fn scenario_parser_separates_generation_dry_run_and_file_acceptance() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Scenario(generate) = parse_arguments( + [ + "scenario".into(), + "--operation".into(), + "generate".into(), + "--spec".into(), + "docs/checkout.md".into(), + "--model".into(), + "qwen2.5-coder:7b".into(), + "--route".into(), + "/checkout".into(), + "--request-policy".into(), + "--json".into(), + ], + cwd, + ) + .expect("scenario generate") else { + panic!("expected scenario"); + }; + let ScenarioCompilerAction::Generate { + provider, context, .. + } = generate.action + else { + panic!("expected generate"); + }; + assert_eq!(provider.provider, "local"); + assert_eq!(context.routes, ["/checkout"]); + assert!(context.include_request_policy); + + let CliCommand::Scenario(accept) = parse_arguments( + [ + "scenario".into(), + "--operation".into(), + "accept".into(), + "--candidate-id".into(), + "candidate-1".into(), + "--candidate-hash".into(), + "a".repeat(64), + "--destination".into(), + ".codevetter/scenarios/checkout.yaml".into(), + "--approve-replacements".into(), + ], + cwd, + ) + .expect("scenario accept") else { + panic!("expected scenario"); + }; + let ScenarioCompilerAction::Accept { + selected_destinations, + approve_replacements, + .. + } = accept.action + else { + panic!("expected accept"); + }; + assert_eq!( + selected_destinations, + [".codevetter/scenarios/checkout.yaml"] + ); + assert!(approve_replacements); + assert!(parse_arguments( + ["scenario".into(), "--operation".into(), "cleanup".into()], + cwd, + ) + .is_err()); +} + +#[test] +fn watcher_parser_separates_configuration_from_confirmed_execution() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Watcher(enable) = parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "enable".into(), + "--interval-secs".into(), + "120".into(), + "--base-branch".into(), + "main".into(), + "--json".into(), + ], + cwd, + ) + .expect("watcher enable") else { + panic!("expected watcher"); + }; + assert_eq!(enable.repo_path.as_deref(), Some(cwd)); + assert_eq!(enable.interval_secs, Some(120)); + assert_eq!(enable.base_branch.as_deref(), Some("main")); + assert!(!enable.confirm_run); + assert_eq!(enable.output, OutputMode::Json); + + assert!( + parse_arguments(["watcher".into(), "--operation".into(), "poll".into()], cwd,).is_err() + ); + let CliCommand::Watcher(poll) = parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "poll".into(), + "--confirm-run".into(), + ], + cwd, + ) + .expect("confirmed watcher poll") else { + panic!("expected watcher"); + }; + assert!(poll.confirm_run); + assert!(parse_arguments( + [ + "watcher".into(), + "--operation".into(), + "disable".into(), + "--confirm-run".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn parser_preserves_explicit_repo_pr_and_json_mode() { + let CliCommand::Trex(arguments) = parse_arguments( + [ + "trex".into(), + "--repo".into(), + "/tmp/other".into(), + "--pr".into(), + "https://github.com/acme/widget/pull/42".into(), + "--preview".into(), + "https://preview.example.com".into(), + "--json".into(), + ], + Path::new("/tmp/widget"), + ) + .expect("arguments") else { + panic!("expected trex"); + }; + assert_eq!(arguments.repo_path, Path::new("/tmp/other")); + assert_eq!(arguments.change_kind, TrexChangeKind::PullRequest); + assert_eq!(arguments.output, OutputMode::Json); +} + +#[test] +fn capabilities_parser_and_human_output_share_the_registry() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["capabilities".into(), "--json".into()], cwd).expect("capabilities"), + CliCommand::Capabilities(OutputMode::Json) + )); + assert!(matches!( + parse_arguments(["capabilities".into(), "--schema".into()], cwd) + .expect("capability schema"), + CliCommand::CapabilitySchema + )); + assert!(parse_arguments(["capabilities".into(), "--unknown".into()], cwd).is_err()); + + let output = render_human_capabilities(&capability_registry()); + assert!(output.contains("verification.local_check")); + assert!(output.contains("native.evidence_workbench")); + assert!(output.contains("UI: building | CLI: unavailable | agent: unavailable")); +} + +#[test] +fn runs_parser_and_human_output_are_bounded() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Runs(arguments) = parse_arguments( + [ + "runs".into(), + "--repo".into(), + "/tmp/widget".into(), + "--limit".into(), + "7".into(), + "--json".into(), + ], + cwd, + ) + .expect("runs arguments") else { + panic!("expected runs"); + }; + assert_eq!(arguments.repo_path, Some(PathBuf::from("/tmp/widget"))); + assert_eq!(arguments.limit, 7); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["runs".into(), "--limit".into(), "101".into()], cwd).is_err()); + + let receipt = fixture_local_check(LocalCheckVerdict::PassedWithLimits); + let history = RunHistoryReceipt { + schema_version: "codevetter.run-history/v1".into(), + generated_at: "2026-08-31T00:00:00Z".into(), + repo_path: None, + limit: 7, + returned: 1, + runs: vec![ + codevetter_desktop::commands::run_history::RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: receipt.run_id.clone(), + kind: codevetter_desktop::commands::run_history::RunKind::LocalCheck, + repo_path: Some(receipt.repo_path.clone()), + recorded_at: receipt.ran_at.clone(), + title: receipt.task.clone(), + outcome: "passed_with_limits".into(), + receipt_schema: receipt.schema_version.clone(), + source_label: Some("bbbbbbbbbbbb".into()), + limitations: receipt.limitations.clone(), + receipt: serde_json::to_value(receipt).expect("receipt JSON"), + }, + ], + }; + let output = render_human_runs(&history); + assert!(output.contains("passed_with_limits")); + assert!(output.contains("bbbbbbbbbbbb")); + assert!(output.contains("LocalCheck")); +} + +#[test] +fn usage_parser_and_human_output_preserve_provider_boundaries() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Usage(arguments) = parse_arguments( + [ + "usage".into(), + "--timezone".into(), + "Asia/Kolkata".into(), + "--refresh".into(), + "--json".into(), + ], + cwd, + ) + .expect("usage arguments") else { + panic!("expected usage"); + }; + assert_eq!(arguments.timezone.as_deref(), Some("Asia/Kolkata")); + assert!(arguments.refresh); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["usage".into(), "--unknown".into()], cwd).is_err()); + + let report: LocalUsageReport = serde_json::from_value(serde_json::json!({ + "status": "ready", + "stale": false, + "error": null, + "provenance": { + "engine": "ccusage", + "version": "20.0.20", + "generated_at": "2026-08-31T00:00:00Z", + "timezone": "Asia/Kolkata", + "window": "all", + "detected_agents": ["claude", "codex", "grok"], + "excluded_agents": ["devin"], + "codex_roots": ["/tmp/codex"], + "source_fingerprint": "sha256:fixture", + "pricing_complete": true, + "fallback_models": [], + "unpriced_models": [] + }, + "daily": [], + "weekly": [], + "monthly": [], + "sessions": [], + "totals": { + "input_tokens": 100, + "cache_creation_tokens": 20, + "cache_read_tokens": 300, + "output_tokens": 40, + "total_tokens": 460, + "cost_usd": 1.25 + }, + "devin": { + "status": "ready", + "source": "CodeVetter SQLite", + "sessions": 3, + "generated_tokens": 1200, + "cache_read_tokens": 400, + "output_tokens": 100, + "cost_usd": 0.52, + "models": [], + "windows": [{ + "window": "1w", + "since": "2026-08-26", + "sessions": 2, + "generated_tokens": 800, + "cache_read_tokens": 250, + "cost_usd": 0.31, + "models": [] + }], + "limitations": ["Devin remains separate from ccusage totals."] + } + })) + .expect("usage fixture"); + let output = render_human_usage(&report); + assert!(output.contains("Local usage · ccusage 20.0.20 · Asia/Kolkata")); + assert!(output.contains("tokens: 460 total · 160 generated · 300 cache read")); + assert!(output.contains("Devin and live provider quotas are separate")); + assert!(output.contains("devin windows: 1w 2 sessions / 800 generated / $0.31")); + assert!(output.contains("excluded: devin")); +} + +#[test] +fn ops_parser_and_human_output_preserve_read_only_secret_boundary() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Ops(arguments) = parse_arguments( + [ + "ops".into(), + "--window-days".into(), + "90".into(), + "--json".into(), + ], + cwd, + ) + .expect("Ops arguments") else { + panic!("expected Ops"); + }; + assert_eq!(arguments.window_days, 90); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["ops".into(), "--window-days".into(), "365".into()], cwd).is_err()); + + let receipt = OpsStatusReceipt { + schema_version: "codevetter.ops-status/v1".into(), + generated_at: "2026-09-02T00:00:00Z".into(), + database_available: true, + window_days: 90, + billing: OpsBillingStatus { + anthropic_configured: true, + openai_configured: false, + }, + webhook: OpsWebhookStatus { + configured: true, + flavor: "slack".into(), + }, + observability: Vec::new(), + excluded_sensitive_keys: vec!["anthropic_admin_key".into()], + limitations: vec!["read only".into()], + }; + let output = render_human_ops(&receipt); + assert!(output.contains("Ops · 90 days")); + assert!(output.contains("billing and webhook configuration: excluded")); + assert!(!output.contains("Anthropic")); + assert!(!output.contains("slack")); + assert!(output.contains("credentials and endpoint values: excluded")); +} + +#[test] +fn unpack_parser_bounds_history_inspection_and_explicit_scan_authority() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Unpack(arguments) = parse_arguments( + [ + "unpack".into(), + "--repo".into(), + "/tmp/widget".into(), + "--limit".into(), + "25".into(), + "--json".into(), + ], + cwd, + ) + .expect("unpack arguments") else { + panic!("expected unpack"); + }; + assert_eq!(arguments.operation, UnpackOperation::List); + assert_eq!(arguments.repo_path.as_deref(), Some("/tmp/widget")); + assert_eq!(arguments.limit, 25); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["unpack".into(), "--limit".into(), "101".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "unpack".into(), + "--repo".into(), + "/tmp/widget".into(), + "--report-id".into(), + "snapshot-1".into(), + ], + cwd, + ) + .is_err()); + + let CliCommand::Unpack(scan) = parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "scan".into(), + "--repo".into(), + "/tmp/widget".into(), + "--json".into(), + ], + cwd, + ) + .expect("scan arguments") else { + panic!("expected unpack scan"); + }; + assert_eq!(scan.operation, UnpackOperation::Scan); + assert_eq!(scan.repo_path.as_deref(), Some("/tmp/widget")); + assert!(scan.report_id.is_none()); + assert!(parse_arguments( + [ + "unpack".into(), + "--operation".into(), + "scan".into(), + "--repo".into(), + "/tmp/widget".into(), + "--limit".into(), + "1".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn settings_parser_preserves_one_explicit_non_secret_assignment() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Settings(arguments) = parse_arguments( + [ + "settings".into(), + "--set".into(), + "review_tone=strict".into(), + "--json".into(), + ], + cwd, + ) + .expect("settings arguments") else { + panic!("expected settings"); + }; + assert_eq!(arguments.set, Some(("review_tone".into(), "strict".into()))); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["settings".into(), "--set".into(), "missing".into()], cwd,).is_err()); + assert!(parse_arguments( + [ + "settings".into(), + "--set".into(), + "review_tone=strict".into(), + "--set".into(), + "compact_mode=true".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn history_roots_parser_separates_read_add_and_remove() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["history-roots".into(), "--json".into()], cwd).expect("read roots"), + CliCommand::HistoryRoots(HistoryRootsArguments { + operation: HistoryRootsOperation::Read, + path: None, + output: OutputMode::Json, + }) + )); + + let CliCommand::HistoryRoots(add) = parse_arguments( + [ + "history-roots".into(), + "--add".into(), + "/tmp/codex".into(), + "--json".into(), + ], + cwd, + ) + .expect("add root") else { + panic!("expected history-roots add"); + }; + assert_eq!(add.operation, HistoryRootsOperation::Add); + assert_eq!(add.path, Some(PathBuf::from("/tmp/codex"))); + + assert!(parse_arguments( + [ + "history-roots".into(), + "--add".into(), + "/tmp/one".into(), + "--remove".into(), + "/tmp/two".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn memories_parser_separates_list_read_and_redacted_diff() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["memories".into(), "--json".into()], cwd).expect("list memories"), + CliCommand::Memories(MemoriesArguments { + source_id: None, + diff: false, + output: OutputMode::Json, + }) + )); + + let CliCommand::Memories(read) = parse_arguments( + [ + "memories".into(), + "--source".into(), + "memory:sha256:fixture".into(), + "--json".into(), + ], + cwd, + ) + .expect("read memory") else { + panic!("expected memories read"); + }; + assert_eq!(read.source_id.as_deref(), Some("memory:sha256:fixture")); + assert!(!read.diff); + + let CliCommand::Memories(diff) = parse_arguments( + [ + "memories".into(), + "--source".into(), + "memory:sha256:fixture".into(), + "--diff".into(), + ], + cwd, + ) + .expect("memory diff") else { + panic!("expected memories diff"); + }; + assert!(diff.diff); + assert!(parse_arguments(["memories".into(), "--diff".into()], cwd).is_err()); +} + +#[test] +fn onboarding_parser_separates_inspection_from_explicit_completion() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["onboarding".into(), "--json".into()], cwd).expect("inspect onboarding"), + CliCommand::Onboarding(OnboardingArguments { + complete: false, + default_adapter: None, + output: OutputMode::Json, + }) + )); + + let CliCommand::Onboarding(arguments) = parse_arguments( + [ + "onboarding".into(), + "--complete".into(), + "--default-adapter".into(), + "codex".into(), + "--json".into(), + ], + cwd, + ) + .expect("complete onboarding") else { + panic!("expected onboarding"); + }; + assert!(arguments.complete); + assert_eq!(arguments.default_adapter.as_deref(), Some("codex")); + assert!(parse_arguments(["onboarding".into(), "--complete".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "onboarding".into(), + "--complete".into(), + "--default-adapter".into(), + "unknown".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn mcp_parser_requires_one_repository_and_at_most_one_authority_change() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Mcp(arguments) = parse_arguments( + [ + "mcp".into(), + "--repo".into(), + "/tmp/widget".into(), + "--enable".into(), + "--json".into(), + ], + cwd, + ) + .expect("mcp arguments") else { + panic!("expected mcp"); + }; + assert_eq!(arguments.repo_path, Path::new("/tmp/widget")); + assert_eq!(arguments.operation, McpSettingsOperation::Enable); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["mcp".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "mcp".into(), + "--repo".into(), + "/tmp/widget".into(), + "--enable".into(), + "--disable".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn retention_parser_separates_preview_apply_and_checkpoint_authority() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Retention(preview) = parse_arguments( + [ + "retention".into(), + "--max-age-days".into(), + "90".into(), + "--max-archive-mib".into(), + "2048".into(), + "--json".into(), + ], + cwd, + ) + .expect("retention preview") else { + panic!("expected retention"); + }; + assert_eq!(preview.operation, SessionRetentionOperation::Plan); + assert_eq!(preview.max_age_days, Some(90)); + assert_eq!(preview.max_archive_mib, Some(2048)); + assert_eq!(preview.output, OutputMode::Json); + + let CliCommand::Retention(apply) = parse_arguments( + [ + "retention".into(), + "--apply".into(), + "retention-plan:abc".into(), + ], + cwd, + ) + .expect("retention apply") else { + panic!("expected retention"); + }; + assert_eq!(apply.operation, SessionRetentionOperation::Apply); + assert_eq!(apply.plan_id.as_deref(), Some("retention-plan:abc")); + + assert!(parse_arguments(["retention".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "retention".into(), + "--checkpoint".into(), + "--apply".into(), + "retention-plan:abc".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments(["retention".into(), "--vacuum".into()], cwd).is_err()); +} + +#[test] +fn rubrics_parser_separates_read_select_and_validated_custom_pack_input() { + let cwd = Path::new("/tmp/widget"); + assert!(matches!( + parse_arguments(["rubrics".into(), "--json".into()], cwd).expect("read rubrics"), + CliCommand::Rubrics(RubricsArguments { + select: None, + upsert: None, + output: OutputMode::Json, + }) + )); + + let CliCommand::Rubrics(arguments) = parse_arguments( + [ + "rubrics".into(), + "--id".into(), + "performance-proof".into(), + "--name".into(), + "Performance Proof".into(), + "--focus".into(), + "Measured regressions".into(), + "--check".into(), + "Require a baseline".into(), + "--check".into(), + "Reject unsupported claims".into(), + ], + cwd, + ) + .expect("custom rubric") else { + panic!("expected rubrics"); + }; + let pack = arguments.upsert.expect("upsert"); + assert_eq!(pack.id, "performance-proof"); + assert_eq!(pack.checks.len(), 2); + + assert!(parse_arguments( + [ + "rubrics".into(), + "--select".into(), + "product-safety".into(), + "--id".into(), + "invalid".into(), + ], + cwd, + ) + .is_err()); + assert!( + parse_arguments(["rubrics".into(), "--id".into(), "incomplete".into(),], cwd,).is_err() + ); +} + +#[test] +fn xray_parser_preserves_public_confirmation_excerpt_and_save_identity() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Xray(arguments) = parse_arguments( + [ + "xray".into(), + "--review-id".into(), + "review-7".into(), + "--public-source".into(), + "owner/repo#7".into(), + "--confirm-public".into(), + "--approve-excerpt".into(), + "finding-1".into(), + "--format".into(), + "markdown".into(), + "--save".into(), + "/tmp/xray.md".into(), + "--json".into(), + ], + cwd, + ) + .expect("xray arguments") else { + panic!("expected xray"); + }; + assert_eq!(arguments.request.review_id, "review-7"); + assert_eq!( + arguments.request.public_source.as_deref(), + Some("owner/repo#7") + ); + assert!(arguments.request.public_source_confirmed); + assert_eq!( + arguments.request.approved_excerpt_finding_ids, + vec!["finding-1"] + ); + assert!(matches!(arguments.format, XrayFormat::Markdown)); + assert_eq!(arguments.save_path.as_deref(), Some("/tmp/xray.md")); + assert_eq!(arguments.output, OutputMode::Json); + + assert!(parse_arguments(["xray".into()], cwd).is_err()); + assert!(parse_arguments( + [ + "xray".into(), + "--review-id".into(), + "review-7".into(), + "--format".into(), + "pdf".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn fix_packet_parser_preserves_bounded_finding_selection() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::FixPacket(arguments) = parse_arguments( + [ + "fix-packet".into(), + "--run-id".into(), + "local-check-7".into(), + "--finding".into(), + "finding-2".into(), + "--finding".into(), + "finding-1".into(), + "--json".into(), + ], + cwd, + ) + .expect("fix packet arguments") else { + panic!("expected fix packet"); + }; + assert_eq!(arguments.run_id, "local-check-7"); + assert_eq!(arguments.finding_ids, vec!["finding-2", "finding-1"]); + assert_eq!(arguments.output, OutputMode::Json); + assert!(parse_arguments(["fix-packet".into()], cwd).is_err()); +} + +#[test] +fn fix_parser_separates_execute_inspect_and_confirmed_discard() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Fix(execute) = parse_arguments( + [ + "fix".into(), + "--operation".into(), + "execute".into(), + "--run-id".into(), + "local-check-7".into(), + "--finding".into(), + "finding-2".into(), + "--agent".into(), + "claude".into(), + "--confirm-run".into(), + "--timeout-ms".into(), + "45000".into(), + "--json".into(), + ], + cwd, + ) + .expect("fix execute arguments") else { + panic!("expected fix command"); + }; + assert_eq!(execute.operation, FixOperation::Execute); + assert_eq!(execute.run_id.as_deref(), Some("local-check-7")); + assert_eq!(execute.finding_ids, vec!["finding-2"]); + assert_eq!(execute.agent, "claude"); + assert!(execute.confirm_run); + assert_eq!(execute.timeout_ms, 45_000); + assert_eq!(execute.output, OutputMode::Json); + + let CliCommand::Fix(inspect) = parse_arguments( + [ + "fix".into(), + "--operation".into(), + "inspect".into(), + "--attempt-id".into(), + "fix-attempt-abc123".into(), + ], + cwd, + ) + .expect("fix inspect arguments") else { + panic!("expected fix command"); + }; + assert_eq!(inspect.operation, FixOperation::Inspect); + assert_eq!(inspect.attempt_id.as_deref(), Some("fix-attempt-abc123")); + + let CliCommand::Fix(discard) = parse_arguments( + [ + "fix".into(), + "--operation".into(), + "discard".into(), + "--attempt-id".into(), + "fix-attempt-abc123".into(), + "--confirm-discard".into(), + ], + cwd, + ) + .expect("fix discard arguments") else { + panic!("expected fix command"); + }; + assert_eq!(discard.operation, FixOperation::Discard); + assert!(discard.confirm_discard); + + assert!(parse_arguments( + [ + "fix".into(), + "--operation".into(), + "execute".into(), + "--run-id".into(), + "local-check-7".into(), + "--finding".into(), + "finding-2".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "fix".into(), + "--operation".into(), + "discard".into(), + "--attempt-id".into(), + "fix-attempt-abc123".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn scope_parser_preserves_one_closed_consumer_and_scope_contract() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Scope(arguments) = parse_arguments( + [ + "scope".into(), + "--consumer".into(), + "performance".into(), + "--change".into(), + "main...HEAD".into(), + "--json".into(), + ], + cwd, + ) + .expect("scope arguments") else { + panic!("expected scope"); + }; + assert_eq!(arguments.input.repo_path, "/tmp/widget"); + assert_eq!(arguments.input.consumer, EvidenceScopeConsumer::Performance); + assert_eq!(arguments.input.kind, EvidenceScopeKind::Change); + assert_eq!(arguments.input.value.as_deref(), Some("main...HEAD")); + assert_eq!(arguments.output, OutputMode::Json); + + assert!(parse_arguments( + [ + "scope".into(), + "--consumer".into(), + "testing".into(), + "--flow".into(), + "checkout".into(), + "--codebase".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments(["scope".into(), "--codebase".into()], cwd,).is_err()); +} + +#[test] +fn scope_cli_projects_the_shared_surface_parity_fixture_without_schema_drift() { + let fixture = surface_parity_fixture(); + let request = &fixture["request"]; + let cwd = Path::new("/fixture/repo"); + let CliCommand::Scope(arguments) = parse_arguments( + [ + "scope".into(), + "--consumer".into(), + request["consumer"] + .as_str() + .expect("fixture consumer") + .into(), + "--repo".into(), + cwd.to_string_lossy().into_owned(), + "--flow".into(), + request["value"].as_str().expect("fixture value").into(), + "--json".into(), + ], + cwd, + ) + .expect("fixture CLI arguments") else { + panic!("expected fixture scope command"); + }; + assert_eq!(arguments.input.repo_path, "/fixture/repo"); + assert_eq!(arguments.input.consumer, EvidenceScopeConsumer::Performance); + assert_eq!(arguments.input.kind, EvidenceScopeKind::Flow); + assert_eq!(arguments.input.value.as_deref(), Some("coupon total")); + assert_eq!(arguments.output, OutputMode::Json); + assert_eq!(fixture["authority"]["cli"], "supervised_projection"); + + let receipt: EvidenceScopePlan = serde_json::from_value(fixture["canonical_receipt"].clone()) + .expect("fixture canonical receipt"); + let encoded = serde_json::to_string(&receipt).expect("serialize fixture receipt"); + let decoded: EvidenceScopePlan = + serde_json::from_str(&encoded).expect("decode CLI fixture receipt"); + assert_eq!( + decoded.schema_version, + fixture["expected"]["schema_version"] + ); + assert_eq!(decoded.status, fixture["expected"]["status"]); + assert_eq!( + decoded.candidates[0].target, + fixture["expected"]["first_candidate"]["target"] + ); + let human = render_human_scope(&decoded); + assert!(human.contains("Evidence scope · performance\nstatus: ready")); + assert!(human.contains("src/cart/coupon.test.ts")); +} + +#[test] +fn check_cli_preserves_the_shared_local_check_receipt_and_exit_semantics() { + let fixture = local_check_parity_fixture(); + let request = &fixture["request"]; + let CliCommand::Check(arguments) = parse_arguments( + [ + "check".into(), + "--request-id".into(), + request["request_id"].as_str().expect("request id").into(), + "--repo".into(), + request["repo_path"].as_str().expect("repository").into(), + "--range".into(), + request["change"].as_str().expect("change").into(), + "--task".into(), + request["task"].as_str().expect("task").into(), + "--json".into(), + ], + Path::new("/ignored"), + ) + .expect("local-check parity CLI arguments") else { + panic!("expected local-check fixture command"); + }; + assert_eq!(fixture["authority"]["cli"], "supervised_execution"); + assert_eq!(arguments.repo_path, Path::new("/fixture/repo")); + assert_eq!(arguments.change, "main...HEAD"); + assert_eq!(arguments.task, "Preserve checkout totals"); + assert_eq!( + arguments.request_id.as_deref(), + request["request_id"].as_str() + ); + + let receipt: LocalCheckReceipt = serde_json::from_value(fixture["canonical_receipt"].clone()) + .expect("fixture canonical local-check receipt"); + assert_eq!( + receipt.schema_version, + fixture["expected"]["receipt_schema"] + ); + assert_eq!( + receipt.request_id.as_deref(), + request["request_id"].as_str() + ); + assert_eq!(receipt.verdict, LocalCheckVerdict::NoConfidence); + assert_eq!( + local_check_exit_code(receipt.verdict), + fixture["expected"]["exit_code"] + .as_i64() + .expect("fixture exit code") as i32 + ); + let human = render_human_check(&receipt); + assert!(human.contains("verdict: no_confidence")); + assert!(human.contains( + fixture["expected"]["limitation"] + .as_str() + .expect("fixture limitation") + )); +} + +#[test] +fn performance_parser_and_human_output_preserve_the_closed_contract() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Performance(arguments) = parse_arguments( + [ + "performance".into(), + "--operation".into(), + "verify-paired".into(), + "--repo".into(), + "/tmp/candidate".into(), + "--baseline-repo".into(), + "/tmp/baseline".into(), + "--adapter".into(), + "go-bench".into(), + "--target".into(), + "bench/parser_test.go".into(), + "--name".into(), + "BenchmarkParser".into(), + "--samples".into(), + "5".into(), + "--warmups".into(), + "2".into(), + "--timeout-ms".into(), + "45000".into(), + "--request-id".into(), + "performance-fixture".into(), + "--json".into(), + ], + cwd, + ) + .expect("performance arguments") else { + panic!("expected performance"); + }; + assert_eq!( + arguments.input.operation, + PerformanceOperation::VerifyPaired + ); + assert_eq!(arguments.input.adapter, Some(PerformanceAdapter::GoBench)); + assert_eq!( + arguments.input.target.as_deref(), + Some("bench/parser_test.go") + ); + assert_eq!(arguments.input.samples, Some(5)); + assert_eq!(arguments.input.warmups, Some(2)); + assert_eq!(arguments.input.timeout_ms, Some(45_000)); + assert_eq!(arguments.input.request_id, "performance-fixture"); + assert_eq!(arguments.output, OutputMode::Json); + + assert!(parse_arguments( + ["performance".into(), "--operation".into(), "guess".into(),], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "performance".into(), + "--operation".into(), + "plan".into(), + "--adapter".into(), + "shell".into(), + ], + cwd, + ) + .is_err()); + + let receipt = PerformanceRunReceipt { + schema_version: 1, + request_id: "performance-fixture".into(), + operation: PerformanceOperation::Plan, + state: "succeeded".into(), + exit_code: Some(0), + duration_ms: 17, + result: serde_json::json!({ + "decision": { "status": "admitted" }, + "limitations": ["Exact fixture scope only."] + }), + stderr_summary: None, + cleanup: codevetter_desktop::commands::performance_bridge::PerformanceCleanupReceipt { + owned_process_reaped: true, + temporary_profiles_retained: false, + }, + resources: Default::default(), + }; + let output = render_human_performance(&receipt); + assert!(output.contains("operation: plan")); + assert!(output.contains("verdict: admitted")); + assert!(output.contains("Exact fixture scope only.")); +} + +#[test] +fn check_parser_supports_discovery_and_explicit_benchmark_targets() { + let CliCommand::Check(arguments) = parse_arguments( + [ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Reduce parser latency without changing output".into(), + "--request-id".into(), + "native-review-fixture".into(), + "--preflight".into(), + "--spec".into(), + "docs/parser.md".into(), + "--spec".into(), + "docs/architecture.md".into(), + "--requirement".into(), + "parser-output-stable".into(), + "--test-adapter".into(), + "node-test".into(), + "--test-target".into(), + "test/parser.test.mjs".into(), + "--perf-adapter".into(), + "node-test".into(), + "--perf-target".into(), + "test/parser.performance.test.mjs".into(), + "--samples".into(), + "5".into(), + "--json".into(), + ], + Path::new("/tmp/widget"), + ) + .expect("arguments") else { + panic!("expected check"); + }; + assert_eq!(arguments.repo_path, Path::new("/tmp/widget")); + assert_eq!(arguments.change, "main...HEAD"); + assert_eq!( + arguments.request_id.as_deref(), + Some("native-review-fixture") + ); + assert_eq!(arguments.review_agent, "claude"); + assert_eq!( + arguments.spec_paths, + vec![ + PathBuf::from("docs/parser.md"), + PathBuf::from("docs/architecture.md") + ] + ); + assert_eq!( + arguments.selected_requirement_ids, + vec!["parser-output-stable"] + ); + assert_eq!(arguments.samples, 5); + assert!(arguments.preflight); + assert!(!arguments.progress_json); + assert_eq!(arguments.output, OutputMode::Json); + assert_eq!( + arguments + .performance_target + .expect("performance target") + .target, + "test/parser.performance.test.mjs" + ); +} + +#[test] +fn check_parser_preserves_the_independent_cross_review_strategy() { + let CliCommand::Check(arguments) = parse_arguments( + [ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Reject incomplete composite reviews".into(), + "--agent".into(), + "cross".into(), + "--json".into(), + ], + Path::new("/tmp/widget"), + ) + .expect("arguments") else { + panic!("expected check"); + }; + assert_eq!(arguments.review_agent, "cross"); + assert_eq!(arguments.output, OutputMode::Json); +} + +#[test] +fn check_parser_bounds_machine_readable_progress_to_executable_json_checks() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Check(arguments) = parse_arguments( + [ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Prove the change".into(), + "--json".into(), + "--progress-json".into(), + ], + cwd, + ) + .expect("progress arguments") else { + panic!("expected check"); + }; + assert!(arguments.progress_json); + assert_eq!(arguments.output, OutputMode::Json); + + for invalid in [ + vec![ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Prove the change".into(), + "--progress-json".into(), + ], + vec![ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Prove the change".into(), + "--preflight".into(), + "--json".into(), + "--progress-json".into(), + ], + ] { + assert!(parse_arguments(invalid, cwd).is_err()); + } +} + +#[test] +fn machine_readable_progress_preserves_the_versioned_stderr_contract() { + let event = VerificationProgress { + schema_version: "codevetter.progress/v2".into(), + request_id: "native-review-fixture".into(), + sequence: 4, + stage: "correctness".into(), + state: "running".into(), + }; + let progress: serde_json::Value = + serde_json::from_str(&render_progress_json(&event)).expect("progress JSON"); + assert_eq!(progress["schema_version"], "codevetter.progress/v2"); + assert_eq!(progress["request_id"], "native-review-fixture"); + assert_eq!(progress["sequence"], 4); + assert_eq!(progress["stage"], "correctness"); + assert_eq!(progress["state"], "running"); +} + +#[test] +fn check_parser_rejects_partial_targets_and_ambiguous_sources() { + let cwd = Path::new("/tmp/widget"); + assert!(parse_arguments( + [ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Review this".into(), + "--perf-target".into(), + "test/performance.test.mjs".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--pr".into(), + "https://github.com/acme/widget/pull/1".into(), + "--task".into(), + "Review this".into(), + ], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "check".into(), + "--range".into(), + "main...HEAD".into(), + "--task".into(), + "Review this".into(), + "--requirement".into(), + "missing-spec".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn collect_parser_requires_a_range_and_explicit_supported_collectors() { + let cwd = Path::new("/tmp/widget"); + let CliCommand::Collect(arguments) = parse_arguments( + [ + "collect".into(), + "--range".into(), + "main..HEAD".into(), + "--collector".into(), + "gitleaks".into(), + "--collector".into(), + "cargo-audit".into(), + "--json".into(), + ], + cwd, + ) + .expect("collect arguments") else { + panic!("expected collect command") + }; + assert_eq!(arguments.repo_path, cwd); + assert_eq!(arguments.change, "main..HEAD"); + assert_eq!( + arguments.collectors, + vec![CollectorKind::Gitleaks, CollectorKind::CargoAudit] + ); + assert_eq!(arguments.output, OutputMode::Json); + + assert!(parse_arguments( + ["collect".into(), "--range".into(), "main..HEAD".into()], + cwd, + ) + .is_err()); + assert!(parse_arguments( + [ + "collect".into(), + "--range".into(), + "main..HEAD".into(), + "--collector".into(), + "unknown".into(), + ], + cwd, + ) + .is_err()); +} + +#[test] +fn output_and_exit_codes_preserve_receipt_meaning() { + let config: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).expect("Tauri config"); + assert_eq!( + app_version(), + config["version"].as_str().expect("app version") + ); + let passed = fixture_receipt(TrexPreviewVerdict::PassedWithLimits); + let failed = fixture_receipt(TrexPreviewVerdict::Failed); + let uncertain = fixture_receipt(TrexPreviewVerdict::NoConfidence); + assert_eq!(verdict_exit_code(passed.verdict), 0); + assert_eq!(verdict_exit_code(failed.verdict), 1); + assert_eq!(verdict_exit_code(uncertain.verdict), 2); + + let output = render_human_receipt(&failed); + assert!(output.contains("verdict: failed")); + assert!(output.contains("head: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); + assert!(output.contains("preview: claimed")); + assert!(output.contains("failure /: fixture journey")); + + let payload = serde_json::to_string(&passed).expect("receipt JSON"); + let round_trip: TrexPreviewReceipt = serde_json::from_str(&payload).expect("receipt"); + assert_eq!(round_trip.run_id, passed.run_id); + assert_eq!(round_trip.verdict, TrexPreviewVerdict::PassedWithLimits); +} + +#[test] +fn local_check_output_and_exit_codes_preserve_stage_meaning() { + let passed = fixture_local_check(LocalCheckVerdict::PassedWithLimits); + let failed = fixture_local_check(LocalCheckVerdict::Failed); + let uncertain = fixture_local_check(LocalCheckVerdict::NoConfidence); + assert_eq!(local_check_exit_code(passed.verdict), 0); + assert_eq!(local_check_exit_code(failed.verdict), 1); + assert_eq!(local_check_exit_code(uncertain.verdict), 2); + + let output = render_human_check(&passed); + assert!(output.contains("verdict: passed_with_limits")); + assert!(output.contains("correctness: passed")); + assert!(output.contains("optimization: ready")); + assert!(output.contains("next: codevetter check --repo ")); + + let payload = serde_json::to_value(&passed).expect("receipt JSON"); + assert_eq!(payload["schema_version"], "codevetter.local-check/v1"); + assert_eq!(payload["stages"]["correctness"]["status"], "passed"); + assert!(payload.get("spec_coverage").is_none()); + + let mut spec_passed = passed.clone(); + spec_passed.spec_coverage = Some( + serde_json::from_value(serde_json::json!({ + "schema_version": "codevetter.spec-coverage/v1", + "head_sha": "b".repeat(40), + "sources": [{"path": "docs/product.md", "sha256": format!("sha256:{}", "c".repeat(64)), "bytes": 42}], + "requirements": [], + "summary": { + "total_requirements": 5, + "review_input_requirements": 5, + "selected_for_execution": 2, + "verified": 2, + "contradicted": 0, + "review_only": 3, + "unverified": 0, + "review_input_coverage_percent": 100, + "executable_evidence_coverage_percent": 40, + "verified_coverage_percent": 40 + }, + "limitations": [] + })) + .expect("spec coverage"), + ); + let spec_output = render_human_check(&spec_passed); + assert!(spec_output.contains("spec review input: 5/5 (100%)")); + assert!(spec_output.contains("spec executable evidence: 2/5 (40%)")); + assert!(spec_output.contains("spec verified: 2/5 (40%)")); + + let mut findings = passed.clone(); + findings.stages.review.evidence = serde_json::json!({ + "findings": [ + {"severity": "low", "title": "Fourth finding", "filePath": "src/four.ts", "line": 4}, + {"severity": "high", "title": "Unsafe\nterminal\u{1b}[31m title", "filePath": "src/high.ts", "line": 9}, + {"severity": "critical", "title": "Critical finding", "filePath": "src/critical.ts", "line": 2}, + {"severity": "medium", "title": "Medium finding", "filePath": "src/medium.ts"} + ] + }); + let findings_output = render_human_check(&findings); + assert!(findings_output.contains("review findings:")); + assert!(findings_output.contains("- critical: Critical finding (src/critical.ts:2)")); + assert!(findings_output.contains("- high: Unsafe terminal[31m title (src/high.ts:9)")); + assert!(findings_output.contains("- medium: Medium finding (src/medium.ts)")); + assert!(!findings_output.contains("Fourth finding")); + + let preflight = LocalCheckPreflightReceipt { + schema_version: "codevetter.local-check-preflight/v1".into(), + request_id: None, + ran_at: "2026-08-29T00:00:00Z".into(), + repo_path: passed.repo_path.clone(), + task: passed.task.clone(), + source: passed.source.clone(), + spec_coverage: None, + correctness_target: Some(LocalCheckTarget { + adapter: "vitest".into(), + target: "test/parser.test.ts".into(), + name: None, + source: "discovered:fixture".into(), + }), + performance_target: None, + status: LocalCheckStatus::Ready, + limitations: vec!["No dedicated performance workload matched".into()], + }; + let preflight_output = render_human_preflight(&preflight); + assert_eq!(preflight_exit_code(preflight.status), 0); + assert!(preflight_output.contains("preflight: ready")); + assert!(preflight_output.contains("correctness target: vitest test/parser.test.ts")); + assert!(preflight_output.contains("performance target: unavailable")); + assert!(preflight_output.contains("rerun this command without --preflight")); +}