Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
18 changes: 18 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <rule>[, <rule>...]`, matched as a substring of the line, so it works after `//`, `#`, `--`, inside `/* */` or `<!-- -->`, etc. The marker is case-insensitive.
- `<rule>` 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.
Expand Down
291 changes: 291 additions & 0 deletions src/engine/inline_suppress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
//! Rule-scoped inline suppression of findings (#554).
//!
//! A source comment containing `cora-ignore: <rule>[, <rule>...]` 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<String> {
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<ReviewIssue>, chunks: &[FileChunk]) -> Vec<ReviewIssue> {
// (file, line) -> lowercased rules suppressed there.
let mut suppressed: HashMap<(String, u32), HashSet<String>> = 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<FileChunk> {
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 */",
"<p>hi</p> <!-- cora-ignore: Rule A -->",
] {
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());
}
}
1 change: 1 addition & 0 deletions src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/engine/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <rule>`, #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).
Expand Down
Loading