diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d834a..f931ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.4] - 2026-07-28 + +### Added + +- **Persist review & scan findings to `cora.db`** (#397, #398). `cora review` and `cora scan` now save findings (severity, file, line, title, fingerprint) to the global database. Uses best-effort logging — never blocks the review pipeline on DB errors. +- **Auto-resolve stale findings** (#399). When a new review/scan completes, findings from prior reviews that no longer appear are automatically marked `resolved` with an `auto_resolved` event. Findings that reappear stay `open`. +- **`cora findings` CLI command** (#400). New subcommand with four actions: + - `cora findings list` — show open findings (use `--all`, `--severity`, `--file`, `--json` for filtering) + - `cora findings stats` — summary counts with resolution rate (`--json` supported) + - `cora findings dismiss ` — mark as won't-fix with optional `--reason` + - `cora findings reopen ` — reopen a dismissed/resolved finding +- **Read-only DB access for `cora findings list/stats`** via `open_db_for_read()` — no migrations, no WAL, safe for concurrent reads. + +### Changed + +- `db_writer` module now exposes `open_db_for_read()`, `open_db_for_write()`, and `compute_fingerprint_pub()` for use by the findings CLI. + ## [0.8.3] - 2026-07-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index bc0f9c3..3a5c35a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,7 +297,7 @@ dependencies = [ [[package]] name = "cora-code" -version = "0.8.3" +version = "0.8.4" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index dd13b46..6308393 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cora-code" -version = "0.8.3" +version = "0.8.4" edition = "2024" description = "CLI-first AI code review — BYOK, diff/scan/branch, pre-commit hooks" license = "MIT" @@ -9,6 +9,13 @@ readme = "README.md" keywords = ["cli", "code-review", "ai", "git", "pre-commit"] categories = ["command-line-utilities", "development-tools"] rust-version = "1.85" +exclude = [ + "vendored/", + "docs/", + ".github/", + "tests/", + "target/", +] [dependencies] # CLI @@ -80,6 +87,7 @@ tree-sitter-javascript = { version = "0.25", optional = true } [features] default = [] +pretrained-embed = [] tree-sitter = [ "dep:tree-sitter", "dep:tree-sitter-rust", diff --git a/install-bundle.sh b/install-bundle.sh index 5d9ee4a..6033399 100755 --- a/install-bundle.sh +++ b/install-bundle.sh @@ -14,24 +14,34 @@ REPO_NAME="cora-code" BINARY_NAME="cora" INSTALL_DIR="$HOME/.local/bin" -# Detect platform +# Detect platform → Rust target triple + archive extension detect_platform() { OS="$(uname -s)" ARCH="$(uname -m)" - case "$OS" in - Linux*) OS="linux" ;; - Darwin*) OS="macos" ;; - *) echo "Unsupported OS: $OS"; exit 1 ;; - esac - + # Normalize arch case "$ARCH" in - x86_64|amd64) ARCH="x86_64" ;; + x86_64|amd64) ARCH="x86_64" ;; aarch64|arm64) ARCH="aarch64" ;; *) echo "Unsupported architecture: $ARCH"; exit 1 ;; esac - PLATFORM="${OS}-${ARCH}" + # Map OS + arch → Rust target triple + case "$OS" in + Linux*) TARGET="${ARCH}-unknown-linux-gnu"; EXT="tar.gz" ;; + Darwin*) TARGET="${ARCH}-apple-darwin"; EXT="tar.gz" ;; + *) echo "Unsupported OS: $OS"; exit 1 ;; + esac + + # Windows override (MSYS2 / Git Bash) + case "${MSYSTEM:-}${MINGW:-}" in + *MSYS*|*MINGW*) + TARGET="${ARCH}-pc-windows-msvc" + EXT="zip" + ;; + esac + + PLATFORM="$TARGET" } # Get latest release tag from GitHub @@ -67,11 +77,24 @@ install_binary() { # Create install directory mkdir -p "$INSTALL_DIR" - # Download URL - URL="https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${VERSION}/${BINARY_NAME}-${PLATFORM}" + # Download URL — matches release asset naming: cora-{target}-v{version}.{ext} + ARCHIVE_NAME="${BINARY_NAME}-${PLATFORM}-${VERSION}.${EXT}" + URL="https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${VERSION}/${ARCHIVE_NAME}" + + TMPDIR="$(mktemp -d)" + trap 'rm -rf "${TMPDIR}"' EXIT echo "Downloading ${BINARY_NAME} ${VERSION}..." - curl -fsSL "$URL" -o "${INSTALL_DIR}/${BINARY_NAME}" + curl -fsSL "$URL" -o "${TMPDIR}/${ARCHIVE_NAME}" + + # Extract archive + case "$EXT" in + tar.gz) tar xzf "${TMPDIR}/${ARCHIVE_NAME}" -C "${TMPDIR}" ;; + zip) unzip -qo "${TMPDIR}/${ARCHIVE_NAME}" -d "${TMPDIR}" ;; + esac + + # Install binary + mv "${TMPDIR}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" # Make executable chmod +x "${INSTALL_DIR}/${BINARY_NAME}" diff --git a/src/commands/findings.rs b/src/commands/findings.rs new file mode 100644 index 0000000..8fb0cf2 --- /dev/null +++ b/src/commands/findings.rs @@ -0,0 +1,342 @@ +//! `cora findings` subcommand — manage review findings stored in cora.db. + +use anyhow::Result; +use colored::Colorize; + +/// Exit codes. +const EXIT_OK: i32 = 0; +const EXIT_NOT_FOUND: i32 = 1; + +/// Sub-actions for `cora findings`. +#[derive(Debug, clap::Subcommand)] +pub enum FindingsAction { + /// List findings (default: open only) + List { + /// Show all findings including resolved/dismissed + #[clap(long)] + all: bool, + + /// Filter by severity (info, minor, major, critical) + #[clap(long)] + severity: Option, + + /// Filter by file path substring + #[clap(long)] + file: Option, + + /// Output as JSON + #[clap(long)] + json: bool, + + /// Maximum number of findings to show + #[clap(long, default_value = "50")] + limit: usize, + }, + + /// Show summary statistics + Stats { + /// Output as JSON + #[clap(long)] + json: bool, + }, + + /// Dismiss a finding (mark as won't-fix) + Dismiss { + /// Finding ID to dismiss + id: i64, + + /// Optional reason for dismissal + #[clap(long)] + reason: Option, + }, + + /// Reopen a resolved or dismissed finding + Reopen { + /// Finding ID to reopen + id: i64, + }, +} + +/// Execute the `cora findings` subcommand. +pub fn execute_findings(action: &FindingsAction) -> Result { + // Read-only actions use a read-only connection. + // Write actions (dismiss, reopen) use a read-write connection. + match action { + FindingsAction::List { .. } | FindingsAction::Stats { .. } => { + let conn = match crate::engine::db_writer::open_db_for_read() { + Some(c) => c, + None => { + eprintln!("{}", "Error: could not open cora.db".red()); + return Ok(EXIT_NOT_FOUND); + } + }; + match action { + FindingsAction::List { + all, + severity, + file, + json, + limit, + } => list_findings(&conn, *all, severity, file, *json, *limit), + FindingsAction::Stats { json } => stats(&conn, *json), + _ => unreachable!(), + } + } + FindingsAction::Dismiss { id, reason } => { + let conn = match crate::engine::db_writer::open_db_for_write() { + Some(c) => c, + None => { + eprintln!("{}", "Error: could not open cora.db for writing".red()); + return Ok(EXIT_NOT_FOUND); + } + }; + dismiss(&conn, *id, reason) + } + FindingsAction::Reopen { id } => { + let conn = match crate::engine::db_writer::open_db_for_write() { + Some(c) => c, + None => { + eprintln!("{}", "Error: could not open cora.db for writing".red()); + return Ok(EXIT_NOT_FOUND); + } + }; + reopen(&conn, *id) + } + } +} + +fn list_findings( + conn: &rusqlite::Connection, + all: bool, + severity: &Option, + file: &Option, + json: bool, + limit: usize, +) -> Result { + let mut sql = String::from( + "SELECT f.id, f.severity, f.file_path, f.line_number, f.title, f.status, + f.fingerprint, r.created_at + FROM findings f + JOIN reviews r ON f.review_id = r.id", + ); + + let mut wheres: Vec = Vec::new(); + if !all { + wheres.push("f.status = 'open'".to_string()); + } + if let Some(s) = severity { + wheres.push(format!("f.severity = '{}'", s.to_uppercase())); + } + if let Some(f) = file { + wheres.push(format!("f.file_path LIKE '%{}%'", f.replace('"', "'"))); + } + + if !wheres.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&wheres.join(" AND ")); + } + sql.push_str(" ORDER BY f.id DESC"); + sql.push_str(&format!(" LIMIT {}", limit)); + + let mut stmt = conn.prepare(&sql)?; + let rows: Vec = stmt + .query([])? + .mapped(|r| { + Ok(ListRow { + id: r.get(0)?, + severity: r.get(1)?, + file_path: r.get(2)?, + line_number: r.get(3)?, + title: r.get(4)?, + status: r.get(5)?, + fingerprint: r.get(6)?, + created_at: r.get(7)?, + }) + }) + .filter_map(|r| r.ok()) + .collect(); + + if json { + println!("{}", serde_json::to_string_pretty(&rows)?); + return Ok(EXIT_OK); + } + + if rows.is_empty() { + println!("{}", "No findings found.".dimmed()); + return Ok(EXIT_OK); + } + + println!( + "{} {} finding(s)\n", + "▸".cyan(), + rows.len().to_string().bold() + ); + for r in &rows { + let sev = match r.severity.as_str() { + "CRITICAL" => r.severity.clone().red().to_string(), + "MAJOR" => r.severity.clone().yellow().to_string(), + "MINOR" => r.severity.clone().green().to_string(), + _ => r.severity.clone().dimmed().to_string(), + }; + let status_tag = match r.status.as_str() { + "open" => "OPEN".green().to_string(), + "resolved" => "RESOLVED".dimmed().to_string(), + "dismissed" => "DISMISSED".dimmed().to_string(), + _ => r.status.to_uppercase().dimmed().to_string(), + }; + let line_info = match r.line_number { + Some(l) => format!(":{}", l), + None => String::new(), + }; + println!( + " #{} {} {} {}{} [{}]", + r.id.to_string().dimmed(), + sev, + format!("{}", r.file_path).blue(), + line_info.dimmed(), + format!(" | {}", r.title), + status_tag, + ); + } + + Ok(EXIT_OK) +} + +fn stats(conn: &rusqlite::Connection, json: bool) -> Result { + let total: i64 = conn + .query_row("SELECT count(*) FROM findings", [], |r| r.get(0)) + .unwrap_or(0); + + let open: i64 = conn + .query_row( + "SELECT count(*) FROM findings WHERE status = 'open'", + [], + |r| r.get(0), + ) + .unwrap_or(0); + + let resolved: i64 = conn + .query_row( + "SELECT count(*) FROM findings WHERE status = 'resolved'", + [], + |r| r.get(0), + ) + .unwrap_or(0); + + let dismissed: i64 = conn + .query_row( + "SELECT count(*) FROM findings WHERE status = 'dismissed'", + [], + |r| r.get(0), + ) + .unwrap_or(0); + + let reviews: i64 = conn + .query_row("SELECT count(*) FROM reviews", [], |r| r.get(0)) + .unwrap_or(0); + + if json { + let stats = serde_json::json!({ + "total_findings": total, + "open": open, + "resolved": resolved, + "dismissed": dismissed, + "total_reviews": reviews, + "resolution_rate": if total > 0 { (resolved as f64 / total as f64 * 100.0).round() } else { 0.0 }, + }); + println!("{}", serde_json::to_string_pretty(&stats)?); + return Ok(EXIT_OK); + } + + println!("{}", "Findings Summary".bold()); + println!(); + println!(" Reviews: {}", reviews.to_string().bold()); + println!(" Total: {}", total); + println!(" {}", format!("Open: {}", open).green()); + println!(" {}", format!("Resolved: {}", resolved).dimmed()); + println!(" {}", format!("Dismissed: {}", dismissed).dimmed()); + if total > 0 { + let rate = resolved as f64 / total as f64 * 100.0; + println!(" Resolution: {:.1}%", rate); + } + + Ok(EXIT_OK) +} + +fn dismiss(conn: &rusqlite::Connection, id: i64, reason: &Option) -> Result { + let exists: bool = conn + .query_row( + "SELECT status FROM findings WHERE id = ?1", + rusqlite::params![id], + |r| r.get::<_, String>(0), + ) + .is_ok(); + + if !exists { + eprintln!("{}", format!("Finding #{} not found.", id).red()); + return Ok(EXIT_NOT_FOUND); + } + + conn.execute( + "UPDATE findings SET status = 'dismissed' WHERE id = ?1", + rusqlite::params![id], + )?; + + let note = reason + .as_deref() + .unwrap_or("Manually dismissed via CLI"); + conn.execute( + "INSERT INTO finding_events (finding_id, event_type, note) VALUES (?1, 'dismissed', ?2)", + rusqlite::params![id, note], + )?; + + println!("{} Finding #{} dismissed.", "✓".green(), id); + Ok(EXIT_OK) +} + +fn reopen(conn: &rusqlite::Connection, id: i64) -> Result { + let status: Option = conn + .query_row( + "SELECT status FROM findings WHERE id = ?1", + rusqlite::params![id], + |r| r.get(0), + ) + .ok(); + + match status.as_deref() { + Some("open") => { + println!("{}", format!("Finding #{} is already open.", id).yellow()); + return Ok(EXIT_OK); + } + None => { + eprintln!("{}", format!("Finding #{} not found.", id).red()); + return Ok(EXIT_NOT_FOUND); + } + _ => {} + } + + conn.execute( + "UPDATE findings SET status = 'open' WHERE id = ?1", + rusqlite::params![id], + )?; + + conn.execute( + "INSERT INTO finding_events (finding_id, event_type, note) VALUES (?1, 'reopened', 'Manually reopened via CLI')", + rusqlite::params![id], + )?; + + println!("{} Finding #{} reopened.", "✓".green(), id); + Ok(EXIT_OK) +} + +#[derive(serde::Serialize)] +struct ListRow { + id: i64, + severity: String, + file_path: String, + line_number: Option, + title: String, + status: String, + fingerprint: Option, + created_at: String, +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9013296..2326e00 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod commit_cmd; pub mod completion; pub mod config_cmd; pub mod debt; +pub mod findings; pub mod hook_cmd; pub mod init; pub mod profile; diff --git a/src/commands/review.rs b/src/commands/review.rs index 88c34ec..31a0295 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -5,6 +5,7 @@ use tracing::debug; use crate::config::schema::Config; use crate::engine::Severity; use crate::engine::chunker; +use crate::engine::db_writer; use crate::engine::quality_gate; use crate::engine::types::ReviewResponse; use crate::formatters::{OutputFormat, formatter_for}; @@ -303,7 +304,47 @@ pub async fn execute_review( crate::engine::debt_tracker::save_snapshot(&snapshot, config.debt.history_dir.as_deref()); } - // 9. Return exit code + // 8c. Save review findings to cora.db (best-effort) + { + let (commit, branch) = get_git_context(); + let cmd = if opts.ci { "review --ci" } else { "review" }; + let gate_status = match gate_result.as_ref().map(|g| g.status) { + Some(quality_gate::GateStatus::Pass) => "passed", + Some(quality_gate::GateStatus::Fail) => "failed", + None => "disabled", + }; + let tokens = response.tokens_used.as_ref(); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let record = db_writer::ReviewRecord { + command: cmd, + project_root: &cwd, + commit_hash: commit.as_deref(), + branch: branch.as_deref(), + summary: &filtered_response.summary, + gate_status, + files_scanned: 0, + lines_scanned: 0, + should_block: filtered_response.should_block, + tokens, + issues: &filtered_response.issues, + }; + if db_writer::save_review_to_db(&record).is_none() { + debug!("Failed to save review to cora.db"); + } + + // Auto-resolve findings that no longer appear in this review. + let fps: Vec = filtered_response + .issues + .iter() + .map(|i| db_writer::compute_fingerprint_pub(i)) + .collect(); + let resolved = db_writer::resolve_stale_findings(&cwd, &fps); + if resolved > 0 { + debug!(resolved, "auto-resolved stale findings"); + } + } let exit_code = if gate_result .as_ref() .is_some_and(|g| g.status == quality_gate::GateStatus::Fail) @@ -644,7 +685,47 @@ async fn execute_chunked_review( crate::engine::debt_tracker::save_snapshot(&snapshot, config.debt.history_dir.as_deref()); } - // 9. Return exit code + // 8c. Save review findings to cora.db (best-effort) + { + let (commit, branch) = get_git_context(); + let cmd = if opts.ci { "review --ci" } else { "review" }; + let gate_status = match gate_result.as_ref().map(|g| g.status) { + Some(quality_gate::GateStatus::Pass) => "passed", + Some(quality_gate::GateStatus::Fail) => "failed", + None => "disabled", + }; + let tokens = merged_response.tokens_used.as_ref(); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let record = db_writer::ReviewRecord { + command: cmd, + project_root: &cwd, + commit_hash: commit.as_deref(), + branch: branch.as_deref(), + summary: &filtered_response.summary, + gate_status, + files_scanned: 0, + lines_scanned: 0, + should_block: filtered_response.should_block, + tokens, + issues: &filtered_response.issues, + }; + if db_writer::save_review_to_db(&record).is_none() { + debug!("Failed to save review to cora.db"); + } + + // Auto-resolve findings that no longer appear in this review. + let fps: Vec = filtered_response + .issues + .iter() + .map(|i| db_writer::compute_fingerprint_pub(i)) + .collect(); + let resolved = db_writer::resolve_stale_findings(&cwd, &fps); + if resolved > 0 { + debug!(resolved, "auto-resolved stale findings"); + } + } let exit_code = compute_exit_code( gate_result.as_ref().map(|g| g.status), opts.ci, diff --git a/src/commands/scan.rs b/src/commands/scan.rs index f27c47b..971bda9 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -5,6 +5,7 @@ use colored::Colorize; use tracing::debug; use crate::config::schema::Config; +use crate::engine::db_writer; use crate::engine::scanner::{batch_files, format_batch_for_prompt, walk_project}; use crate::engine::types::TokenUsage; use crate::formatters::{OutputFormat, formatter_for}; @@ -251,6 +252,52 @@ pub async fn execute_scan( debug!(cached = files.len(), "saved scan cache"); } + // 7. Save scan findings to cora.db (best-effort) + { + let commit = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().to_string().into()); + let branch = std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().to_string().into()); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let record = db_writer::ReviewRecord { + command: "scan", + project_root: &cwd, + commit_hash: commit.as_deref(), + branch: branch.as_deref(), + summary: &response.summary, + gate_status: "disabled", + files_scanned: response.files_scanned, + lines_scanned: response.lines_scanned, + should_block: response.should_block, + tokens: response.tokens_used.as_ref(), + issues: &response.issues, + }; + if db_writer::save_review_to_db(&record).is_none() { + debug!("Failed to save scan to cora.db"); + } + + // Auto-resolve findings that no longer appear in this scan. + let fps: Vec = response + .issues + .iter() + .map(|i| db_writer::compute_fingerprint_pub(i)) + .collect(); + let resolved = db_writer::resolve_stale_findings(&cwd, &fps); + if resolved > 0 { + debug!(resolved, "auto-resolved stale findings"); + } + } + if response.should_block && config.hook.mode == "block" { Ok(2) } else { diff --git a/src/data_dir.rs b/src/data_dir.rs index 5c0b7c7..404a0d8 100644 --- a/src/data_dir.rs +++ b/src/data_dir.rs @@ -40,8 +40,25 @@ pub fn cora_data_dir() -> PathBuf { } /// Returns the path to the global graph database. +/// +/// Defaults to `cora.db`. If `cora.db` does not exist but `graph.db` does, +/// automatically renames `graph.db` → `cora.db` (backward-compatible migration). pub fn graph_db_path() -> PathBuf { - cora_data_dir().join("graph.db") + let dir = cora_data_dir(); + let new_db = dir.join("cora.db"); + + if !new_db.exists() { + let old_db = dir.join("graph.db"); + if old_db.exists() { + if let Err(e) = std::fs::rename(&old_db, &new_db) { + eprintln!("warning: failed to migrate graph.db → cora.db: {e}"); + // Fall back to graph.db on rename failure + return old_db; + } + } + } + + new_db } /// Ensure the cora-code data directory exists. @@ -70,7 +87,10 @@ mod tests { #[test] fn test_graph_db_path() { let path = graph_db_path(); - assert!(path.ends_with(".codecora/cora-code/graph.db")); + assert!( + path.ends_with(".codecora/cora-code/cora.db") || path.ends_with(".codecora/cora-code/graph.db"), + "graph_db_path should end with cora.db (or graph.db on migration failure), got: {path:?}" + ); } #[test] diff --git a/src/embed/mod.rs b/src/embed/mod.rs index d5b61f3..2307a2c 100644 --- a/src/embed/mod.rs +++ b/src/embed/mod.rs @@ -15,5 +15,9 @@ // Embedding engine — consumed by brain module (Phase 3). -pub mod token_vocab; pub mod tokens; + +// Pre-trained nomic-embed-code module — only compiled when vendored data +// exists. Excluded from crates.io package (too large for 10 MB upload limit). +#[cfg(feature = "pretrained-embed")] +pub mod token_vocab; diff --git a/src/engine/db_writer.rs b/src/engine/db_writer.rs new file mode 100644 index 0000000..dd9120b --- /dev/null +++ b/src/engine/db_writer.rs @@ -0,0 +1,304 @@ +//! Database writer - persists review/scan results to `cora.db`. +//! +//! Uses the v5 schema tables: `reviews`, `findings`, `finding_events`. +//! All operations are best-effort (non-fatal on error) to never block a review. + + +use rusqlite::Connection; + +use crate::engine::types::{ReviewIssue, TokenUsage}; +use crate::engine::Severity; +use crate::index::schema; + +/// Input data for saving a review/scan run to the database. +pub struct ReviewRecord<'a> { + /// "review" or "scan". + pub command: &'a str, + /// Absolute path of the project root (used for project lookup/creation). + pub project_root: &'a str, + /// Git commit hash (short) if available. + pub commit_hash: Option<&'a str>, + /// Git branch name if available. + pub branch: Option<&'a str>, + /// LLM-generated summary text. + pub summary: &'a str, + /// Quality gate status: "passed", "failed", or "disabled". + pub gate_status: &'a str, + /// Number of files scanned/reviewed. + pub files_scanned: usize, + /// Number of lines scanned/reviewed. + pub lines_scanned: usize, + /// Whether the quality gate should block. + pub should_block: bool, + /// Token usage from the LLM call (if any). + pub tokens: Option<&'a TokenUsage>, + /// The issues/findings to persist. + pub issues: &'a [ReviewIssue], +} + +/// Save a review/scan run to `cora.db`. +/// +/// Opens its own connection (the global DB is single-writer safe for our +/// low-frequency writes). Returns the `review_id` on success, or `None` on +/// error (best-effort: caller continues regardless). +pub fn save_review_to_db(record: &ReviewRecord<'_>) -> Option { + let conn = open_db().ok()?; + let project_id = schema::get_or_create_project(&conn, record.project_root).ok()?; + + // Insert the review row. + let (input_tokens, output_tokens, cost_usd) = record + .tokens + .map(|t| (t.input_tokens as i64, t.output_tokens as i64, t.estimated_cost_usd)) + .unwrap_or((0, 0, 0.0)); + + conn.execute( + "INSERT INTO reviews + (project_id, command, commit_hash, branch, summary, score, gate_status, + files_scanned, lines_scanned, should_block, input_tokens, output_tokens, cost_usd) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + rusqlite::params![ + project_id, + record.command, + record.commit_hash, + record.branch, + record.summary, + calculate_score(record.issues) as i64, + record.gate_status, + record.files_scanned as i64, + record.lines_scanned as i64, + record.should_block as i64, + input_tokens, + output_tokens, + cost_usd, + ], + ) + .ok()?; + + let review_id = conn.last_insert_rowid(); + + // Insert each finding + an "opened" event. + let mut stmt_findings = conn + .prepare( + "INSERT INTO findings + (review_id, file_path, line_number, severity, issue_type, title, body, + suggested_fix, status, fingerprint) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'open', ?9)", + ) + .ok()?; + + let mut stmt_events = conn + .prepare( + "INSERT INTO finding_events (finding_id, event_type, note) + VALUES (?1, 'opened', NULL)", + ) + .ok()?; + + for issue in record.issues { + let fingerprint = compute_fingerprint(issue); + stmt_findings + .execute(rusqlite::params![ + review_id, + issue.file, + issue.line.map(|l| l as i64), + issue.severity.to_string(), + issue.issue_type.as_deref(), + issue.title, + issue.body, + issue.suggested_fix.as_deref(), + fingerprint, + ]) + .ok()?; + + let finding_id = conn.last_insert_rowid(); + stmt_events + .execute(rusqlite::params![finding_id]) + .ok()?; + } + + Some(review_id) +} + +/// Auto-resolve findings from prior reviews that no longer appear. +/// +/// After saving a new review, any `open` findings in the same project whose +/// fingerprint is *not* in the current review's findings are marked `resolved` +/// with an `auto_resolved` event. Findings that *do* reappear are left `open`. +pub fn resolve_stale_findings(project_root: &str, current_fingerprints: &[String]) -> usize { + let Ok(conn) = open_db() else { return 0 }; + let Ok(project_id) = schema::get_or_create_project(&conn, project_root) else { return 0 }; + + // Fetch (id, fingerprint) for all open findings in this project. + let mut stmt = match conn.prepare( + "SELECT f.id, f.fingerprint FROM findings f + JOIN reviews r ON f.review_id = r.id + WHERE r.project_id = ?1 + AND f.status = 'open' + AND f.fingerprint IS NOT NULL", + ) { + Ok(s) => s, + Err(_) => return 0, + }; + let mut rows = match stmt.query(rusqlite::params![project_id]) { + Ok(r) => r, + Err(_) => return 0, + }; + + let mut stale_ids: Vec = Vec::new(); + while let Some(row) = rows.next().unwrap_or(None) { + let id: i64 = row.get(0).unwrap_or(0); + let fp: String = row.get(1).unwrap_or_default(); + if !current_fingerprints.contains(&fp) { + stale_ids.push(id); + } + } + + let mut resolved = 0; + let mut stmt_update = conn + .prepare("UPDATE findings SET status = 'resolved' WHERE id = ?1") + .ok(); + let mut stmt_event = conn + .prepare( + "INSERT INTO finding_events (finding_id, event_type, note) + VALUES (?1, 'auto_resolved', 'No longer found in latest review')", + ) + .ok(); + + for id in &stale_ids { + if let (Some(u), Some(e)) = (stmt_update.as_mut(), stmt_event.as_mut()) { + if u.execute(rusqlite::params![id]).is_ok() + && e.execute(rusqlite::params![id]).is_ok() + { + resolved += 1; + } + } + } + + resolved +} + +/// Open the global `cora.db` and ensure migrations are up to date. +fn open_db() -> anyhow::Result { + crate::data_dir::ensure_data_dir()?; + let db_path = crate::data_dir::graph_db_path(); + let conn = Connection::open(&db_path)?; + conn.execute_batch("PRAGMA foreign_keys=ON;")?; + conn.execute_batch("PRAGMA journal_mode=WAL;")?; + schema::run_migrations(&conn)?; + Ok(conn) +} + +/// Open cora.db in read-only mode (no migrations, no WAL). +/// Returns `None` if the DB doesn't exist or can't be opened. +pub fn open_db_for_read() -> Option { + let db_path = crate::data_dir::graph_db_path(); + if !std::path::Path::new(&db_path).exists() { + return None; + } + Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok() +} + +/// Open cora.db for read-write, running migrations if needed. +/// Returns `None` on failure (best-effort, never panics). +pub fn open_db_for_write() -> Option { + open_db().ok() +} + +/// Compute a fingerprint for dedup/auto-resolve: `file:line:title_slug`. +/// Public wrapper so callers (review.rs, scan.rs) can compute fingerprints +/// for the current review before calling `resolve_stale_findings`. +pub fn compute_fingerprint_pub(issue: &ReviewIssue) -> String { + compute_fingerprint(issue) +} + +fn compute_fingerprint(issue: &ReviewIssue) -> String { + let line = issue.line.unwrap_or(0); + let title_slug = issue.title.to_lowercase().replace(' ', "_"); + format!("{}:{}:{}", issue.file, line, title_slug) +} + +/// Calculate a quality score 0-100 from issue severities. +/// +/// 100 = no issues. Each finding reduces the score: +/// - critical: -20, major: -10, minor: -3, info: -1 +fn calculate_score(issues: &[ReviewIssue]) -> f64 { + let mut score: f64 = 100.0; + for issue in issues { + let penalty: f64 = match issue.severity { + Severity::Critical => 20.0, + Severity::Major => 10.0, + Severity::Minor => 3.0, + Severity::Info => 1.0, + }; + score -= penalty; + } + score.max(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_issue(file: &str, line: u32, severity: Severity, title: &str) -> ReviewIssue { + ReviewIssue { + file: file.to_string(), + line: Some(line), + severity, + issue_type: Some("security".to_string()), + title: title.to_string(), + body: "test body".to_string(), + suggested_fix: Some("fix it".to_string()), + } + } + + #[test] + fn test_fingerprint_format() { + let issue = make_issue("src/main.rs", 42, Severity::Critical, "SQL Injection"); + let fp = compute_fingerprint(&issue); + assert_eq!(fp, "src/main.rs:42:sql_injection"); + } + + #[test] + fn test_fingerprint_no_line() { + let mut issue = make_issue("src/lib.rs", 0, Severity::Minor, "Unused Import"); + issue.line = None; + let fp = compute_fingerprint(&issue); + assert_eq!(fp, "src/lib.rs:0:unused_import"); + } + + #[test] + fn test_score_no_issues() { + let issues: Vec = vec![]; + assert_eq!(calculate_score(&issues), 100.0); + } + + #[test] + fn test_score_with_critical() { + let issues = vec![make_issue("a.rs", 1, Severity::Critical, "x")]; + assert_eq!(calculate_score(&issues), 80.0); + } + + #[test] + fn test_score_floor_zero() { + let issues = vec![ + make_issue("a.rs", 1, Severity::Critical, "x"), + make_issue("a.rs", 2, Severity::Critical, "y"), + make_issue("a.rs", 3, Severity::Critical, "z"), + make_issue("a.rs", 4, Severity::Critical, "w"), + make_issue("a.rs", 5, Severity::Critical, "v"), + make_issue("a.rs", 6, Severity::Critical, "u"), + ]; + assert_eq!(calculate_score(&issues), 0.0); + } + + #[test] + fn test_score_mixed() { + let issues = vec![ + make_issue("a.rs", 1, Severity::Critical, "c"), + make_issue("b.rs", 2, Severity::Major, "m"), + make_issue("c.rs", 3, Severity::Minor, "n"), + make_issue("d.rs", 4, Severity::Info, "i"), + ]; + // 100 - 20 - 10 - 3 - 1 = 66 + assert_eq!(calculate_score(&issues), 66.0); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 5f8aa38..ed8ab21 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -2,6 +2,7 @@ pub mod bundling; pub mod cache; pub mod chunker; pub mod context; +pub mod db_writer; pub mod debt_tracker; pub mod diff_parser; pub mod language_analyzer; diff --git a/src/index/mod.rs b/src/index/mod.rs index 42a3d3e..a1eb9b5 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -26,7 +26,7 @@ pub use symbols::{SearchResult, SymbolKind, SymbolQuery}; /// Open or create the **global** symbol index database. /// -/// All projects share a single SQLite database at `~/.codecora/cora-code/graph.db`. +/// All projects share a single SQLite database at `~/.codecora/cora-code/cora.db`. /// Project isolation is handled via the `project_id` foreign key. pub fn open_global_index() -> anyhow::Result { crate::data_dir::ensure_data_dir()?; diff --git a/src/index/schema.rs b/src/index/schema.rs index 6c1c394..2722dec 100644 --- a/src/index/schema.rs +++ b/src/index/schema.rs @@ -4,7 +4,7 @@ use rusqlite::Connection; /// Current schema version. #[allow(dead_code)] -const SCHEMA_VERSION: i32 = 4; +const SCHEMA_VERSION: i32 = 5; /// Run database migrations (creates tables if not exist). pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { @@ -34,6 +34,9 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { if current < 4 { migrate_v4(conn)?; } + if current < 5 { + migrate_v5(conn)?; + } Ok(()) } @@ -128,7 +131,7 @@ fn migrate_v1(conn: &Connection) -> anyhow::Result<()> { /// Migration v2: Multi-project support. /// /// Adds `projects` table and `project_id` column to `symbols`, `files`, -/// and `call_graph`. The global DB at `~/.codecora/cora-code/graph.db` +/// and `call_graph`. The global DB at `~/.codecora/cora-code/cora.db` /// stores data for all indexed projects, keyed by absolute path. fn migrate_v2(conn: &Connection) -> anyhow::Result<()> { conn.execute_batch( @@ -212,6 +215,77 @@ fn migrate_v4(conn: &Connection) -> anyhow::Result<()> { Ok(()) } +/// Migration v5: Review history — reviews, findings, finding_events. +/// +/// Enables persistent storage of review/scan results in the same `cora.db`. +/// This replaces the legacy JSON snapshot files in `.cora/history/`. +fn migrate_v5(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + " + -- One row per review/scan run + CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + command TEXT NOT NULL DEFAULT 'review', + commit_hash TEXT, + branch TEXT, + summary TEXT NOT NULL DEFAULT '', + score INTEGER NOT NULL DEFAULT 0, + gate_status TEXT NOT NULL DEFAULT 'disabled', + files_scanned INTEGER NOT NULL DEFAULT 0, + lines_scanned INTEGER NOT NULL DEFAULT 0, + should_block INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cost_usd REAL NOT NULL DEFAULT 0.0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_reviews_project ON reviews(project_id); + CREATE INDEX IF NOT EXISTS idx_reviews_created ON reviews(created_at); + CREATE INDEX IF NOT EXISTS idx_reviews_command ON reviews(command); + + -- Individual findings (issues) from each review/scan + CREATE TABLE IF NOT EXISTS findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + line_number INTEGER, + severity TEXT NOT NULL DEFAULT 'info', + issue_type TEXT, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + suggested_fix TEXT, + status TEXT NOT NULL DEFAULT 'open', + fingerprint TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_findings_review ON findings(review_id); + CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status); + CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity); + CREATE INDEX IF NOT EXISTS idx_findings_file ON findings(file_path); + CREATE INDEX IF NOT EXISTS idx_findings_fingerprint ON findings(fingerprint); + + -- Audit trail for finding status changes + CREATE TABLE IF NOT EXISTS finding_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + finding_id INTEGER NOT NULL REFERENCES findings(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, -- 'opened', 'auto_resolved', 'dismissed', 'reopened' + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_fevents_finding ON finding_events(finding_id); + CREATE INDEX IF NOT EXISTS idx_fevents_type ON finding_events(event_type); + ", + )?; + + conn.execute("INSERT INTO schema_version (version) VALUES (5)", [])?; + + Ok(()) +} + /// Get or create a project entry by root path. /// /// Returns the project ID. @@ -467,4 +541,92 @@ mod tests { .unwrap(); assert_eq!(count, 1); } + + #[test] + fn test_v5_review_tables_exist() { + let conn = mem_conn(); + + // Check reviews table + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM reviews", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + + // Check findings table + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM findings", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + + // Check finding_events table + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM finding_events", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + + // Verify schema version is 5 + let version: i32 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + assert_eq!(version, 5); + } + + #[test] + fn test_v5_insert_review_and_findings() { + let conn = mem_conn(); + let pid = get_or_create_project(&conn, "/test/proj").unwrap(); + + // Insert a review + conn.execute( + "INSERT INTO reviews (project_id, command, commit_hash, branch, summary, score, files_scanned, lines_scanned) + VALUES (?1, 'review', 'abc123', 'main', '3 issues found', 75, 5, 200)", + rusqlite::params![pid], + ) + .unwrap(); + let review_id: i64 = conn.last_insert_rowid(); + + // Insert findings + conn.execute( + "INSERT INTO findings (review_id, file_path, line_number, severity, title, body, fingerprint) + VALUES (?1, 'src/main.rs', 42, 'critical', 'SQL injection', 'Unsanitized input', 'main.rs:42:sql_injection')", + rusqlite::params![review_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO findings (review_id, file_path, line_number, severity, title, body, fingerprint) + VALUES (?1, 'src/lib.rs', 10, 'minor', 'Unused import', 'Import not used', 'lib.rs:10:unused_import')", + rusqlite::params![review_id], + ) + .unwrap(); + + // Query back + let finding_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM findings WHERE review_id = ?1", + rusqlite::params![review_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(finding_count, 2); + + // Insert an event + let finding_id: i64 = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO finding_events (finding_id, event_type, note) VALUES (?1, 'opened', NULL)", + rusqlite::params![finding_id], + ) + .unwrap(); + + let event_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM finding_events WHERE finding_id = ?1", + rusqlite::params![finding_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(event_count, 1); + } } diff --git a/src/main.rs b/src/main.rs index 67ab510..f2e8eb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -436,6 +436,12 @@ enum Command { /// Start MCP server (Model Context Protocol for AI agents) Mcp, + /// Manage review findings (list, dismiss, reopen, stats) + Findings { + #[command(subcommand)] + action: commands::findings::FindingsAction, + }, + /// Show tech debt report from review history Debt { /// Output as JSON @@ -1383,6 +1389,9 @@ async fn main() -> Result<()> { mcp::server::run_server()?; 0 } + Command::Findings { action } => { + commands::findings::execute_findings(&action)? + } Command::Debt { json, trend,