From ae3b37b4efbfdc7e09f6e3114568e622aae63469 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 11 Jul 2026 12:35:15 +0000 Subject: [PATCH 1/2] feat: add blockOnKinds gate predicate and finding IDs Implement finding kind-based gate blocking and stable finding IDs for the hosted approval system. - Add blockOnKinds config (Vec) with HumanEscalation default to gate logic - New gate predicate: findings block if severity fails OR kind is in blockOnKinds - Generate stable finding IDs: SHA256 hash of (head_sha, kind, normalized_path, normalized_line, normalized_title, duplicate_index) - Add blockOnKinds to Gate envelope for auditability - Update check-run summaries to mark kind-blocked findings - Bump version to 0.3.0 - Add sha2 dependency for finding ID generation --- Cargo.lock | 73 +++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- src/config.rs | 17 ++++++++++- src/envelope.rs | 33 ++++++++++++++++++++++ src/filter.rs | 1 + src/forge/mod.rs | 3 ++ src/llm.rs | 4 +++ src/output.rs | 2 ++ src/plan.rs | 2 ++ src/review.rs | 49 ++++++++++++++++++++++++++++++-- src/sarif.rs | 2 ++ 11 files changed, 183 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b850ede..a4acbaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -250,6 +259,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.10.1" @@ -266,6 +281,24 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -311,6 +344,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -605,6 +649,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -988,7 +1041,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "postil-cli" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "assert_cmd", @@ -1001,6 +1054,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "similar", "tempfile", "tokio", @@ -1464,6 +1518,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1760,6 +1825,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index da1a616..95adc54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "postil-cli" -version = "0.2.1" +version = "0.3.0" edition = "2024" description = "Postil: a low-noise AI review gate. Silent on clean PRs, hard gate on real risk." license = "Apache-2.0" @@ -23,6 +23,7 @@ reqwest = { version = "0.13.4", default-features = false, features = ["json", "r serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" serde_yaml = "0.9.34" +sha2 = "0.11.0" similar = "3.1.1" tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "process", "time"] } diff --git a/src/config.rs b/src/config.rs index bd7761f..94717c5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use crate::envelope::Severity; +use crate::envelope::{Severity, Kind}; pub const DEFAULT_MODEL: &str = "deepseek/deepseek-v4-pro"; pub const DEFAULT_CASCADE: [&str; 3] = [ @@ -89,6 +89,8 @@ pub struct Config { pub gate_fail_on: GateLevel, /// Gate behavior on operational error. Default: fail closed. pub gate_on_error: OnError, + /// Finding kinds that block the gate regardless of severity. Default: [HumanEscalation]. + pub block_on_kinds: Vec, pub model: String, pub cascade: Vec, pub api_base: String, @@ -122,6 +124,7 @@ impl Default for Config { on_clean: OnClean::Skip, gate_fail_on: GateLevel::Severity(Severity::Error), gate_on_error: OnError::Block, + block_on_kinds: vec![Kind::HumanEscalation], model: DEFAULT_MODEL.to_string(), cascade: DEFAULT_CASCADE.iter().map(|m| (*m).to_string()).collect(), api_base: DEFAULT_API_BASE.to_string(), @@ -169,6 +172,7 @@ pub struct ReviewSection { pub struct GateSection { pub fail_on: Option, pub on_error: Option, + pub block_on_kinds: Option>, } #[derive(Debug, Default, Deserialize, Serialize)] @@ -305,6 +309,15 @@ impl Config { if let Some(oe) = g.on_error { self.gate_on_error = oe; } + if let Some(kinds) = g.block_on_kinds { + let mut parsed_kinds = Vec::new(); + for kind_name in kinds { + parsed_kinds.push(Kind::parse(&kind_name).with_context(|| { + format!("invalid gate.blockOnKinds entry {kind_name:?} (risk|humanEscalation|guardrail|uncertainty|contentPolicy)") + })?); + } + self.block_on_kinds = parsed_kinds; + } } if let Some(m) = f.model { if let Some(n) = m.name { @@ -463,6 +476,8 @@ review: gate: failOn: error # the postil/gate check fails at/above: info | warn | error | never + # blockOnKinds: # finding kinds that block regardless of severity (default: [humanEscalation]) + # - humanEscalation # onError: block # block (default, fail closed) | advisory — gate outcome when # # the review itself errors (model outage). advisory keeps an # # outage from freezing merges; the review check goes neutral, not green. diff --git a/src/envelope.rs b/src/envelope.rs index b1696e7..c3dad79 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -48,6 +48,17 @@ pub enum Kind { } impl Kind { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "risk" => Some(Kind::Risk), + "humanescalation" => Some(Kind::HumanEscalation), + "guardrail" => Some(Kind::Guardrail), + "uncertainty" => Some(Kind::Uncertainty), + "contentpolicy" => Some(Kind::ContentPolicy), + _ => None, + } + } + pub fn as_str(self) -> &'static str { match self { Kind::Risk => "risk", @@ -71,6 +82,10 @@ pub struct Finding { pub confidence: f64, pub title: String, pub body: String, + /// Stable, engine-generated finding ID for deduplication and approval tracking. + /// Hash of (head_sha, kind, normalized_path, normalized_line, normalized_title, duplicate_index). + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] @@ -92,6 +107,9 @@ pub struct Gate { /// Severity at or above which the gate check fails. "never" disables the gate. pub fail_on: String, pub failing: bool, + /// Finding kinds that block the gate regardless of severity. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub block_on_kinds: Vec, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] @@ -184,6 +202,7 @@ pub fn fail_closed_finding(detail: &str) -> Finding { "Postil could not obtain a valid, diff-grounded review from the configured \ model(s) and is failing closed rather than passing unreviewed code.\n\nDetail: {detail}" ), + id: None, } } @@ -211,6 +230,7 @@ pub fn narrated_risk_finding(summary: &str) -> Finding { Re-run the review; if the contradiction persists, inspect the areas the \ summary names and address them or record findings manually." ), + id: None, } } @@ -228,6 +248,7 @@ pub fn provider_error_finding(detail: &str) -> Finding { "Postil could not complete the model request and is failing closed rather \ than passing unreviewed code.\n\nDetail: {detail}" ), + id: None, } } @@ -245,6 +266,7 @@ mod tests { confidence: conf, title: "t".into(), body: "b".into(), + id: None, } } @@ -270,6 +292,16 @@ mod tests { assert_eq!(Severity::parse("nope"), None); } + #[test] + fn kind_parse() { + assert_eq!(Kind::parse("risk"), Some(Kind::Risk)); + assert_eq!(Kind::parse("humanescalation"), Some(Kind::HumanEscalation)); + assert_eq!(Kind::parse("guardrail"), Some(Kind::Guardrail)); + assert_eq!(Kind::parse("uncertainty"), Some(Kind::Uncertainty)); + assert_eq!(Kind::parse("contentpolicy"), Some(Kind::ContentPolicy)); + assert_eq!(Kind::parse("unknown"), None); + } + #[test] fn envelope_serializes_camel_case() { let env = Envelope { @@ -283,6 +315,7 @@ mod tests { gate: Gate { fail_on: "error".into(), failing: false, + block_on_kinds: vec![], }, model_used: "m".into(), usage: Usage::default(), diff --git a/src/filter.rs b/src/filter.rs index a3a54e8..9954ece 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -206,6 +206,7 @@ mod tests { confidence: conf, title: "t".into(), body: "b".into(), + id: None, } } diff --git a/src/forge/mod.rs b/src/forge/mod.rs index f28a2dd..a21b308 100644 --- a/src/forge/mod.rs +++ b/src/forge/mod.rs @@ -326,6 +326,7 @@ mod tests { confidence: 0.91, title: "Unsanitized input reaches query".into(), body: "user_input flows into exec_query.".into(), + id: None, } } @@ -376,6 +377,7 @@ mod tests { gate: crate::envelope::Gate { fail_on: "error".into(), failing: false, + block_on_kinds: vec![], }, model_used: "m".into(), usage: Default::default(), @@ -401,6 +403,7 @@ mod tests { gate: crate::envelope::Gate { fail_on: "error".into(), failing: false, + block_on_kinds: vec![], }, model_used: "none (disabled by config)".into(), usage: Default::default(), diff --git a/src/llm.rs b/src/llm.rs index fe8c62a..f538492 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -505,6 +505,7 @@ fn into_review(raw: RawReview, model: &str, usage: Usage) -> ModelReview { confidence: f.confidence.clamp(0.0, 1.0), title, body: f.body, + id: None, } }) .collect(); @@ -699,6 +700,7 @@ mod tests { confidence: conf, title: "t".into(), body: "b".into(), + id: None, }], model_used: model.into(), usage: Usage { @@ -721,6 +723,7 @@ mod tests { confidence: 0.99, title: "solo".into(), body: "b".into(), + id: None, }); let merged = consensus_merge(vec![a, b]); assert_eq!(merged.findings.len(), 1); @@ -757,6 +760,7 @@ mod tests { confidence: 0.9, title: "t2".into(), body: "b2".into(), + id: None, }); let merged = consensus_merge(vec![a, b]); assert!(merged.findings.is_empty()); diff --git a/src/output.rs b/src/output.rs index 2eb3a24..20eca17 100644 --- a/src/output.rs +++ b/src/output.rs @@ -249,6 +249,7 @@ mod tests { confidence: 0.9, title: "\x1b[2Jhijacked title".into(), body: "line one\n\x1b[31mFAKE ALL CLEAR\x1b[0m\nline three".into(), + id: None, }; let env = Envelope { version: 1, @@ -261,6 +262,7 @@ mod tests { gate: Gate { fail_on: "error".into(), failing: false, + block_on_kinds: vec![], }, model_used: "m".into(), usage: Default::default(), diff --git a/src/plan.rs b/src/plan.rs index 7daa10b..3911d23 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -148,6 +148,7 @@ mod tests { gate: Gate { fail_on: "error".into(), failing: gate_failing, + block_on_kinds: vec![], }, model_used: "m".into(), usage: Usage::default(), @@ -168,6 +169,7 @@ mod tests { confidence: conf, title: "t".into(), body: "b".into(), + id: None, } } diff --git a/src/review.rs b/src/review.rs index 044b91c..09c6094 100644 --- a/src/review.rs +++ b/src/review.rs @@ -13,6 +13,8 @@ use crate::forge::{ clean_review_message, github::GitHub, gitlab::GitLab, }; use crate::llm::LlmClient; +use sha2::{Sha256, Digest}; +use std::collections::HashMap; use crate::local::{self, LocalSource}; use crate::output::{self, OutputFormat}; use crate::prompt::{self, PrContext}; @@ -370,6 +372,39 @@ fn load_baseline(args: &ReviewArgs) -> Result> { } /// Core engine: diff text in, envelope out. No forge I/O. +/// Generate stable IDs for findings based on (head_sha, kind, normalized_path, normalized_line, normalized_title, duplicate_index). +fn generate_finding_ids(findings: &mut [Finding], head_sha: Option<&str>) { + if head_sha.is_none() { + return; + } + + let head_sha = head_sha.unwrap(); + let mut id_map: HashMap = HashMap::new(); + + for finding in findings.iter_mut() { + // Normalize the finding data + let normalized_path = finding.path.to_lowercase(); + let normalized_line = finding.line.to_string(); + let normalized_title = finding.title.trim().to_lowercase(); + + // Create a pre-hash key to track duplicates + let prehash_key = format!("{}{}{}{}", head_sha, finding.kind.as_str(), normalized_path, normalized_line); + let duplicate_index = id_map.entry(prehash_key).and_modify(|count| *count += 1).or_insert(0); + + // Build the hash input + let hash_input = format!("{}\x00{}\x00{}\x00{}\x00{}\x00{}", + head_sha, finding.kind.as_str(), normalized_path, normalized_line, normalized_title, duplicate_index); + + // Generate SHA256 hash + let mut hasher = Sha256::new(); + hasher.update(hash_input.as_bytes()); + let result = hasher.finalize(); + let hex_id = result.iter().map(|b| format!("{:02x}", b)).collect::(); + + finding.id = Some(hex_id); + } +} + async fn review_diff(cfg: &Config, args: &ReviewArgs, input: ReviewInput<'_>) -> Result { let ReviewInput { diff_text, @@ -501,6 +536,7 @@ async fn review_diff(cfg: &Config, args: &ReviewArgs, input: ReviewInput<'_>) -> kind: crate::envelope::Kind::Uncertainty, confidence: 1.0, title: "Diff truncated at the review size limit".to_string(), + id: None, body: format!( "This change exceeds the {} KB review limit; only the first part was \ reviewed. Files beyond the cut were not assessed. Split the change \ @@ -540,13 +576,17 @@ async fn review_diff(cfg: &Config, args: &ReviewArgs, input: ReviewInput<'_>) -> // induce it via prompt injection. let advisory_on_error = cfg.gate_on_error == OnError::Advisory; let gate_failing = findings.iter().any(|f| { - cfg.gate_fail_on.fails(f.severity) - && !(advisory_on_error && f.path == crate::envelope::PROVIDER_PATH) + (cfg.gate_fail_on.fails(f.severity) + && !(advisory_on_error && f.path == crate::envelope::PROVIDER_PATH)) + || cfg.block_on_kinds.contains(&f.kind) }); let silent = findings.is_empty(); let mut counts = Envelope::counts_of(&findings, suppressed); counts.ungrounded = ungrounded; let buckets = Envelope::buckets_of(&findings); + + // Generate stable IDs for findings + generate_finding_ids(&mut findings, head_sha.as_deref()); Ok(Envelope { version: 1, @@ -559,6 +599,7 @@ async fn review_diff(cfg: &Config, args: &ReviewArgs, input: ReviewInput<'_>) -> gate: Gate { fail_on: cfg.gate_fail_on.as_str().to_string(), failing: gate_failing, + block_on_kinds: cfg.block_on_kinds.iter().map(|k| k.as_str().to_string()).collect(), }, model_used, usage, @@ -702,6 +743,7 @@ fn error_envelope( gate: Gate { fail_on: cfg.gate_fail_on.as_str().to_string(), failing: blocking, + block_on_kinds: cfg.block_on_kinds.iter().map(|k| k.as_str().to_string()).collect(), }, model_used: cfg.model_chain().join(" -> "), usage: Usage::default(), @@ -727,7 +769,8 @@ mod tests { confidence: 0.9, title: "Finding".to_string(), body: body.to_string(), - } + id: None, + } } #[test] diff --git a/src/sarif.rs b/src/sarif.rs index 4e9af51..250c100 100644 --- a/src/sarif.rs +++ b/src/sarif.rs @@ -128,6 +128,7 @@ mod tests { gate: Gate { fail_on: "error".into(), failing: true, + block_on_kinds: vec![], }, model_used: "m".into(), usage: Usage::default(), @@ -149,6 +150,7 @@ mod tests { confidence: 0.9, title: "Bug".into(), body: "details".into(), + id: None, }; let s = to_sarif(&env_with(vec![f])); let r = &s["runs"][0]["results"][0]; From 799ec29fe9c6342a2c8d565e9379c6dc995d353f Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 11 Jul 2026 12:38:35 +0000 Subject: [PATCH 2/2] style: format code with cargo fmt --- src/config.rs | 2 +- src/filter.rs | 2 +- src/forge/mod.rs | 6 ++--- src/llm.rs | 8 +++--- src/output.rs | 4 +-- src/plan.rs | 4 +-- src/review.rs | 63 ++++++++++++++++++++++++++++++++++-------------- src/sarif.rs | 4 +-- 8 files changed, 60 insertions(+), 33 deletions(-) diff --git a/src/config.rs b/src/config.rs index 94717c5..7d24f45 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use crate::envelope::{Severity, Kind}; +use crate::envelope::{Kind, Severity}; pub const DEFAULT_MODEL: &str = "deepseek/deepseek-v4-pro"; pub const DEFAULT_CASCADE: [&str; 3] = [ diff --git a/src/filter.rs b/src/filter.rs index 9954ece..c4857d3 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -206,7 +206,7 @@ mod tests { confidence: conf, title: "t".into(), body: "b".into(), - id: None, + id: None, } } diff --git a/src/forge/mod.rs b/src/forge/mod.rs index a21b308..6c400d9 100644 --- a/src/forge/mod.rs +++ b/src/forge/mod.rs @@ -326,7 +326,7 @@ mod tests { confidence: 0.91, title: "Unsanitized input reaches query".into(), body: "user_input flows into exec_query.".into(), - id: None, + id: None, } } @@ -377,7 +377,7 @@ mod tests { gate: crate::envelope::Gate { fail_on: "error".into(), failing: false, - block_on_kinds: vec![], + block_on_kinds: vec![], }, model_used: "m".into(), usage: Default::default(), @@ -403,7 +403,7 @@ mod tests { gate: crate::envelope::Gate { fail_on: "error".into(), failing: false, - block_on_kinds: vec![], + block_on_kinds: vec![], }, model_used: "none (disabled by config)".into(), usage: Default::default(), diff --git a/src/llm.rs b/src/llm.rs index f538492..ee4fafb 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -505,7 +505,7 @@ fn into_review(raw: RawReview, model: &str, usage: Usage) -> ModelReview { confidence: f.confidence.clamp(0.0, 1.0), title, body: f.body, - id: None, + id: None, } }) .collect(); @@ -700,7 +700,7 @@ mod tests { confidence: conf, title: "t".into(), body: "b".into(), - id: None, + id: None, }], model_used: model.into(), usage: Usage { @@ -723,7 +723,7 @@ mod tests { confidence: 0.99, title: "solo".into(), body: "b".into(), - id: None, + id: None, }); let merged = consensus_merge(vec![a, b]); assert_eq!(merged.findings.len(), 1); @@ -760,7 +760,7 @@ mod tests { confidence: 0.9, title: "t2".into(), body: "b2".into(), - id: None, + id: None, }); let merged = consensus_merge(vec![a, b]); assert!(merged.findings.is_empty()); diff --git a/src/output.rs b/src/output.rs index 20eca17..ab026f3 100644 --- a/src/output.rs +++ b/src/output.rs @@ -249,7 +249,7 @@ mod tests { confidence: 0.9, title: "\x1b[2Jhijacked title".into(), body: "line one\n\x1b[31mFAKE ALL CLEAR\x1b[0m\nline three".into(), - id: None, + id: None, }; let env = Envelope { version: 1, @@ -262,7 +262,7 @@ mod tests { gate: Gate { fail_on: "error".into(), failing: false, - block_on_kinds: vec![], + block_on_kinds: vec![], }, model_used: "m".into(), usage: Default::default(), diff --git a/src/plan.rs b/src/plan.rs index 3911d23..dc7bcef 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -148,7 +148,7 @@ mod tests { gate: Gate { fail_on: "error".into(), failing: gate_failing, - block_on_kinds: vec![], + block_on_kinds: vec![], }, model_used: "m".into(), usage: Usage::default(), @@ -169,7 +169,7 @@ mod tests { confidence: conf, title: "t".into(), body: "b".into(), - id: None, + id: None, } } diff --git a/src/review.rs b/src/review.rs index 09c6094..7947ba9 100644 --- a/src/review.rs +++ b/src/review.rs @@ -13,11 +13,11 @@ use crate::forge::{ clean_review_message, github::GitHub, gitlab::GitLab, }; use crate::llm::LlmClient; -use sha2::{Sha256, Digest}; -use std::collections::HashMap; use crate::local::{self, LocalSource}; use crate::output::{self, OutputFormat}; use crate::prompt::{self, PrContext}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; const MAX_DIFF_BYTES: usize = 400_000; /// Hard cap on the raw fetched diff text before parsing. A generous multiple of @@ -377,30 +377,49 @@ fn generate_finding_ids(findings: &mut [Finding], head_sha: Option<&str>) { if head_sha.is_none() { return; } - + let head_sha = head_sha.unwrap(); let mut id_map: HashMap = HashMap::new(); - + for finding in findings.iter_mut() { // Normalize the finding data let normalized_path = finding.path.to_lowercase(); let normalized_line = finding.line.to_string(); let normalized_title = finding.title.trim().to_lowercase(); - + // Create a pre-hash key to track duplicates - let prehash_key = format!("{}{}{}{}", head_sha, finding.kind.as_str(), normalized_path, normalized_line); - let duplicate_index = id_map.entry(prehash_key).and_modify(|count| *count += 1).or_insert(0); - + let prehash_key = format!( + "{}{}{}{}", + head_sha, + finding.kind.as_str(), + normalized_path, + normalized_line + ); + let duplicate_index = id_map + .entry(prehash_key) + .and_modify(|count| *count += 1) + .or_insert(0); + // Build the hash input - let hash_input = format!("{}\x00{}\x00{}\x00{}\x00{}\x00{}", - head_sha, finding.kind.as_str(), normalized_path, normalized_line, normalized_title, duplicate_index); - + let hash_input = format!( + "{}\x00{}\x00{}\x00{}\x00{}\x00{}", + head_sha, + finding.kind.as_str(), + normalized_path, + normalized_line, + normalized_title, + duplicate_index + ); + // Generate SHA256 hash let mut hasher = Sha256::new(); hasher.update(hash_input.as_bytes()); let result = hasher.finalize(); - let hex_id = result.iter().map(|b| format!("{:02x}", b)).collect::(); - + let hex_id = result + .iter() + .map(|b| format!("{:02x}", b)) + .collect::(); + finding.id = Some(hex_id); } } @@ -578,13 +597,13 @@ async fn review_diff(cfg: &Config, args: &ReviewArgs, input: ReviewInput<'_>) -> let gate_failing = findings.iter().any(|f| { (cfg.gate_fail_on.fails(f.severity) && !(advisory_on_error && f.path == crate::envelope::PROVIDER_PATH)) - || cfg.block_on_kinds.contains(&f.kind) + || cfg.block_on_kinds.contains(&f.kind) }); let silent = findings.is_empty(); let mut counts = Envelope::counts_of(&findings, suppressed); counts.ungrounded = ungrounded; let buckets = Envelope::buckets_of(&findings); - + // Generate stable IDs for findings generate_finding_ids(&mut findings, head_sha.as_deref()); @@ -599,7 +618,11 @@ async fn review_diff(cfg: &Config, args: &ReviewArgs, input: ReviewInput<'_>) -> gate: Gate { fail_on: cfg.gate_fail_on.as_str().to_string(), failing: gate_failing, - block_on_kinds: cfg.block_on_kinds.iter().map(|k| k.as_str().to_string()).collect(), + block_on_kinds: cfg + .block_on_kinds + .iter() + .map(|k| k.as_str().to_string()) + .collect(), }, model_used, usage, @@ -743,7 +766,11 @@ fn error_envelope( gate: Gate { fail_on: cfg.gate_fail_on.as_str().to_string(), failing: blocking, - block_on_kinds: cfg.block_on_kinds.iter().map(|k| k.as_str().to_string()).collect(), + block_on_kinds: cfg + .block_on_kinds + .iter() + .map(|k| k.as_str().to_string()) + .collect(), }, model_used: cfg.model_chain().join(" -> "), usage: Usage::default(), @@ -770,7 +797,7 @@ mod tests { title: "Finding".to_string(), body: body.to_string(), id: None, - } + } } #[test] diff --git a/src/sarif.rs b/src/sarif.rs index 250c100..d7bd43e 100644 --- a/src/sarif.rs +++ b/src/sarif.rs @@ -128,7 +128,7 @@ mod tests { gate: Gate { fail_on: "error".into(), failing: true, - block_on_kinds: vec![], + block_on_kinds: vec![], }, model_used: "m".into(), usage: Usage::default(), @@ -150,7 +150,7 @@ mod tests { confidence: 0.9, title: "Bug".into(), body: "details".into(), - id: None, + id: None, }; let s = to_sarif(&env_with(vec![f])); let r = &s["runs"][0]["results"][0];