diff --git a/CHANGELOG.md b/CHANGELOG.md index bb313f0..3f11bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.16.0] - 2026-10-08 +### Added + +- **Rule-scoped inline suppression with `cora-ignore:`.** A source comment such as `// cora-ignore: Hardcoded password or secret in variable` (also `#`, `--`, `/* */`, ``; comma-separated rules) suppresses findings with that exact title (case-insensitive) on the same line, or on the next line when the marker line holds only a comment. Applies to both static-scanner and LLM findings; other rules and other lines stay visible, and a bare `cora-ignore` without rules suppresses nothing. Coexists with `ignore.rules` / `ignore.files` (#554). + ### Security - **Project `.cora.yaml` can no longer redirect your API key.** `provider.base_url` from a discovered project config is ignored unless `CORA_TRUST_PROJECT_CONFIG=1`; `base_url` must be `https` (plain `http` only for loopback); LLM error bodies echoed to the terminal are length-capped (#563). diff --git a/docs/configuration.md b/docs/configuration.md index e51048c..bf3ae45 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -486,6 +486,24 @@ brain: > **Note:** If you select `pretrained` but cora was built without the `pretrained-embed` feature, it falls back to `hashing` with a warning. +## Inline Suppression (`cora-ignore`) + +`ignore.rules` hides a finding title across the whole project. To suppress one finding at one location, put a marker in a source comment: + +```ts +const bytesPerToken = 4; // cora-ignore: Hardcoded password or secret in variable + +// cora-ignore: Rule A, Rule B (comment-only line: applies to the next line) +const next = compute(); +``` + +- Syntax: `cora-ignore: [, ...]`, matched as a substring of the line, so it works after `//`, `#`, `--`, inside `/* */` or ``, etc. The marker is case-insensitive. +- `` is the finding **title**, matched exactly and case-insensitively (not a substring, unlike `ignore.rules`). +- Scope: the same line; plus the next line when the marker line contains only a comment. +- A bare `cora-ignore` (no `:` or empty rule list) suppresses nothing, so every suppression names its rule. +- Applies to both deterministic scanner and LLM findings, in `cora review`. Other rules on the same line and the rest of the project stay visible. Works alongside `ignore.rules` and `ignore.files`. +- Markers are read from the diff (added and context lines); a marker outside the diff's context window is not seen. + ## Ignore Files Exclude files or directories from **all** cora operations — review, scan, and indexing. This is the broadest exclusion mechanism. diff --git a/src/engine/inline_suppress.rs b/src/engine/inline_suppress.rs new file mode 100644 index 0000000..3bd4fc6 --- /dev/null +++ b/src/engine/inline_suppress.rs @@ -0,0 +1,291 @@ +//! Rule-scoped inline suppression of findings (#554). +//! +//! A source comment containing `cora-ignore: [, ...]` suppresses +//! findings whose title equals one of the listed rules (case-insensitive, +//! trimmed — the same identity `ignore.rules` uses for titles, but exact match +//! rather than substring so a marker cannot hide more than it names). +//! +//! * The marker applies to the line it is on, and, when the line contains only +//! a comment (nothing but the comment syntax before the marker), also to the +//! next line. +//! * The marker is matched as the substring `cora-ignore:` anywhere in a line, +//! so it works after `//`, `#`, `--`, `/* */`, ``, and so on. +//! * A bare `cora-ignore` with no `:` and rule list never suppresses anything. +//! +//! One filter ([`apply`]) is applied to the merged deterministic + LLM findings. +//! It reads the post-change lines present in the parsed diff (added and +//! context lines), so no file I/O is needed. A marker that is not visible in +//! the diff (more than the context window away from the finding) is not seen. + +use crate::engine::ReviewIssue; +use crate::engine::diff_parser::{DiffLineType, FileChunk}; +use std::collections::{HashMap, HashSet}; +use tracing::debug; + +const MARKER: &str = "cora-ignore:"; + +/// Parse the rule list from a line carrying the marker, lowercased. +/// The marker is located on an ASCII-lowercased copy so byte offsets stay valid. +/// Returns an empty vec when there is no marker or no rule after it. +fn parse_rules(line: &str) -> Vec { + let lower = line.to_ascii_lowercase(); + let Some(idx) = lower.find(MARKER) else { + return Vec::new(); + }; + let mut rest = &lower[idx + MARKER.len()..]; + // Drop a trailing block/HTML comment terminator. + for end in ["*/", "-->"] { + if let Some(i) = rest.find(end) { + rest = &rest[..i]; + } + } + rest.split(',') + .map(|r| r.trim().to_lowercase()) + .filter(|r| !r.is_empty()) + .collect() +} + +/// True when everything before the marker is comment syntax / whitespace. +fn is_comment_only(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + let Some(idx) = lower.find(MARKER) else { + return false; + }; + let before = line[..idx].trim(); + !before.is_empty() + && before.chars().all(|c| { + matches!( + c, + '/' | '*' | '#' | '-' | ';' | '<' | '!' | '%' | '"' | '\'' + ) || c.is_whitespace() + }) +} + +/// Drop findings suppressed by an inline `cora-ignore:` marker. +pub fn apply(mut issues: Vec, chunks: &[FileChunk]) -> Vec { + // (file, line) -> lowercased rules suppressed there. + let mut suppressed: HashMap<(String, u32), HashSet> = HashMap::new(); + for chunk in chunks { + let path = chunk + .new_path + .as_deref() + .or(chunk.old_path.as_deref()) + .unwrap_or(""); + for hunk in &chunk.chunks { + for line in &hunk.lines { + if line.line_type == DiffLineType::Remove { + continue; + } + let Some(ln) = line.new_line_no else { continue }; + let rules = parse_rules(&line.content); + if rules.is_empty() { + if line.content.to_lowercase().contains("cora-ignore") { + debug!( + file = path, + line = ln, + "bare cora-ignore ignored: rule list required" + ); + } + continue; + } + suppressed + .entry((path.to_string(), ln)) + .or_default() + .extend(rules.iter().cloned()); + if is_comment_only(&line.content) { + suppressed + .entry((path.to_string(), ln + 1)) + .or_default() + .extend(rules); + } + } + } + } + if suppressed.is_empty() { + return issues; + } + + let before = issues.len(); + issues.retain(|issue| { + let Some(ln) = issue.line else { return true }; + match suppressed.get(&(issue.file.clone(), ln)) { + Some(rules) => !rules.contains(issue.title.trim().to_lowercase().as_str()), + None => true, + } + }); + let dropped = before - issues.len(); + if dropped > 0 { + debug!(dropped, "suppressed findings via inline cora-ignore"); + } + issues +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::Severity; + use crate::engine::diff_parser::parse_diff; + + const RULE: &str = "Hardcoded password or secret in variable"; + + fn issue(file: &str, line: u32, title: &str) -> ReviewIssue { + ReviewIssue { + file: file.to_string(), + line: Some(line), + severity: Severity::Major, + issue_type: Some("rule".to_string()), + title: title.to_string(), + body: String::new(), + suggested_fix: None, + } + } + + /// Diff adding `lines` to `f.rs` starting at line 1. + fn diff_of(lines: &[&str]) -> Vec { + let mut d = format!( + "diff --git a/f.rs b/f.rs\nnew file mode 100644\n--- /dev/null\n+++ b/f.rs\n@@ -0,0 +1,{} @@\n", + lines.len() + ); + for l in lines { + d.push('+'); + d.push_str(l); + d.push('\n'); + } + parse_diff(&d) + } + + #[test] + fn same_line_suppresses() { + let c = diff_of(&[ + "let bytesPerToken = 4; // cora-ignore: Hardcoded password or secret in variable", + ]); + assert!(apply(vec![issue("f.rs", 1, RULE)], &c).is_empty()); + } + + #[test] + fn next_line_when_comment_only() { + let c = diff_of(&[ + "// cora-ignore: Hardcoded password or secret in variable", + "let x = 1;", + ]); + assert!(apply(vec![issue("f.rs", 2, RULE)], &c).is_empty()); + } + + #[test] + fn next_line_not_suppressed_when_marker_trails_code() { + let c = diff_of(&[ + "let a = 1; // cora-ignore: Hardcoded password or secret in variable", + "let b = 2;", + ]); + assert_eq!(apply(vec![issue("f.rs", 2, RULE)], &c).len(), 1); + } + + #[test] + fn multiple_rules() { + let c = diff_of(&["x(); // cora-ignore: Rule A, Rule B"]); + let out = apply( + vec![ + issue("f.rs", 1, "Rule A"), + issue("f.rs", 1, "rule b"), + issue("f.rs", 1, "Rule C"), + ], + &c, + ); + assert_eq!(out.len(), 1); + assert_eq!(out[0].title, "Rule C"); + } + + #[test] + fn unrelated_rule_and_other_lines_stay() { + let c = diff_of(&["x(); // cora-ignore: Rule A", "y();"]); + let out = apply( + vec![issue("f.rs", 1, "Other"), issue("f.rs", 2, "Rule A")], + &c, + ); + assert_eq!(out.len(), 2); + } + + #[test] + fn other_file_not_suppressed() { + let c = diff_of(&["x(); // cora-ignore: Rule A"]); + assert_eq!(apply(vec![issue("g.rs", 1, "Rule A")], &c).len(), 1); + } + + #[test] + fn bare_marker_ignored() { + for l in [ + "x(); // cora-ignore", + "x(); // cora-ignore:", + "x(); // cora-ignore: , ", + ] { + let c = diff_of(&[l]); + assert_eq!(apply(vec![issue("f.rs", 1, "Rule A")], &c).len(), 1, "{l}"); + } + } + + #[test] + fn comment_styles() { + for l in [ + "x = 1 # cora-ignore: Rule A", + "x = 1 -- cora-ignore: Rule A", + "x = 1; /* cora-ignore: Rule A */", + "

