diff --git a/CHANGELOG.md b/CHANGELOG.md index b7998c2..fdc50df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +- Inline suppressions: a comment `jevgate: allow(RULE) reason` on a finding's line, or in the comments and attributes directly above it, accepts that finding as the baseline does. RULE is a rule ID, key or group, and the reason is required; the report keeps the finding with its reason (`suppressed`, and `gate.suppressed_findings`), and `jevgate baseline` leaves it out. - `jevgate mcp` runs a Model Context Protocol server on stdin and stdout, so coding agents can call JevGate as a tool: `jevgate_check` runs a check and returns its findings (an incomplete run is a tool error), `jevgate_findings` reads the last report, and `jevgate_rules` lists the rules. ## [0.18.0] - 2026-09-25 diff --git a/README.md b/README.md index 9bdf373..54153d9 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,16 @@ Findings are `review` (act on it), `consider` (worth a look) or `note` (optional | 1 | Gate failed | | 2 | Run incomplete, invalid configuration or invalid usage | -`--fail-on review|consider|uncertain|none` sets what fails the gate; `--fail-on security=consider` sets it for one group or rule. Baselined findings and notes never fail it. +`--fail-on review|consider|uncertain|none` sets what fails the gate; `--fail-on security=consider` sets it for one group or rule. Baselined findings, findings allowed by a comment, and notes never fail it. + +A single finding can also be accepted where it is, with a comment on its line or directly above it (doc comments and attributes may sit in between). The comment names a rule ID, key or group, and needs a reason; without one it is ignored and the finding says so: + +```python +# jevgate: allow(hardcoded_values) the protocol fixes this port +PORT = 4222 +``` + +The report keeps the finding with its reason, it never fails the gate, and `jevgate baseline` leaves it out, so deleting the comment brings it back. `jevgate baseline` can record why each finding was accepted: `intended` (right, and meant to be so), `later` (right, to fix later) or `wrong` (mistaken), with `--reason` or `jevgate baseline mark`. Reasons survive later rewrites of the baseline, and `jevgate baseline stats` reports each rule's share of findings marked wrong: labels from daily use, not the model's own probabilities. diff --git a/site/generate.py b/site/generate.py index e9677b4..9e3e091 100644 --- a/site/generate.py +++ b/site/generate.py @@ -34,7 +34,7 @@ def rules_page(binary): "", f"Generated from `jevgate rules --format json` ({version}). A rule is named by its ID,", "its key or its group anywhere a rule is accepted: `--rule`, `--skip-rule`,", - "`--fail-on TARGET=LEVEL`, `[rules]` and `[[scope]]`.", + "`--fail-on TARGET=LEVEL`, `[rules]`, `[[scope]]` and `jevgate: allow(…)` comments.", "", "| Rule | Key | Default | Question |", "|---|---|---|---|", diff --git a/site/src/output.md b/site/src/output.md index aff6aa2..baf908a 100644 --- a/site/src/output.md +++ b/site/src/output.md @@ -19,6 +19,15 @@ Findings are `review` (act on it), `consider` (worth a look) or `note` (optional | 1 | Gate failed | | 2 | Run incomplete, invalid configuration or invalid usage | -`--fail-on review|consider|uncertain|none` sets what fails the gate; `--fail-on security=consider` sets it for one group or rule. Baselined findings and notes never fail it. +`--fail-on review|consider|uncertain|none` sets what fails the gate; `--fail-on security=consider` sets it for one group or rule. Baselined findings, findings allowed by a comment, and notes never fail it. + +A single finding can also be accepted where it is, with a comment on its line or directly above it (doc comments and attributes may sit in between). The comment names a rule ID, key or group, and needs a reason; without one it is ignored and the finding says so: + +```python +# jevgate: allow(hardcoded_values) the protocol fixes this port +PORT = 4222 +``` + +The report keeps the finding with its reason, it never fails the gate, and `jevgate baseline` leaves it out, so deleting the comment brings it back. `jevgate baseline` can record why each finding was accepted: `intended` (right, and meant to be so), `later` (right, to fix later) or `wrong` (mistaken), with `--reason` or `jevgate baseline mark`. Reasons survive later rewrites of the baseline, and `jevgate baseline stats` reports each rule's share of findings marked wrong: labels from daily use, not the model's own probabilities. diff --git a/src/analysis/comments.rs b/src/analysis/comments.rs index 883d10b..376b0d1 100644 --- a/src/analysis/comments.rs +++ b/src/analysis/comments.rs @@ -269,6 +269,7 @@ pub fn prose(text: &str) -> String { /// Prefixes of comments that instruct a tool rather than a reader. const DIRECTIVES: &[&str] = &[ "eslint", + "jevgate:", "prettier-ignore", "@ts-", "tslint:", diff --git a/src/baseline.rs b/src/baseline.rs index e9572b8..a53e2af 100644 --- a/src/baseline.rs +++ b/src/baseline.rs @@ -89,15 +89,20 @@ pub fn write(root: &Path, merge: bool, reason: Option) -> Result, pub new_findings: usize, pub baselined_findings: usize, + /// Findings accepted by an inline `jevgate: allow` comment. + #[serde(default)] + pub suppressed_findings: usize, } /// Exit 0 when the gate passes, 1 when it fails, 2 when the run is incomplete. @@ -34,13 +37,18 @@ pub fn evaluate(report: &mut Report, args: &CheckArgs) { .flat_map(|f| f.findings.iter().map(|finding| (f.path.as_path(), finding))) .filter(|(_, f)| f.strength != Strength::Note); let baselined = findings.clone().filter(|(_, f)| f.baselined).count(); - let new: Vec<_> = findings.filter(|(_, f)| !f.baselined).collect(); + let suppressed = findings + .clone() + .filter(|(_, f)| !f.baselined && f.suppressed.is_some()) + .count(); + let new: Vec<_> = findings.filter(|(_, f)| !f.accepted()).collect(); let reasons = failures(report, &new, args); report.gate = report.complete.then_some(Gate { passed: reasons.is_empty(), reasons, new_findings: new.len(), baselined_findings: baselined, + suppressed_findings: suppressed, }); } @@ -49,7 +57,7 @@ pub fn evaluate(report: &mut Report, args: &CheckArgs) { /// on review findings. pub fn fails(finding: &Finding, path: &Path, args: &CheckArgs) -> bool { let levels = args.levels_at(&finding.rule, path); - !finding.baselined + !finding.accepted() && finding.strength != Strength::Note && (levels.contains(&FailOn::Consider) || (finding.strength == Strength::Review && levels.contains(&FailOn::Review))) @@ -99,6 +107,7 @@ fn failures(report: &Report, new: &[(&Path, &Finding)], args: &CheckArgs) -> Vec /// Apply the baseline and the gate policy to a settled report. pub fn settle(root: &Path, report: &mut Report, args: &CheckArgs) -> Result<()> { + crate::suppress::apply(root, report); crate::baseline::apply(root, report)?; evaluate(report, args); Ok(()) diff --git a/src/github.rs b/src/github.rs index e2a266e..7fe9ac8 100644 --- a/src/github.rs +++ b/src/github.rs @@ -29,7 +29,7 @@ pub fn emit(out: &mut impl Write, report: &Report, args: &CheckArgs) -> Result<( } let shown: Vec<(&Path, &Finding)> = output::ranked(report) .into_iter() - .filter(|(_, f)| f.strength != Strength::Note && !f.baselined) + .filter(|(_, f)| f.strength != Strength::Note && !f.accepted()) .collect(); for (path, finding) in &shown { writeln!( diff --git a/src/gitlab.rs b/src/gitlab.rs index 764832f..560b6d4 100644 --- a/src/gitlab.rs +++ b/src/gitlab.rs @@ -14,7 +14,7 @@ use std::{io::Write, path::Path}; pub fn emit(out: &mut impl Write, report: &Report, args: &CheckArgs) -> Result<()> { let issues: Vec = output::ranked(report) .into_iter() - .filter(|(_, f)| f.strength != Strength::Note && !f.baselined) + .filter(|(_, f)| f.strength != Strength::Note && !f.accepted()) .map(|(path, finding)| issue(path, finding, crate::gate::fails(finding, path, args))) .collect(); serde_json::to_writer_pretty(&mut *out, &issues)?; diff --git a/src/main.rs b/src/main.rs index 1eeb890..4a71eb5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,7 @@ mod sarif; mod schema; mod server; mod storage; +mod suppress; mod syntax; mod test_locations; mod token_budget; diff --git a/src/mcp.rs b/src/mcp.rs index b4aa870..c58c6b7 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -185,6 +185,7 @@ fn findings(report: &Report, prefix: Option<&str>, include_notes: bool) -> Value "action": f.action, "probability": f.concern_probability, "baselined": f.baselined, + "suppressed": f.suppressed, }) }) .collect(); diff --git a/src/options/commands.rs b/src/options/commands.rs index 25e1e44..a75143c 100644 --- a/src/options/commands.rs +++ b/src/options/commands.rs @@ -41,6 +41,9 @@ pub enum JevCommand { /// fingerprint of rule, path, unit and evidence, so unrelated edits keep /// them accepted. Offline: no source is read or sent. /// + /// A single finding can instead be accepted in the code, with a comment + /// `jevgate: allow(RULE) reason` on its line or directly above it. + /// /// Each accepted finding can record why it was accepted: `intended` (right /// about the code, which is meant to be this way), `later` (right, to fix /// later) or `wrong` (the finding is mistaken). `baseline stats` turns diff --git a/src/output.rs b/src/output.rs index aa3377d..77b4ce1 100644 --- a/src/output.rs +++ b/src/output.rs @@ -352,15 +352,12 @@ fn emit_context_load(out: &mut impl Write, load: &crate::docs::load::ContextLoad /// the rule dim. fn emit_finding(out: &mut impl Write, path: &Path, finding: &Finding, style: Style) -> Result<()> { let location = format!("{}:{}", path.display(), finding.line); - let rule = format!( - "[{}]{}", - finding.rule, - if finding.baselined { - " (baselined)" - } else { - "" - } - ); + let accepted = match (&finding.suppressed, finding.baselined) { + (_, true) => " (baselined)".to_string(), + (Some(reason), false) => format!(" (allowed: {reason})"), + (None, false) => String::new(), + }; + let rule = format!("[{}]{accepted}", finding.rule); writeln!( out, " {} {} {}", diff --git a/src/report.html b/src/report.html index fe96cc0..6754228 100644 --- a/src/report.html +++ b/src/report.html @@ -72,7 +72,7 @@ const files=[...data.files].sort((a,b)=>order.indexOf(a.status)-order.indexOf(b.status)||a.path.localeCompare(b.path)); function findingRow(finding){ const row=el('details',null,'row finding-row'),summary=el('summary'),main=el('span',null,'main'); - main.append(el('span','Line '+finding.line+(finding.baselined?' · baselined':''),'path'),el('span',finding.message,'lead')); + main.append(el('span','Line '+finding.line+(finding.baselined?' · baselined':finding.suppressed?' · allowed: '+finding.suppressed:''),'path'),el('span',finding.message,'lead')); summary.append(main,el('span',name(finding.rule),'count'),badge(finding.strength));row.append(summary); const body=el('div',null,'detail');row.append(body); p(body,finding.action); diff --git a/src/sarif.rs b/src/sarif.rs index 40eaa77..f9c1649 100644 --- a/src/sarif.rs +++ b/src/sarif.rs @@ -19,7 +19,7 @@ const HOME: &str = "https://github.com/Tech-Byte-Frontier/jevgate"; pub fn emit(out: &mut impl Write, report: &Report, args: &CheckArgs) -> Result<()> { let shown: Vec<(&Path, &Finding)> = output::ranked(report) .into_iter() - .filter(|(_, f)| f.strength != Strength::Note && !f.baselined) + .filter(|(_, f)| f.strength != Strength::Note && !f.accepted()) .collect(); serde_json::to_writer_pretty(&mut *out, &document(report, &shown, args))?; writeln!(out)?; diff --git a/src/schema.rs b/src/schema.rs index caafa7c..cb3af9b 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -180,6 +180,17 @@ pub struct Finding { pub rank: f64, #[serde(default)] pub baselined: bool, + /// The reason an inline `jevgate: allow(RULE) reason` comment gives; such a + /// finding is accepted as a baselined one is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suppressed: Option, +} + +impl Finding { + /// Accepted by the baseline or by an inline comment, so it never fails the gate. + pub fn accepted(&self) -> bool { + self.baselined || self.suppressed.is_some() + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/suppress.rs b/src/suppress.rs new file mode 100644 index 0000000..fc00fe2 --- /dev/null +++ b/src/suppress.rs @@ -0,0 +1,162 @@ +//! Inline suppressions: a comment `jevgate: allow(RULE, …) reason` on a +//! finding's line, or in the comments and attributes directly above it, +//! accepts that finding as the baseline does. RULE is a rule ID, key or group, +//! and the reason is required: without one the comment is ignored and the +//! finding says so. +use crate::{catalog, schema::Report}; +use std::path::Path; + +const MARKER: &str = "jevgate:"; + +/// What one `jevgate: allow(…)` comment names and why. +#[derive(Debug, PartialEq)] +struct Allow { + rules: Vec, + reason: String, +} + +/// Mark the findings an allow comment names. Files that cannot be read keep +/// their findings. +pub fn apply(root: &Path, report: &mut Report) { + for file in report.files.iter_mut().filter(|f| !f.findings.is_empty()) { + let Ok(text) = std::fs::read_to_string(root.join(&file.path)) else { + continue; + }; + let lines: Vec<&str> = text.lines().collect(); + for finding in &mut file.findings { + finding.suppressed = None; + let Some(allow) = allow_for(&lines, finding.line, &finding.rule) else { + continue; + }; + if allow.reason.is_empty() { + finding.message.push_str( + " The `jevgate: allow` comment for it is ignored: it gives no reason after the rule.", + ); + } else { + finding.suppressed = Some(allow.reason); + } + } + } +} + +/// The allow comment naming `rule` on 1-based `line`, or in the block of +/// comment and attribute lines directly above it. +fn allow_for(lines: &[&str], line: usize, rule: &str) -> Option { + let at = line.checked_sub(1)?; + let above = lines[..at.min(lines.len())] + .iter() + .rev() + .take_while(|l| annotation(l)); + lines + .get(at) + .into_iter() + .chain(above) + .filter_map(|l| parse(l)) + .find(|allow| allow.rules.iter().any(|name| names(name, rule))) +} + +/// A comment, attribute or decorator line, which may sit between an allow +/// comment and the code it is about. +fn annotation(line: &str) -> bool { + let line = line.trim_start(); + ["//", "#", "/*", "*", "--", "") + .trim_end_matches("%>") + .trim() + .trim_start_matches(['-', ':', '—']) + .trim(); + Some(Allow { + rules, + reason: reason.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allow_comments_name_rules_and_a_reason_in_any_comment_style() { + for (line, rules, reason) in [ + ( + "// jevgate: allow(shared_logic) the two exports must stay separate", + vec!["shared_logic"], + "the two exports must stay separate", + ), + ( + " # jevgate: allow(maintainability/hardcoded-values, security) -- test fixture", + vec!["maintainability/hardcoded-values", "security"], + "test fixture", + ), + ( + "/* jevgate: allow(injection): the query is a constant */", + vec!["injection"], + "the query is a constant", + ), + ("", vec!["comments"], ""), + ] { + assert_eq!( + parse(line), + Some(Allow { + rules: rules.into_iter().map(String::from).collect(), + reason: reason.into() + }), + "{line}" + ); + } + assert_eq!(parse("// jevgate: allow shared_logic"), None); + assert_eq!(parse("let jevgate = 1;"), None); + } + + #[test] + fn an_allow_comment_applies_on_its_line_or_through_the_annotations_above() { + let source = [ + "use std::fs;", + "// jevgate: allow(maintainability) generated shape we keep", + "/// Loads the file.", + "#[inline]", + "fn load() {}", + "", + "fn other() {} // jevgate: allow(shared_logic) mirrors load", + ]; + let rule = "maintainability/shared-logic"; + assert!( + allow_for(&source, 5, rule).is_some(), + "through a doc comment and attribute" + ); + assert!( + allow_for(&source, 7, rule).is_some(), + "at the end of the line" + ); + assert!(allow_for(&source, 7, "security/injection").is_none()); + assert!(allow_for(&source, 1, rule).is_none()); + let apart = ["// jevgate: allow(shared_logic) old", "", "fn load() {}"]; + assert!( + allow_for(&apart, 3, rule).is_none(), + "a blank line ends the block" + ); + } +} diff --git a/src/tests/gating.rs b/src/tests/gating.rs index 712ed9a..649e1a4 100644 --- a/src/tests/gating.rs +++ b/src/tests/gating.rs @@ -207,3 +207,41 @@ fn baseline_reasons_are_marked_counted_and_kept_across_rewrites() { assert_eq!(baseline::stats(&project.0).unwrap()[rule].wrong, 1); assert!(baseline::stats_table(&counts).contains("50%")); } + +#[test] +fn an_allow_comment_accepts_a_finding_only_with_a_reason() { + let project = Project::new(); + let options = args(); + let mut mock = Mock { + level: 2, + ..Default::default() + }; + for (comment, accepted) in [ + ( + "// jevgate: allow(maintainability) kept as the protocol spells it\n", + true, + ), + ("// jevgate: allow(maintainability)\n", false), + ( + "// jevgate: allow(security) kept as the protocol spells it\n", + false, + ), + ] { + project.write("lib.rs", &format!("{comment}{}", function("f"))); + let report = run(&project, &options, &mut mock); + let finding = &report.files[0].findings[0]; + assert_eq!(finding.suppressed.is_some(), accepted, "{comment}"); + assert_eq!( + gate::exit_code(&report), + if accepted { 0 } else { 1 }, + "{comment}" + ); + let gate = report.gate.as_ref().unwrap(); + assert_eq!(gate.suppressed_findings, usize::from(accepted)); + assert_eq!( + finding.message.contains("ignored: it gives no reason"), + comment.ends_with(")\n"), + "{comment}" + ); + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 686662d..3dd85b1 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -65,6 +65,7 @@ pub(super) fn finding(strength: crate::schema::Strength) -> crate::schema::Findi fingerprint: String::new(), rank: 1.0, baselined: false, + suppressed: None, } } diff --git a/src/units/compose.rs b/src/units/compose.rs index 08419ad..01a6611 100644 --- a/src/units/compose.rs +++ b/src/units/compose.rs @@ -1054,6 +1054,7 @@ fn finding( fingerprint: fingerprint(unit.rule, plan, &unit.identity), rank: rank(p, lines), baselined: false, + suppressed: None, } } @@ -1255,6 +1256,7 @@ fn group_finding(plan: &FilePlan, cluster: Cluster<'_>) -> Finding { fingerprint: fingerprint(catalog::TEST_REDUNDANCY, plan, &super::identity(&identity)), rank: rank(p, lines), baselined: false, + suppressed: None, } } @@ -1342,6 +1344,7 @@ fn comment_findings( fingerprint: fingerprint(catalog::COMMENTS, plan, &super::identity(&identities)), rank: rank(p, lines), baselined: false, + suppressed: None, } }) .collect()