diff --git a/Cargo.lock b/Cargo.lock index 319c4e6..1c38f49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,6 +360,12 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", "url", "uuid", "walkdir", @@ -1891,6 +1897,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2249,6 +2256,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "stringprep" version = "0.1.5" @@ -2621,6 +2634,76 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index e408939..06681e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,12 @@ petgraph = "0.7" # Base64 (for PaaS-safe env var encoding of PEM keys) base64 = "0.22" +tree-sitter = "0.26.11" +tree-sitter-rust = "0.24.2" +tree-sitter-go = "0.25.0" +tree-sitter-python = "0.25.0" +tree-sitter-javascript = "0.25.0" +tree-sitter-typescript = "0.23.2" [dev-dependencies] tempfile = "3" diff --git a/docs/codasaurus-toml.md b/docs/codasaurus-toml.md index 42ccc67..ea78c89 100644 --- a/docs/codasaurus-toml.md +++ b/docs/codasaurus-toml.md @@ -64,6 +64,10 @@ threshold = 0.0 metric = "new_medium_issues" op = "gt" threshold = 5.0 + +[confidence] +judge_tier1 = false +drop_ungrounded = false ``` ## Sections @@ -76,6 +80,7 @@ threshold = 5.0 | `[guidelines]` | Contribution guideline path override | | `[pre_merge]` | Soft caps used as defaults before DB policy overlay | | `[quality_gate]` | Sonar-style gate on new findings; failed gate blocks the check run when `block_on_fail` | +| `[confidence]` | Per-finding confidence 0-5: optional LLM judge + grounding filter | ## `review_strictness` @@ -103,6 +108,17 @@ Sonar-style gate evaluated against findings on new code lines. Any failed condit Operators: `gt`, `gte`, `lt`, `lte`, `eq`, `ne`. +## `confidence` + +Every finding carries a confidence score 0-5. Tier-1 detectors (registry, manifest, secrets, IaC) get a base 5; heuristic detectors (style, stale APIs, guidelines, graph) get a base 3; LLM-authored prose findings default to 4. + +| Key | Default | Effect | +| ---------------- | ------- | ------ | +| `judge_tier1` | `false` | Also run the LLM judge on deterministic tier-1 findings | +| `drop_ungrounded` | `false` | Drop findings with confidence <= 1 (after judge) | + +When a BYOK LLM is configured and enabled for the repo, the judge scores heuristic findings and stores `confidence` + `judge_rationale`. The judge is best-effort: LLM failure keeps base confidence and never fails the review. + ## Repo `config_json` (dashboard) ```json diff --git a/src/bot/commands.rs b/src/bot/commands.rs index 855b056..236cef4 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -1037,6 +1037,8 @@ async fn spawn_review(ctx: WebhookContext, pr_number: i64, timeout_secs: u64) { sender: None, repositories: None, repositories_added: None, + after: None, + commits: None, }; review_pr_with_options( &token, @@ -1259,6 +1261,14 @@ async fn spawn_impact(ctx: WebhookContext, pr_number: i64, timeout_secs: u64) { } else { text.push_str(&card); } + if let Some(pool) = crate::bot::bot_db_pool() { + let index_md = + crate::index::callers_markdown(pool, &ctx.repo_full_name, &changed_paths).await; + if !index_md.is_empty() { + text.push('\n'); + text.push_str(&index_md); + } + } text.push_str("\n---\n"); text.push_str(&crate::bot::markdown::commands_details()); post_issue_comment_kind( diff --git a/src/bot/concern.rs b/src/bot/concern.rs index f9eb982..e31d97d 100644 --- a/src/bot/concern.rs +++ b/src/bot/concern.rs @@ -107,6 +107,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, } } diff --git a/src/bot/markdown.rs b/src/bot/markdown.rs index 6cd4799..d55a01a 100644 --- a/src/bot/markdown.rs +++ b/src/bot/markdown.rs @@ -329,6 +329,8 @@ pub fn guide_label_parts(detector: &str, message: &str, file: &str, line: Option suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }; guide_label(&stub) } @@ -1166,6 +1168,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }; assert_eq!(short_fp(&f).len(), 12); } @@ -1182,6 +1186,8 @@ mod tests { suggestion: None, evidence: Some("AKIA".into()), codemod: None, + confidence: None, + judge_rationale: None, }; let body = inline_finding_comment(&f); assert!(body.contains("
")); @@ -1209,6 +1215,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }], }; let files = vec![ @@ -1396,6 +1404,8 @@ mod tests { suggestion: Some("Rotate abcdefghijklmnopqrstuvwxyz0123456789 and use env".into()), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }]; let prompt = agent_fix_prompt(&findings, "Add webhook retries").expect("prompt"); assert!(prompt.contains("## Findings (priority order)")); @@ -1420,6 +1430,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }, Finding { detector: "policy".into(), @@ -1431,6 +1443,8 @@ mod tests { suggestion: Some("raise max_blocking".into()), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }, Finding { detector: "todo-leaks".into(), @@ -1442,6 +1456,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }, ]; let prompt = agent_fix_prompt(&findings, "Fix todos").expect("prompt"); @@ -1462,6 +1478,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }; let b = Finding { detector: "todo-leaks".into(), @@ -1473,6 +1491,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }; let prior = vec![ ( @@ -1506,6 +1526,8 @@ mod tests { suggestion: None, evidence: Some("AKIA".into()), codemod: None, + confidence: None, + judge_rationale: None, }; let body = inline_finding_comment(&f); assert!(body.contains("**Do this:**")); diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 9ea37f5..25f281a 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -211,6 +211,29 @@ pub(crate) struct WebhookPayload { /// Sent in `installation_repositories.added` event #[serde(rename = "repositories_added")] repositories_added: Option>, + /// Head commit SHA of a `push` event + after: Option, + /// `push` event commits + commits: Option>, +} + +/// Paths added/modified across all commits of a `push` event payload. +fn payload_commits_paths(payload: &WebhookPayload) -> Vec { + let mut out = Vec::new(); + if let Some(commits) = payload.commits.as_ref() { + for commit in commits { + for key in ["added", "modified"] { + if let Some(list) = commit[key].as_array() { + for p in list { + if let Some(s) = p.as_str() { + out.push(s.to_string()); + } + } + } + } + } + } + out } /// Comment author association / identity from GitHub payload. @@ -516,6 +539,78 @@ pub(crate) async fn handle_webhook( } }); } + } else if event == "push" { + // Incremental symbol-index: re-parse files touched by the push. + let repo_full_name = payload + .repo + .as_ref() + .and_then(|r| r["full_name"].as_str()) + .unwrap_or("unknown") + .to_string(); + let inst_id = payload.installation.as_ref().map(|i| i.id); + let after_sha = payload.after.clone().unwrap_or_default(); + let deleted_branch = after_sha.is_empty() || after_sha.chars().all(|c| c == '0'); + let cfg = config.clone(); + let delivery = delivery_id.to_string(); + let changed: Vec = payload_commits_paths(&payload) + .into_iter() + .filter(|p| { + let lower = p.to_ascii_lowercase(); + lower.ends_with(".rs") + || lower.ends_with(".go") + || lower.ends_with(".py") + || lower.ends_with(".js") + || lower.ends_with(".jsx") + || lower.ends_with(".mjs") + || lower.ends_with(".ts") + || lower.ends_with(".tsx") + }) + .collect(); + if repo_full_name != "unknown" && !deleted_branch && !changed.is_empty() { + tokio::spawn(async move { + let span = tracing::info_span!( + "index_push_webhook", + delivery_id = %delivery, + repo = %repo_full_name + ); + let _enter = span.enter(); + let Some(pool) = bot_db_pool() else { + return; + }; + let index_cfg = crate::config::Config::load_for_bot(Some(pool)).await.index; + if !index_cfg.enabled { + return; + } + let Some(client) = crate::bot::review::github::GITHUB_CLIENT.as_ref() else { + return; + }; + let Ok(token) = crate::bot::auth::get_installation_token(&cfg, inst_id).await + else { + tracing::warn!("index: no installation token"); + return; + }; + let auth_header = format!("Bearer {token}"); + let Ok(headers) = crate::bot::review::github::github_api_headers(&auth_header) + else { + return; + }; + for path in &changed { + if let Err(e) = crate::index::reindex_file( + pool, + client, + &headers, + &repo_full_name, + &after_sha, + path, + &index_cfg, + ) + .await + { + tracing::warn!(error = %e, path, "incremental index failed"); + } + } + }); + } } else if event == "issue_comment" && payload.action == "created" { // Ignore our own (and other bots') comments — review footers mention // `@codasaurus help` / command names and must not re-trigger ACL denials. @@ -856,6 +951,8 @@ mod author_acl_tests { sender: None, repositories: None, repositories_added: None, + after: None, + commits: None, } } diff --git a/src/bot/policy.rs b/src/bot/policy.rs index 47a8eb1..fa6ca99 100644 --- a/src/bot/policy.rs +++ b/src/bot/policy.rs @@ -199,6 +199,8 @@ pub fn forbidden_path_findings(changed_paths: &[String], forbidden: &[String]) - ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); break; } @@ -226,6 +228,8 @@ pub fn enforce_count_caps(findings: &mut Vec, pack: &PolicyPack) { suggestion: Some("Fix blocking issues or raise max_blocking in policy.".into()), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } if warning > pack.max_warnings { @@ -242,6 +246,8 @@ pub fn enforce_count_caps(findings: &mut Vec, pack: &PolicyPack) { suggestion: Some("Address warnings or raise max_warnings in policy.".into()), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } } @@ -283,6 +289,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }; 3 ]; diff --git a/src/bot/provenance.rs b/src/bot/provenance.rs index 533878d..a19acf5 100644 --- a/src/bot/provenance.rs +++ b/src/bot/provenance.rs @@ -196,6 +196,8 @@ mod tests { suggestion: None, evidence: Some("AKIA".into()), codemod: None, + confidence: None, + judge_rationale: None, }; let line = provenance_line(&f); assert!(line.contains("secrets")); diff --git a/src/bot/quality.rs b/src/bot/quality.rs index ef38fb3..34d6101 100644 --- a/src/bot/quality.rs +++ b/src/bot/quality.rs @@ -88,6 +88,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, } } diff --git a/src/bot/review/findings.rs b/src/bot/review/findings.rs index d963203..ee0773c 100644 --- a/src/bot/review/findings.rs +++ b/src/bot/review/findings.rs @@ -103,6 +103,8 @@ pub(crate) fn merge_vulnerability_findings( ), suggestion: group.first().and_then(|f| f.suggestion.clone()), codemod: None, + confidence: None, + judge_rationale: None, evidence: None, }); } @@ -156,6 +158,8 @@ mod tests { suggestion: sug.map(|s| s.into()), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, } } diff --git a/src/bot/review/mod.rs b/src/bot/review/mod.rs index 82c1932..1217ef7 100644 --- a/src/bot/review/mod.rs +++ b/src/bot/review/mod.rs @@ -1,7 +1,7 @@ //! PR review pipeline: GitHub fetch, detectors, comments, and persistence. mod findings; -mod github; +pub(crate) mod github; mod llm; mod persist; mod pipeline; diff --git a/src/bot/review/persist.rs b/src/bot/review/persist.rs index aa9a863..c5f24a9 100644 --- a/src/bot/review/persist.rs +++ b/src/bot/review/persist.rs @@ -86,6 +86,8 @@ pub(crate) async fn save_review_to_db( .map(|s| crate::bot::markdown::redact_secrets(s)), context: None, category: None, + confidence: f.confidence.map(|c| c as i32), + judge_rationale: f.judge_rationale.clone(), }) .collect(); if let Err(e) = crate::db::reviews::create_findings_batch(pool, &batch).await { diff --git a/src/bot/review/pipeline.rs b/src/bot/review/pipeline.rs index 8abafd1..08946ce 100644 --- a/src/bot/review/pipeline.rs +++ b/src/bot/review/pipeline.rs @@ -692,6 +692,28 @@ pub async fn review_pr_with_options( } } + crate::confidence::apply_base(&mut findings.findings); + + if repo_llm_enabled { + if let Some(llm_cfg) = crate::llm::LlmConfig::from_db_or_env(pool).await { + match crate::llm::judge_findings(&llm_cfg, &findings.findings).await { + Ok(verdicts) => { + for v in verdicts { + if let Some(f) = findings.findings.get_mut(v.index) { + f.confidence = Some(v.confidence); + f.judge_rationale = Some(v.rationale); + } + } + } + Err(e) => tracing::warn!(error = %e, "LLM judge failed; keeping base confidence"), + } + } + } + + if config.confidence.drop_ungrounded { + crate::confidence::retain_grounded(&mut findings.findings); + } + let mut gate = crate::gates::QualityGate::from(config.quality_gate.clone()); if let Some(raw) = repo_config_json.as_deref() { if let Ok(value) = serde_json::from_str::(raw) { @@ -716,6 +738,16 @@ pub async fn review_pr_with_options( let blast_report = crate::bot::blast::estimate_blast_radius(&parsed_files_collected, &changed_paths); let blast_md = crate::bot::blast::blast_markdown(&blast_report); + let index_callers_md = if let Some(pool) = crate::bot::CONFIG_POOL.get() { + crate::index::callers_markdown(pool, repo_name, &changed_paths).await + } else { + String::new() + }; + let blast_md = if index_callers_md.is_empty() { + blast_md + } else { + format!("{blast_md}\n{index_callers_md}") + }; let vuln_pkgs: Vec = findings .findings .iter() diff --git a/src/bot/worker.rs b/src/bot/worker.rs index 2b4c384..f415f06 100644 --- a/src/bot/worker.rs +++ b/src/bot/worker.rs @@ -150,6 +150,8 @@ async fn process_queued_review( sender: None, repositories: None, repositories_added: None, + after: None, + commits: None, }; review_pr_with_options(&token, repo, &wrapped, opts).await }) @@ -226,6 +228,8 @@ pub(crate) async fn run_webhook_review_inline( sender: None, repositories: None, repositories_added: None, + after: None, + commits: None, }; review_pr_with_options(&token, &repo_full_name, &wrapped, opts).await }) diff --git a/src/confidence.rs b/src/confidence.rs new file mode 100644 index 0000000..c659ab5 --- /dev/null +++ b/src/confidence.rs @@ -0,0 +1,110 @@ +//! Confidence scoring for findings (0-5). +//! +//! Base confidence comes from detector class: deterministic registry checks +//! are certain, heuristic detectors need an LLM judge to be trusted. + +use crate::detectors::Finding; + +/// Confidence for a detector class, before any LLM judge runs. +/// +/// - 5: registry / manifest ground truth (imports, deps, licenses, secrets, IaC) +/// - 3: heuristic detectors (style, slop, stale APIs, guidelines, graph) +/// - 3: vulnerabilities (manifest-only; Phase 2 reachability uplifts to 5) +/// - 4: everything else (LLM-authored prose findings) +pub fn base_confidence(detector: &str) -> u8 { + match detector { + "hallucinated-imports" + | "phantom-deps" + | "lockfile-drift" + | "license-drift" + | "secrets" + | "iac" + | "risky-patterns" => 5, + "vulnerabilities" => 3, + "boilerplate" | "over-engineering" | "slop-detection" | "stale-api" | "graph" + | "guidelines" | "todo-leaks" | "policy" => 3, + _ => 4, + } +} + +/// Set `confidence` on any finding that does not already carry one. +pub fn apply_base(findings: &mut [Finding]) { + for f in findings.iter_mut() { + if f.confidence.is_none() { + f.confidence = Some(base_confidence(&f.detector)); + } + } +} + +/// Drop findings the pipeline cannot ground: confidence <= 1. +pub fn retain_grounded(findings: &mut Vec) { + findings.retain(|f| f.confidence.unwrap_or(0) >= 2); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn finding(detector: &str) -> Finding { + Finding { + detector: detector.to_string(), + severity: "warning", + file: "a.rs".into(), + line: 1, + column: 0, + message: "m".into(), + suggestion: None, + evidence: None, + codemod: None, + confidence: None, + judge_rationale: None, + } + } + + #[test] + fn registry_detectors_are_max_confidence() { + for d in [ + "hallucinated-imports", + "phantom-deps", + "lockfile-drift", + "license-drift", + "secrets", + "iac", + "risky-patterns", + ] { + assert_eq!(base_confidence(d), 5, "{d}"); + } + } + + #[test] + fn vulnerabilities_and_heuristics_are_3() { + assert_eq!(base_confidence("vulnerabilities"), 3); + for d in ["boilerplate", "stale-api", "slop-detection", "graph"] { + assert_eq!(base_confidence(d), 3, "{d}"); + } + } + + #[test] + fn unknown_detector_defaults_to_4() { + assert_eq!(base_confidence("some-llm-prose"), 4); + } + + #[test] + fn apply_base_fills_missing_only() { + let mut fs = vec![finding("secrets"), finding("stale-api")]; + fs[1].confidence = Some(2); + apply_base(&mut fs); + assert_eq!(fs[0].confidence, Some(5)); + assert_eq!(fs[1].confidence, Some(2)); + } + + #[test] + fn retain_grounded_drops_low_confidence() { + let mut fs = vec![finding("x"), finding("y")]; + fs[0].confidence = Some(1); + fs[1].confidence = Some(3); + retain_grounded(&mut fs); + assert_eq!(fs.len(), 1); + assert_eq!(fs[0].confidence, Some(3)); + } +} diff --git a/src/config.rs b/src/config.rs index 5e135c3..d1bccff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,12 @@ pub struct Config { #[serde(default)] pub quality_gate: QualityGateConfig, + + #[serde(default)] + pub confidence: ConfidenceConfig, + + #[serde(default)] + pub index: IndexConfig, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -161,6 +167,52 @@ pub struct QualityGateCondition { pub threshold: f64, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ConfidenceConfig { + #[serde(default = "default_false")] + pub judge_tier1: bool, + #[serde(default = "default_false")] + pub drop_ungrounded: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_index_languages")] + pub languages: Vec, + #[serde(default = "default_index_max_files")] + pub max_files: usize, +} + +impl Default for IndexConfig { + fn default() -> Self { + Self { + enabled: true, + languages: default_index_languages(), + max_files: default_index_max_files(), + } + } +} + +fn default_index_languages() -> Vec { + vec![ + "rust".into(), + "go".into(), + "python".into(), + "javascript".into(), + "typescript".into(), + ] +} + +fn default_index_max_files() -> usize { + 50_000 +} + +fn default_false() -> bool { + false +} + fn default_gate_name() -> String { "codasaurus way".into() } @@ -254,6 +306,8 @@ impl Default for Config { guidelines: GuidelinesConfig::default(), pre_merge: PreMergeConfig::default(), quality_gate: QualityGateConfig::default(), + confidence: ConfidenceConfig::default(), + index: IndexConfig::default(), } } } diff --git a/src/db/migrations.rs b/src/db/migrations.rs index a6df88d..1604ddb 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -251,6 +251,112 @@ pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::Error> { migrate_v15_invites_email_index(pool).await?; migrate_v16_dismissal_provenance(pool).await?; migrate_v17_baseline_and_gates(pool).await?; + migrate_v18_confidence(pool).await?; + migrate_v19_symbol_index(pool).await?; + Ok(()) +} + +/// v19: whole-repo symbol graph index (tree-sitter output persisted). +async fn migrate_v19_symbol_index(pool: &PgPool) -> Result<(), sqlx::Error> { + let current: Option = sqlx::query_scalar("SELECT MAX(version) FROM schema_version") + .fetch_one(pool) + .await?; + if current.unwrap_or(0) >= 19 { + return Ok(()); + } + + let _ = sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS repo_symbols ( + repo_full_name TEXT NOT NULL, + file_path TEXT NOT NULL, + symbol_name TEXT NOT NULL, + kind TEXT NOT NULL, + signature TEXT, + line INTEGER, + PRIMARY KEY (repo_full_name, file_path, symbol_name, line) + ) + "#, + ) + .execute(pool) + .await; + + let _ = sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_repo_symbols_lookup ON repo_symbols(repo_full_name, file_path)", + ) + .execute(pool) + .await; + + let _ = sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS repo_edges ( + repo_full_name TEXT NOT NULL, + from_symbol TEXT NOT NULL, + to_symbol TEXT NOT NULL, + edge_kind TEXT NOT NULL, + PRIMARY KEY (repo_full_name, from_symbol, to_symbol, edge_kind) + ) + "#, + ) + .execute(pool) + .await; + + let _ = sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_repo_edges_lookup ON repo_edges(repo_full_name, from_symbol)", + ) + .execute(pool) + .await; + + let _ = sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_repo_edges_target ON repo_edges(repo_full_name, to_symbol)", + ) + .execute(pool) + .await; + + let _ = sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS index_status ( + repo_full_name TEXT PRIMARY KEY, + status TEXT NOT NULL, + built_at TIMESTAMPTZ, + error TEXT + ) + "#, + ) + .execute(pool) + .await; + + sqlx::query( + "INSERT INTO schema_version (version) VALUES (19) ON CONFLICT (version) DO NOTHING", + ) + .execute(pool) + .await?; + + Ok(()) +} + +/// v18: per-finding confidence (0-5) + LLM judge rationale. +async fn migrate_v18_confidence(pool: &PgPool) -> Result<(), sqlx::Error> { + let current: Option = sqlx::query_scalar("SELECT MAX(version) FROM schema_version") + .fetch_one(pool) + .await?; + if current.unwrap_or(0) >= 18 { + return Ok(()); + } + + let _ = sqlx::query("ALTER TABLE findings ADD COLUMN IF NOT EXISTS confidence INTEGER") + .execute(pool) + .await; + + let _ = sqlx::query("ALTER TABLE findings ADD COLUMN IF NOT EXISTS judge_rationale TEXT") + .execute(pool) + .await; + + sqlx::query( + "INSERT INTO schema_version (version) VALUES (18) ON CONFLICT (version) DO NOTHING", + ) + .execute(pool) + .await?; Ok(()) } diff --git a/src/db/models.rs b/src/db/models.rs index d5f3793..500f3e1 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -83,6 +83,10 @@ pub struct Finding { pub code_snippet: Option, pub context: Option, pub category: Option, + #[sqlx(default)] + pub confidence: Option, + #[sqlx(default)] + pub judge_rationale: Option, pub created_at: DateTime, } @@ -103,6 +107,8 @@ pub struct FindingCreate { pub code_snippet: Option, pub context: Option, pub category: Option, + pub confidence: Option, + pub judge_rationale: Option, } #[derive(Serialize, Deserialize, Debug, Clone, FromRow)] diff --git a/src/detectors/graph.rs b/src/detectors/graph.rs index 84c2dae..96fdcc2 100644 --- a/src/detectors/graph.rs +++ b/src/detectors/graph.rs @@ -152,6 +152,8 @@ pub fn detect(parsed_files: &[ParsedFile]) -> Vec { ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); bfs_done += 1; continue; @@ -176,6 +178,8 @@ pub fn detect(parsed_files: &[ParsedFile]) -> Vec { ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/guidelines.rs b/src/detectors/guidelines.rs index b83342e..b5d81a3 100644 --- a/src/detectors/guidelines.rs +++ b/src/detectors/guidelines.rs @@ -61,6 +61,8 @@ pub fn detect_remote( suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } ExtractedRule::FileRequired { path } => { @@ -87,6 +89,8 @@ pub fn detect_remote( )), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } } @@ -145,6 +149,8 @@ fn check_branch_pattern_remote( suggestion: Some(format!("Rename branch to match '{pattern}'")), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } } @@ -180,6 +186,8 @@ fn check_sign_off_remote( ), evidence: Some(unsigned.join("\n")), codemod: None, + confidence: None, + judge_rationale: None, }); } } @@ -206,6 +214,8 @@ fn check_conventional_remote( ), evidence: Some(title.to_string()), codemod: None, + confidence: None, + judge_rationale: None, }); } @@ -234,6 +244,8 @@ fn check_conventional_remote( ), evidence: Some(non_conventional.join("\n")), codemod: None, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/hallucinated_imports.rs b/src/detectors/hallucinated_imports.rs index 3e1eee4..bdeba46 100644 --- a/src/detectors/hallucinated_imports.rs +++ b/src/detectors/hallucinated_imports.rs @@ -55,6 +55,8 @@ pub fn detect(parsed_files: &[ParsedFile]) -> Vec { package_registry_url(registry_name, &package) )), codemod: None, + confidence: None, + judge_rationale: None, evidence: None, }); } @@ -75,6 +77,8 @@ pub fn detect(parsed_files: &[ParsedFile]) -> Vec { )), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/iac.rs b/src/detectors/iac.rs index b81eb9e..2320c88 100644 --- a/src/detectors/iac.rs +++ b/src/detectors/iac.rs @@ -38,6 +38,8 @@ fn scan_terraform(file: &ParsedFile) -> Vec { suggestion: Some("Restrict ingress to known CIDRs or security groups.".into()), evidence: Some(line.trim().chars().take(120).collect()), codemod: None, + confidence: None, + judge_rationale: None, }); } if lower.contains("password") @@ -56,6 +58,8 @@ fn scan_terraform(file: &ParsedFile) -> Vec { suggestion: Some("Use secrets manager / sensitive variables.".into()), evidence: Some(line.trim().chars().take(80).collect()), codemod: None, + confidence: None, + judge_rationale: None, }); } if lower.contains("resource \"aws_security_group\"") { @@ -69,6 +73,8 @@ fn scan_terraform(file: &ParsedFile) -> Vec { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } } @@ -97,6 +103,8 @@ fn scan_k8s(file: &ParsedFile) -> Vec { suggestion: Some("Remove privileged: true unless absolutely required.".into()), evidence: Some(line.trim().into()), codemod: None, + confidence: None, + judge_rationale: None, }); } if trimmed == "hostnetwork: true" { @@ -110,6 +118,8 @@ fn scan_k8s(file: &ParsedFile) -> Vec { suggestion: Some("Avoid hostNetwork for untrusted workloads.".into()), evidence: Some(line.trim().into()), codemod: None, + confidence: None, + judge_rationale: None, }); } if trimmed.starts_with("value:") @@ -128,6 +138,8 @@ fn scan_k8s(file: &ParsedFile) -> Vec { suggestion: Some("Use Secret refs / external secrets instead of literals.".into()), evidence: Some(line.trim().chars().take(80).collect()), codemod: None, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/license_drift.rs b/src/detectors/license_drift.rs index c8d4c62..ff6a735 100644 --- a/src/detectors/license_drift.rs +++ b/src/detectors/license_drift.rs @@ -84,6 +84,8 @@ fn check_dep(findings: &mut Vec, file: &ParsedFile, registry: &str, dep ), evidence: Some(license), codemod: None, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/lockfile_drift.rs b/src/detectors/lockfile_drift.rs index 92b53ef..d3fd27b 100644 --- a/src/detectors/lockfile_drift.rs +++ b/src/detectors/lockfile_drift.rs @@ -176,6 +176,8 @@ fn lockfile_finding(manifest_path: &str, dep: &str, lockfile: &str) -> Finding { ), evidence: Some(dep.to_string()), codemod: None, + confidence: None, + judge_rationale: None, } } diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index 63bcfd7..1a616c2 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -53,6 +53,14 @@ pub struct Finding { /// Auto-fix codemod suggestion — a code snippet to replace the issue #[serde(default)] pub codemod: Option, + + /// Confidence 0-5 that the finding is a real problem (5 = certain). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confidence: Option, + + /// Judge rationale when an LLM judge scored this finding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub judge_rationale: Option, } impl Finding { diff --git a/src/detectors/phantom_deps.rs b/src/detectors/phantom_deps.rs index 17dae90..a77becd 100644 --- a/src/detectors/phantom_deps.rs +++ b/src/detectors/phantom_deps.rs @@ -78,6 +78,8 @@ pub fn detect(parsed_files: &[ParsedFile]) -> Vec { )), evidence: Some(import.name.clone()), codemod, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/risky_patterns.rs b/src/detectors/risky_patterns.rs index 120ab74..4d05dbe 100644 --- a/src/detectors/risky_patterns.rs +++ b/src/detectors/risky_patterns.rs @@ -145,6 +145,8 @@ pub fn detect(parsed_files: &[ParsedFile]) -> Vec { suggestion: Some(pattern.suggestion.to_string()), evidence: Some(trimmed.chars().take(160).collect()), codemod: None, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/security.rs b/src/detectors/security.rs index 54728d0..9258744 100644 --- a/src/detectors/security.rs +++ b/src/detectors/security.rs @@ -99,6 +99,8 @@ pub fn detect_secrets(parsed_files: &[ParsedFile]) -> Vec { )), evidence: Some(format!("`{masked}`")), codemod: None, + confidence: None, + judge_rationale: None, }); } } @@ -172,6 +174,8 @@ pub fn detect_todos(parsed_files: &[ParsedFile]) -> Vec { ), evidence: Some(trimmed.chars().take(120).collect()), codemod: None, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/slop.rs b/src/detectors/slop.rs index 95dff9e..1573e56 100644 --- a/src/detectors/slop.rs +++ b/src/detectors/slop.rs @@ -107,6 +107,8 @@ pub fn detect_slop( ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }] } else { vec![] diff --git a/src/detectors/stale_api.rs b/src/detectors/stale_api.rs index f38aa69..eddd40b 100644 --- a/src/detectors/stale_api.rs +++ b/src/detectors/stale_api.rs @@ -19,6 +19,8 @@ pub fn detect(parsed_files: &[ParsedFile]) -> Vec { suggestion: Some(pattern.suggestion.to_string()), evidence: Some(line.content.clone()), codemod: pattern.codemod.map(|s| s.to_string()), + confidence: None, + judge_rationale: None, }); } } diff --git a/src/detectors/style.rs b/src/detectors/style.rs index 2982836..0f6a99d 100644 --- a/src/detectors/style.rs +++ b/src/detectors/style.rs @@ -63,6 +63,8 @@ pub fn detect_boilerplate(parsed_files: &[ParsedFile]) -> Vec { ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } } @@ -116,6 +118,8 @@ fn check_single_impl_interface(path: &str, lines: &[&str]) -> Option { ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } @@ -165,6 +169,8 @@ fn check_deep_nesting(path: &str, lines: &[&str]) -> Option { ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }) } else { None @@ -202,6 +208,8 @@ fn check_unnecessary_factory(path: &str, content: &str) -> Option { ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }) } else { None @@ -240,6 +248,8 @@ fn check_abstraction_overload(path: &str, content: &str) -> Option { ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }) } else { None @@ -313,6 +323,8 @@ fn check_long_functions(path: &str, content: &str) -> Option { ), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }) } else { None @@ -354,6 +366,8 @@ fn check_repeated_code(path: &str, lines: &[&str]) -> Option { suggestion: Some("Extract repeated blocks into reusable functions.".to_string()), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }) } else { None @@ -401,6 +415,8 @@ fn check_boilerplate_getters_setters(path: &str, content: &str) -> Option Vec { )), evidence: Some(format!("{}: {}", vuln.id, vuln.summary)), codemod: None, + confidence: None, + judge_rationale: None, }); } } @@ -81,6 +83,8 @@ pub fn detect(parsed_files: &[ParsedFile]) -> Vec { )), evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } } diff --git a/src/gates.rs b/src/gates.rs index 6ac1729..04a1827 100644 --- a/src/gates.rs +++ b/src/gates.rs @@ -206,6 +206,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } for _ in 0..warning { @@ -219,6 +221,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } for _ in 0..info { @@ -232,6 +236,8 @@ mod tests { suggestion: None, evidence: None, codemod: None, + confidence: None, + judge_rationale: None, }); } out diff --git a/src/index/extract.rs b/src/index/extract.rs new file mode 100644 index 0000000..f29fc56 --- /dev/null +++ b/src/index/extract.rs @@ -0,0 +1,484 @@ +//! Tree-sitter symbol + edge extraction for the whole-repo index. +//! +//! Parses one source file and yields definitions (symbols) plus +//! CALLS / IMPORTS / EXTENDS / DEFINES edges. Unresolvable names are kept as +//! plain text — the graph is best-effort and query time does the matching. + +use tree_sitter::{Language, Node, Parser}; + +pub const SYMBOL_FUNCTION: &str = "function"; +pub const SYMBOL_CLASS: &str = "class"; +pub const SYMBOL_METHOD: &str = "method"; +pub const SYMBOL_CONST: &str = "const"; +pub const SYMBOL_IMPORT: &str = "import"; + +pub const EDGE_CALLS: &str = "CALLS"; +pub const EDGE_IMPORTS: &str = "IMPORTS"; +pub const EDGE_EXTENDS: &str = "EXTENDS"; +pub const EDGE_DEFINES: &str = "DEFINES"; + +#[derive(Debug, Clone)] +pub struct ExtractedSymbol { + pub name: String, + pub kind: String, + pub signature: Option, + pub line: i64, +} + +#[derive(Debug, Clone)] +pub struct ExtractedEdge { + pub from_symbol: String, + pub to_symbol: String, + pub edge_kind: String, +} + +#[derive(Debug, Clone)] +pub struct FileIndex { + pub file_path: String, + pub symbols: Vec, + pub edges: Vec, +} + +static RUST: std::sync::LazyLock = + std::sync::LazyLock::new(|| Language::new(tree_sitter_rust::LANGUAGE)); +static GO: std::sync::LazyLock = + std::sync::LazyLock::new(|| Language::new(tree_sitter_go::LANGUAGE)); +static PYTHON: std::sync::LazyLock = + std::sync::LazyLock::new(|| Language::new(tree_sitter_python::LANGUAGE)); +static JAVASCRIPT: std::sync::LazyLock = + std::sync::LazyLock::new(|| Language::new(tree_sitter_javascript::LANGUAGE)); +static TYPESCRIPT: std::sync::LazyLock = + std::sync::LazyLock::new(|| Language::new(tree_sitter_typescript::LANGUAGE_TYPESCRIPT)); + +fn language_for_path(path: &str) -> Option<&'static Language> { + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".rs") { + Some(&RUST) + } else if lower.ends_with(".go") { + Some(&GO) + } else if lower.ends_with(".py") { + Some(&PYTHON) + } else if lower.ends_with(".js") || lower.ends_with(".jsx") || lower.ends_with(".mjs") { + Some(&JAVASCRIPT) + } else if lower.ends_with(".ts") || lower.ends_with(".tsx") { + Some(&TYPESCRIPT) + } else { + None + } +} + +pub fn language_name(path: &str) -> Option<&'static str> { + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".rs") { + Some("rust") + } else if lower.ends_with(".go") { + Some("go") + } else if lower.ends_with(".py") { + Some("python") + } else if lower.ends_with(".js") || lower.ends_with(".jsx") || lower.ends_with(".mjs") { + Some("javascript") + } else if lower.ends_with(".ts") || lower.ends_with(".tsx") { + Some("typescript") + } else { + None + } +} + +/// Parse a source file into symbols + edges. `None` when the language is not +/// indexed or the tree fails to build (e.g. binary bytes). +pub fn extract_file(file_path: &str, content: &str) -> Option { + let language = language_for_path(file_path)?; + let mut parser = Parser::new(); + parser.set_language(language).ok()?; + + let tree = parser.parse(content, None)?; + let mut collector = Collector { + file_path, + content, + symbols: Vec::new(), + edges: Vec::new(), + scope: Vec::new(), + }; + collector.walk(tree.root_node()); + Some(FileIndex { + file_path: file_path.to_string(), + symbols: collector.symbols, + edges: collector.edges, + }) +} + +struct Collector<'a> { + file_path: &'a str, + content: &'a str, + symbols: Vec, + edges: Vec, + /// Enclosing definition names, innermost last. Methods/consts get + /// `Parent::name` qualification from the top of the stack. + scope: Vec, +} + +impl<'a> Collector<'a> { + fn text(&self, node: Node) -> Option { + node.utf8_text(self.content.as_bytes()) + .ok() + .map(|s| s.to_string()) + } + + fn node_name(&self, node: Node) -> Option { + node.child_by_field_name("name").and_then(|n| self.text(n)) + } + + fn enclosing(&self) -> Option<&str> { + self.scope.last().map(|s| s.as_str()) + } + + /// Record a symbol (+ DEFINES edge) and return its (possibly qualified) name. + fn add_symbol(&mut self, name: &str, kind: &str, node: Node) -> String { + let qualified = match (self.enclosing(), kind) { + (Some(parent), SYMBOL_METHOD) => format!("{parent}::{name}"), + _ => name.to_string(), + }; + let signature = self + .text(node) + .map(|t| t.lines().next().unwrap_or_default().trim().to_string()) + .map(|t| t.chars().take(160).collect()); + self.symbols.push(ExtractedSymbol { + name: qualified.clone(), + kind: kind.to_string(), + signature, + line: node.start_position().row as i64 + 1, + }); + self.edges.push(ExtractedEdge { + from_symbol: self.file_path.to_string(), + to_symbol: qualified.clone(), + edge_kind: EDGE_DEFINES.to_string(), + }); + qualified + } + + fn walk(&mut self, node: Node) { + let kind = node.kind(); + let mut pushed = false; + + match kind { + // --- Rust --- + "function_item" => { + if let Some(name) = self.node_name(node) { + let kind = if self.enclosing().is_some() { + SYMBOL_METHOD + } else { + SYMBOL_FUNCTION + }; + let qualified = self.add_symbol(&name, kind, node); + self.scope.push(qualified); + pushed = true; + } + } + "struct_item" | "enum_item" | "trait_item" | "union_item" => { + if let Some(name) = self.node_name(node) { + let qualified = self.add_symbol(&name, SYMBOL_CLASS, node); + self.scope.push(qualified); + pushed = true; + } + } + "impl_item" => { + let name = node.child_by_field_name("type").and_then(|n| self.text(n)); + if let Some(name) = name { + let qualified = self.add_symbol(&name, SYMBOL_CLASS, node); + self.scope.push(qualified); + pushed = true; + } + } + "const_item" | "static_item" => { + if let Some(name) = self.node_name(node) { + self.add_symbol(&name, SYMBOL_CONST, node); + } + } + "use_declaration" => { + if let Some(text) = self.text(node) { + let clean = text.trim_start_matches("use").trim().trim_end_matches(';'); + self.push_import(clean.to_string(), node); + } + } + // --- Go --- + "function_declaration" => { + if let Some(name) = self.node_name(node) { + let qualified = self.add_symbol(&name, SYMBOL_FUNCTION, node); + self.scope.push(qualified); + pushed = true; + } + } + "method_declaration" => { + if let Some(name) = self.node_name(node) { + let qualified = self.add_symbol(&name, SYMBOL_METHOD, node); + self.scope.push(qualified); + pushed = true; + } + } + "type_declaration" => { + if let Some(spec) = node.child_by_field_name("type") { + if let Some(name) = self.node_name(spec) { + let qualified = self.add_symbol(&name, SYMBOL_CLASS, node); + self.scope.push(qualified); + pushed = true; + } + } + } + "const_declaration" | "var_declaration" => { + if let Some(name) = self.node_name(node) { + self.add_symbol(&name, SYMBOL_CONST, node); + } + } + "import_declaration" => { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() == "import_spec" { + if let Some(path) = child.child_by_field_name("path") { + if let Some(clean) = self + .text(path) + .map(|t| t.trim_matches('"').to_string()) + .filter(|t| !t.is_empty()) + { + self.push_import(clean, node); + } + } + } + } + } + // --- Python --- + "function_definition" => { + if let Some(name) = self.node_name(node) { + let kind = if self.enclosing().is_some() { + SYMBOL_METHOD + } else { + SYMBOL_FUNCTION + }; + let qualified = self.add_symbol(&name, kind, node); + self.scope.push(qualified); + pushed = true; + } + } + "class_definition" => { + if let Some(name) = self.node_name(node) { + let qualified = self.add_symbol(&name, SYMBOL_CLASS, node); + if let Some(superclasses) = node.child_by_field_name("superclasses") { + for child in superclasses.named_children(&mut superclasses.walk()) { + if let Some(base) = self.text(child) { + self.edges.push(ExtractedEdge { + from_symbol: name.clone(), + to_symbol: base, + edge_kind: EDGE_EXTENDS.to_string(), + }); + } + } + } + self.scope.push(qualified); + pushed = true; + } + } + "import_statement" | "import_from_statement" => { + if let Some(text) = self.text(node) { + let clean = text.replace("import", "").trim().to_string(); + self.push_import(clean, node); + } + } + // --- JavaScript / TypeScript --- + "class_declaration" | "interface_declaration" => { + if let Some(name) = self.node_name(node) { + let qualified = self.add_symbol(&name, SYMBOL_CLASS, node); + if let Some(heritage) = node.child_by_field_name("heritage") { + for child in heritage.named_children(&mut heritage.walk()) { + if let Some(base) = self.text(child) { + self.edges.push(ExtractedEdge { + from_symbol: name.clone(), + to_symbol: base, + edge_kind: EDGE_EXTENDS.to_string(), + }); + } + } + } + self.scope.push(qualified); + pushed = true; + } + } + "method_definition" => { + if let Some(name) = self.node_name(node) { + let qualified = self.add_symbol(&name, SYMBOL_METHOD, node); + self.scope.push(qualified); + pushed = true; + } + } + "variable_declarator" => { + let is_const = node + .parent() + .and_then(|p| p.child(0)) + .and_then(|k| self.text(k)) + .map(|t| t == "const") + .unwrap_or(false); + if is_const { + if let Some(name) = self.node_name(node) { + self.add_symbol(&name, SYMBOL_CONST, node); + } + } + } + _ => {} + } + + // CALLS edges: name the callee from the call's function field. + if matches!(kind, "call_expression" | "call") { + if let Some(callee) = node.child_by_field_name("function") { + let callee_name = if callee.kind() == "member_expression" { + callee + .child_by_field_name("property") + .and_then(|n| self.text(n)) + } else if callee.kind() == "field_expression" { + callee + .child_by_field_name("field") + .and_then(|n| self.text(n)) + } else { + callee + .child_by_field_name("name") + .and_then(|n| self.text(n)) + .or_else(|| self.text(callee)) + }; + if let Some(target) = callee_name { + if let Some(caller) = self.enclosing() { + self.edges.push(ExtractedEdge { + from_symbol: caller.to_string(), + to_symbol: target, + edge_kind: EDGE_CALLS.to_string(), + }); + } + } + } + } + + // Recurse, then pop the scope entry this node pushed (if any). + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + self.walk(child); + } + if pushed { + self.scope.pop(); + } + } + + fn push_import(&mut self, name: String, node: Node) { + self.symbols.push(ExtractedSymbol { + name: name.clone(), + kind: SYMBOL_IMPORT.to_string(), + signature: None, + line: node.start_position().row as i64 + 1, + }); + self.edges.push(ExtractedEdge { + from_symbol: self.file_path.to_string(), + to_symbol: name, + edge_kind: EDGE_IMPORTS.to_string(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rust_extracts_functions_and_calls() { + let src = r#" +fn greet(name: &str) -> String { + format!("hi {}", name) +} + +fn main() { + let g = greet("x"); +} +"#; + let idx = extract_file("src/main.rs", src).unwrap(); + let names: Vec<&str> = idx.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"greet"), "{names:?}"); + assert!(names.contains(&"main"), "{names:?}"); + let calls = idx + .edges + .iter() + .filter(|e| e.edge_kind == EDGE_CALLS) + .collect::>(); + assert!( + calls + .iter() + .any(|e| e.from_symbol == "main" && e.to_symbol == "greet"), + "{calls:?}" + ); + } + + #[test] + fn python_extracts_class_and_extends() { + let src = "import os\nclass Animal:\n pass\nclass Dog(Animal):\n pass\n"; + let idx = extract_file("app.py", src).unwrap(); + let names: Vec<&str> = idx.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Animal"), "{names:?}"); + assert!(names.contains(&"Dog"), "{names:?}"); + assert!(idx.edges.iter().any(|e| { + e.edge_kind == EDGE_EXTENDS && e.from_symbol == "Dog" && e.to_symbol == "Animal" + })); + } + + #[test] + fn go_extracts_imports() { + let src = "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"hi\") }\n"; + let idx = extract_file("main.go", src).unwrap(); + assert!(idx + .edges + .iter() + .any(|e| e.edge_kind == EDGE_IMPORTS && e.to_symbol == "fmt")); + assert!(idx.symbols.iter().any(|s| s.name == "main")); + } + + #[test] + fn javascript_extracts_methods() { + let src = "class User {\n async load() { return fetch('/u'); }\n}\nconst MAX = 5;\n"; + let idx = extract_file("user.js", src).unwrap(); + let names: Vec<&str> = idx.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"User"), "{names:?}"); + assert!(names.contains(&"User::load"), "{names:?}"); + assert!(names.contains(&"MAX"), "{names:?}"); + } + + #[test] + fn rust_impl_methods_qualified() { + let src = "struct User {}\nimpl User {\n fn load() -> u8 { 1 }\n}\n"; + let idx = extract_file("src/user.rs", src).unwrap(); + let names: Vec<&str> = idx.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"User"), "{names:?}"); + assert!(names.contains(&"User::load"), "{names:?}"); + } + + #[test] + fn python_class_methods_qualified() { + let src = "class A:\n def go(self):\n pass\n"; + let idx = extract_file("a.py", src).unwrap(); + let names: Vec<&str> = idx.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"A"), "{names:?}"); + assert!(names.contains(&"A::go"), "{names:?}"); + } + + #[test] + fn javascript_method_calls_use_field_name() { + let src = "class A {\n go() { return this.b(); }\n}\n"; + let idx = extract_file("a.js", src).unwrap(); + let calls: Vec = idx + .edges + .iter() + .filter(|e| e.edge_kind == EDGE_CALLS) + .map(|e| e.to_symbol.clone()) + .collect(); + assert!(calls.contains(&"b".to_string()), "{calls:?}"); + assert!(idx.edges.iter().any(|e| { + e.edge_kind == EDGE_CALLS && e.from_symbol == "A::go" && e.to_symbol == "b" + })); + } + + #[test] + fn unsupported_extension_returns_none() { + assert!(extract_file("data.csv", "a,b\n").is_none()); + } +} diff --git a/src/index/mod.rs b/src/index/mod.rs new file mode 100644 index 0000000..7aa6583 --- /dev/null +++ b/src/index/mod.rs @@ -0,0 +1,194 @@ +//! Whole-repo symbol graph index: tree-sitter extraction + Postgres store. + +pub mod extract; +pub mod store; + +use crate::config::IndexConfig; +use extract::FileIndex; + +/// List every file path in the repo at `git_ref` via the Git Trees API +/// (`GET /repos/{repo}/git/trees/{sha}?recursive=1`), capped by max_files. +pub async fn list_repo_files( + client: &reqwest::Client, + headers: &reqwest::header::HeaderMap, + repo_full_name: &str, + git_ref: &str, + max_files: usize, +) -> Result, anyhow::Error> { + let url = + format!("https://api.github.com/repos/{repo_full_name}/git/trees/{git_ref}?recursive=1"); + let resp = crate::retry::retry_async( + &crate::retry::RetryConfig::api_default(), + "index_tree", + &crate::retry::is_reqwest_error_retryable, + || async { + client + .get(&url) + .headers(headers.clone()) + .send() + .await + .map_err(Into::into) + }, + ) + .await?; + if !resp.status().is_success() { + anyhow::bail!("git trees API returned {}", resp.status()); + } + let json: serde_json::Value = resp.json().await?; + let mut paths: Vec = json["tree"] + .as_array() + .map(|arr| { + arr.iter() + .filter(|t| t["type"].as_str() == Some("blob")) + .filter_map(|t| t["path"].as_str().map(|p| p.to_string())) + .filter(|p| extract::language_name(p).is_some()) + .collect() + }) + .unwrap_or_default(); + paths.truncate(max_files); + Ok(paths) +} + +/// Build the index for one repo: list files, fetch + parse the ones whose +/// language is enabled, persist. Returns the number of files indexed. +pub async fn build_repo_index( + pool: &crate::db::DbPool, + client: &reqwest::Client, + headers: &reqwest::header::HeaderMap, + repo_full_name: &str, + git_ref: &str, + config: &IndexConfig, +) -> Result { + let paths = list_repo_files(client, headers, repo_full_name, git_ref, config.max_files).await?; + let mut files: Vec = Vec::new(); + + for path in &paths { + let Some(lang) = extract::language_name(path) else { + continue; + }; + if !config.languages.iter().any(|l| l == lang) { + continue; + } + if path.starts_with("vendor/") + || path.starts_with("node_modules/") + || path.starts_with(".git/") + || path.starts_with("target/") + { + continue; + } + let content = crate::bot::github_files::fetch_repo_file( + client, + headers, + repo_full_name, + path, + git_ref, + ) + .await; + let Ok(Some(content)) = content else { + continue; + }; + if let Some(idx) = extract::extract_file(path, &content) { + files.push(idx); + } + } + + store::replace_repo_index(pool, repo_full_name, &files).await?; + Ok(files.len()) +} + +/// Whole-repo callers of symbols defined in `paths`, as compact markdown. +/// Empty when the repo has no index rows (callers fall back to diff-only impact). +pub async fn callers_markdown( + pool: &crate::db::DbPool, + repo_full_name: &str, + paths: &[String], +) -> String { + let mut symbols: Vec<(String, String)> = Vec::new(); + for path in paths { + if let Ok(rows) = store::symbols_in_file(pool, repo_full_name, path).await { + for (sym, kind, _) in rows { + if kind != extract::SYMBOL_IMPORT { + symbols.push((sym, path.clone())); + } + } + } + } + if symbols.is_empty() { + return String::new(); + } + let mut sections: Vec = Vec::new(); + for (sym, path) in symbols.iter().take(30) { + let Ok(edges) = store::callers_of(pool, repo_full_name, sym).await else { + continue; + }; + let mut callers: Vec = edges + .into_iter() + .filter(|(_, _, k)| k == extract::EDGE_CALLS || k == extract::EDGE_EXTENDS) + .map(|(f, _, _)| f) + .collect(); + callers.sort(); + callers.dedup(); + if !callers.is_empty() { + let listed = callers + .iter() + .take(8) + .map(|c| format!("`{c}`")) + .collect::>() + .join(", "); + sections.push(format!("- `{sym}` ({path}) <- {listed}")); + } + } + if sections.is_empty() { + return String::new(); + } + let mut out = String::from("**Indexed callers of changed symbols:**\n\n"); + out.push_str(§ions.join("\n")); + out.push('\n'); + out +} + +/// Incremental: re-index one changed file. +pub async fn reindex_file( + pool: &crate::db::DbPool, + client: &reqwest::Client, + headers: &reqwest::header::HeaderMap, + repo_full_name: &str, + git_ref: &str, + path: &str, + config: &IndexConfig, +) -> Result<(), anyhow::Error> { + let Some(lang) = extract::language_name(path) else { + return Ok(()); + }; + if !config.languages.iter().any(|l| l == lang) { + return Ok(()); + } + let content = + crate::bot::github_files::fetch_repo_file(client, headers, repo_full_name, path, git_ref) + .await?; + let Some(content) = content else { + return Ok(()); + }; + if let Some(idx) = extract::extract_file(path, &content) { + store::replace_file_index(pool, repo_full_name, &idx).await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_gating_respects_config() { + let config = IndexConfig { + enabled: true, + languages: vec!["python".into()], + max_files: 1000, + }; + assert_eq!(extract::language_name("a.py"), Some("python")); + assert_eq!(extract::language_name("a.rs"), Some("rust")); + assert!(config.languages.iter().any(|l| l == "python")); + assert!(!config.languages.iter().any(|l| l == "rust")); + } +} diff --git a/src/index/store.rs b/src/index/store.rs new file mode 100644 index 0000000..5d6adb4 --- /dev/null +++ b/src/index/store.rs @@ -0,0 +1,190 @@ +//! PostgreSQL persistence for the whole-repo symbol index. + +use crate::db::DbPool; +use crate::index::extract::FileIndex; +use sqlx::Row; + +const INDEX_READY: &str = "ready"; +const INDEX_FAILED: &str = "failed"; + +/// Wipe the repo's index and insert a fresh full build in one transaction. +pub async fn replace_repo_index( + pool: &DbPool, + repo_full_name: &str, + files: &[FileIndex], +) -> Result<(), sqlx::Error> { + let mut tx = pool.as_pg().begin().await?; + sqlx::query("DELETE FROM repo_symbols WHERE repo_full_name = $1") + .bind(repo_full_name) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM repo_edges WHERE repo_full_name = $1") + .bind(repo_full_name) + .execute(&mut *tx) + .await?; + insert_files(&mut tx, repo_full_name, files).await?; + upsert_status(&mut tx, repo_full_name, INDEX_READY, None).await?; + tx.commit().await +} + +/// Incremental update: re-parse one changed file, replacing only its rows. +pub async fn replace_file_index( + pool: &DbPool, + repo_full_name: &str, + file: &FileIndex, +) -> Result<(), sqlx::Error> { + let mut tx = pool.as_pg().begin().await?; + sqlx::query("DELETE FROM repo_symbols WHERE repo_full_name = $1 AND file_path = $2") + .bind(repo_full_name) + .bind(&file.file_path) + .execute(&mut *tx) + .await?; + sqlx::query( + "DELETE FROM repo_edges WHERE repo_full_name = $1 AND + (from_symbol = $2 OR + from_symbol IN (SELECT symbol_name FROM repo_symbols WHERE repo_full_name = $1 AND file_path = $2))", + ) + .bind(repo_full_name) + .bind(&file.file_path) + .execute(&mut *tx) + .await?; + insert_files(&mut tx, repo_full_name, std::slice::from_ref(file)).await?; + upsert_status(&mut tx, repo_full_name, INDEX_READY, None).await?; + tx.commit().await +} + +type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>; + +async fn insert_files(tx: &mut Tx<'_>, repo: &str, files: &[FileIndex]) -> Result<(), sqlx::Error> { + for file in files { + for sym in &file.symbols { + sqlx::query( + "INSERT INTO repo_symbols (repo_full_name, file_path, symbol_name, kind, signature, line) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (repo_full_name, file_path, symbol_name, line) DO NOTHING", + ) + .bind(repo) + .bind(&file.file_path) + .bind(&sym.name) + .bind(&sym.kind) + .bind(&sym.signature) + .bind(sym.line) + .execute(&mut **tx) + .await?; + } + for edge in &file.edges { + sqlx::query( + "INSERT INTO repo_edges (repo_full_name, from_symbol, to_symbol, edge_kind) + VALUES ($1, $2, $3, $4) + ON CONFLICT (repo_full_name, from_symbol, to_symbol, edge_kind) DO NOTHING", + ) + .bind(repo) + .bind(&edge.from_symbol) + .bind(&edge.to_symbol) + .bind(&edge.edge_kind) + .execute(&mut **tx) + .await?; + } + } + Ok(()) +} + +async fn upsert_status( + tx: &mut Tx<'_>, + repo: &str, + status: &str, + error: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO index_status (repo_full_name, status, built_at, error) + VALUES ($1, $2, NOW(), $3) + ON CONFLICT (repo_full_name) + DO UPDATE SET status = EXCLUDED.status, + built_at = EXCLUDED.built_at, + error = EXCLUDED.error", + ) + .bind(repo) + .bind(status) + .bind(error) + .execute(&mut **tx) + .await?; + Ok(()) +} + +pub async fn mark_index_failed(pool: &DbPool, repo: &str, error: &str) { + if let Ok(mut tx) = pool.as_pg().begin().await { + let _ = upsert_status(&mut tx, repo, INDEX_FAILED, Some(error)).await; + let _ = tx.commit().await; + } +} + +pub async fn index_status(pool: &DbPool, repo: &str) -> Option<(String, String)> { + sqlx::query("SELECT status, COALESCE(error, '') FROM index_status WHERE repo_full_name = $1") + .bind(repo) + .fetch_optional(pool.as_pg()) + .await + .ok() + .flatten() + .map(|row| (row.get(0), row.get(1))) +} + +/// Callers of `symbol`: reverse CALLS/EXTENDS edges pointing at it. +pub async fn callers_of( + pool: &DbPool, + repo: &str, + symbol: &str, +) -> Result, sqlx::Error> { + sqlx::query( + "SELECT from_symbol, to_symbol, edge_kind FROM repo_edges + WHERE repo_full_name = $1 AND to_symbol = $2 + ORDER BY from_symbol LIMIT 50", + ) + .bind(repo) + .bind(symbol) + .fetch_all(pool.as_pg()) + .await + .map(|rows| { + rows.into_iter() + .map(|r| (r.get(0), r.get(1), r.get(2))) + .collect() + }) +} + +/// Symbols defined in a file, so callers can be resolved by name. +pub async fn symbols_in_file( + pool: &DbPool, + repo: &str, + file_path: &str, +) -> Result, sqlx::Error> { + sqlx::query( + "SELECT symbol_name, kind, COALESCE(line, 0) FROM repo_symbols + WHERE repo_full_name = $1 AND file_path = $2 + ORDER BY line", + ) + .bind(repo) + .bind(file_path) + .fetch_all(pool.as_pg()) + .await + .map(|rows| { + rows.into_iter() + .map(|r| (r.get(0), r.get(1), r.get(2))) + .collect() + }) +} + +/// Files defining a symbol (reverse DEFINES edges). +pub async fn files_defining_symbol( + pool: &DbPool, + repo: &str, + symbol: &str, +) -> Result, sqlx::Error> { + sqlx::query( + "SELECT file_path FROM repo_symbols + WHERE repo_full_name = $1 AND symbol_name = $2", + ) + .bind(repo) + .bind(symbol) + .fetch_all(pool.as_pg()) + .await + .map(|rows| rows.into_iter().map(|r| r.get(0)).collect()) +} diff --git a/src/lib.rs b/src/lib.rs index 1ece2c1..19a72fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod baseline; pub mod bot; pub mod bot_runtime; +pub mod confidence; pub mod config; pub mod context; pub mod db; @@ -10,6 +11,7 @@ pub mod detectors; pub mod gates; pub mod github_jwt; pub mod graph; +pub mod index; pub mod learning; pub mod llm; pub mod metrics; diff --git a/src/llm/mod.rs b/src/llm/mod.rs index bfa1b6a..8f2db40 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -918,10 +918,137 @@ async fn chat_completion_text( .inspect_err(|_| crate::metrics::record_llm_error()) } +/// LLM judge verdict for one finding: 0-5 confidence + rationale. +#[derive(Debug, Clone)] +pub struct JudgeOutcome { + pub index: usize, + pub confidence: u8, + pub rationale: String, +} + +const MAX_JUDGE_FINDINGS: usize = 25; + +/// Score findings 0-5 via the LLM. Best-effort: errors return empty verdicts. +/// +/// Prompts the model with detector/file/line/message and asks for a strict JSON +/// `{"verdicts":[{"index":0,"confidence":3,"rationale":"..."}]}`. Findings are +/// identified by position; indices outside the batch are ignored. +pub async fn judge_findings( + config: &LlmConfig, + findings: &[crate::detectors::Finding], +) -> Result> { + if findings.is_empty() { + return Ok(Vec::new()); + } + assert_endpoint_safe(config).await?; + let client = llm_client()?; + let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); + + let batch: Vec = findings + .iter() + .take(MAX_JUDGE_FINDINGS) + .enumerate() + .map(|(i, f)| { + json!({ + "index": i, + "detector": f.detector, + "file": f.file, + "line": f.line, + "message": truncate_chars(&f.message, 300), + }) + }) + .collect(); + + let system_prompt = "\ +You are a skeptical review judge. For each finding, decide whether it is a real \ +problem (5) or noise (0) based only on the evidence shown. Never trust the \ +detector's own severity. Output strict JSON only: \ +{\"verdicts\":[{\"index\":,\"confidence\":<0-5>,\"rationale\":\"\"}]}. \ +Empty verdicts when nothing is grounded."; + + let user_prompt = format!("Judge these findings:\n{}", serde_json::to_string(&batch)?); + let max_tokens = 1024; + crate::metrics::record_llm_request(user_prompt.len() + system_prompt.len(), max_tokens, false); + + let text = chat_completion_text( + client, + &url, + config, + system_prompt, + &user_prompt, + max_tokens, + ) + .await?; + parse_judge_verdicts(&text) +} + +fn parse_judge_verdicts(text: &str) -> Result> { + let mut outcomes = Vec::new(); + let value: serde_json::Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => { + let Some(start) = text.find('{') else { + return Ok(Vec::new()); + }; + let Some(end) = text.rfind('}') else { + return Ok(Vec::new()); + }; + match serde_json::from_str(&text[start..=end]) { + Ok(v) => v, + Err(_) => return Ok(Vec::new()), + } + } + }; + if let Some(verdicts) = value.get("verdicts").and_then(|v| v.as_array()) { + for v in verdicts { + let index = v["index"].as_u64().unwrap_or(u64::MAX) as usize; + let confidence = v["confidence"].as_u64().unwrap_or(0).min(5) as u8; + let rationale = v["rationale"].as_str().unwrap_or("").to_string(); + outcomes.push(JudgeOutcome { + index, + confidence, + rationale, + }); + } + } + Ok(outcomes) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn parses_judge_verdicts_json() { + let raw = r#"{"verdicts":[{"index":0,"confidence":4,"rationale":"real vuln"},{"index":2,"confidence":1,"rationale":"noise"}]}"#; + let v = parse_judge_verdicts(raw).unwrap(); + assert_eq!(v.len(), 2); + assert_eq!(v[0].index, 0); + assert_eq!(v[0].confidence, 4); + assert_eq!(v[1].index, 2); + assert_eq!(v[1].confidence, 1); + } + + #[test] + fn parses_verdicts_wrapped_in_code_fence() { + let raw = "```json\n{\"verdicts\":[{\"index\":1,\"confidence\":5,\"rationale\":\"certain\"}]}\n```"; + let v = parse_judge_verdicts(raw).unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v[0].confidence, 5); + } + + #[test] + fn clamps_confidence_to_5() { + let raw = r#"{"verdicts":[{"index":0,"confidence":99,"rationale":"overconfident"}]}"#; + let v = parse_judge_verdicts(raw).unwrap(); + assert_eq!(v[0].confidence, 5); + } + + #[test] + fn garbage_input_yields_empty() { + assert!(parse_judge_verdicts("not json at all").unwrap().is_empty()); + } + #[tokio::test] async fn offline_mode_blocks_llm_config_without_pool() { let prev = std::env::var("CODASAURUS_OFFLINE").ok(); diff --git a/svelte-dashboard/src/pages/app/ReviewDetail.svelte b/svelte-dashboard/src/pages/app/ReviewDetail.svelte index 7c00804..c5aec4f 100644 --- a/svelte-dashboard/src/pages/app/ReviewDetail.svelte +++ b/svelte-dashboard/src/pages/app/ReviewDetail.svelte @@ -364,6 +364,12 @@ {/if} {finding.detector} + {#if finding.confidence != null} + c{finding.confidence} + {/if} {#if finding.fingerprint} {shortFp(finding)} {/if} @@ -518,6 +524,14 @@ background: transparent; } + .rd-chip.conf { + cursor: help; + font-variant-numeric: tabular-nums; + } + .rd-chip.conf-0, .rd-chip.conf-1 { color: var(--danger, #ef4444); border-color: color-mix(in srgb, var(--danger, #ef4444) 40%, var(--border)); } + .rd-chip.conf-2, .rd-chip.conf-3 { color: var(--warning, #f59e0b); border-color: color-mix(in srgb, var(--warning, #f59e0b) 40%, var(--border)); } + .rd-chip.conf-4, .rd-chip.conf-5 { color: var(--success, #22c55e); border-color: color-mix(in srgb, var(--success, #22c55e) 40%, var(--border)); } + .rd-filters { margin: 0; align-items: end;