hi

", + ] { + let c = diff_of(&[l]); + assert!( + apply(vec![issue("f.rs", 1, "Rule A")], &c).is_empty(), + "{l}" + ); + } + } + + #[test] + fn block_comment_terminator_not_part_of_rule() { + let c = diff_of(&["/* cora-ignore: Rule A, Rule B */", "z();"]); + assert!(apply(vec![issue("f.rs", 2, "Rule B")], &c).is_empty()); + } + + #[test] + fn case_insensitive_marker_and_rule() { + let c = diff_of(&["x(); // CORA-IGNORE: HARDCODED PASSWORD OR SECRET IN VARIABLE"]); + assert!(apply(vec![issue("f.rs", 1, RULE)], &c).is_empty()); + } + + #[test] + fn non_ascii_before_marker_does_not_panic() { + let c = diff_of(&["let s = \"\u{130}\u{130}\"; // cora-ignore: Rule A"]); + assert!(apply(vec![issue("f.rs", 1, "Rule A")], &c).is_empty()); + } + + #[test] + fn exact_title_not_substring() { + let c = diff_of(&["x(); // cora-ignore: Hardcoded password"]); + assert_eq!(apply(vec![issue("f.rs", 1, RULE)], &c).len(), 1); + } + + /// Real scanner output flows through the same filter (#554). + #[test] + fn suppresses_real_scanner_finding() { + let line = r#"const password = "hunter2hunter2";"#; + let plain = diff_of(&[line]); + let found = crate::engine::security_scanner::scan_security(&plain, 50); + let found_all: Vec<_> = found + .into_iter() + .chain(crate::engine::secrets_scanner::scan_secrets(&plain, 50)) + .collect(); + assert!(!found_all.is_empty(), "scanner should flag the fixture"); + let title = found_all[0].title.clone(); + let issues = crate::engine::rules::merge_rule_findings(vec![], found_all); + assert!(!issues.is_empty()); + + let marked = diff_of(&[&format!("{line} // cora-ignore: {title}")]); + let issues_marked: Vec<_> = issues + .iter() + .filter(|i| i.title == title) + .cloned() + .collect(); + assert!(apply(issues_marked, &marked).is_empty()); + // Without the marker the finding stays. + assert!(!apply(issues, &plain).is_empty()); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 9b1dd5d..1e12326 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -9,6 +9,7 @@ pub mod diff_parser; pub mod enclosing; pub mod index_bridge; pub mod index_scanner; +pub mod inline_suppress; pub mod language_analyzer; pub mod llm; pub mod markdown; diff --git a/src/engine/review.rs b/src/engine/review.rs index 87f8ca9..3642921 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -310,6 +310,8 @@ async fn review_diff_inner( }; fallback.issues = apply_markdown_code_block_filter(fallback.issues, &diff_chunks); fallback.issues = apply_ignore_rules(fallback.issues, &config.ignore.rules); + fallback.issues = + crate::engine::inline_suppress::apply(fallback.issues, &diff_chunks); let min_sev = config.hook.min_severity_level(); fallback.should_block = fallback .issues @@ -356,6 +358,10 @@ async fn review_diff_inner( // Apply ignore rules: filter out issues matching ignored patterns response.issues = apply_ignore_rules(response.issues, &config.ignore.rules); + // Rule-scoped inline suppression (`cora-ignore: `, #554), shared by + // deterministic and LLM findings. + response.issues = crate::engine::inline_suppress::apply(response.issues, &diff_chunks); + // Drop low-severity findings on unchanged (context) lines — these are // pre-existing code that appeared in the diff due to surrounding changes, // not new code introduced by the PR (#507 Pattern #3).