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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion site/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 |",
"|---|---|---|---|",
Expand Down
11 changes: 10 additions & 1 deletion site/src/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/analysis/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
23 changes: 14 additions & 9 deletions src/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,74 +78,79 @@
/// as unchanged files of a `--base` run; entries for checked or deleted files
/// are replaced by what the check found. A finding accepted before keeps its
/// reason; the others get `reason`.
pub fn write(root: &Path, merge: bool, reason: Option<Disposition>) -> Result<Written> {
let report = crate::storage::read_latest(root)
.context("No compatible .jevgate/latest.json; run jevgate check first")?;
ensure!(
report.complete && !report.dry_run,
"The last check was incomplete; rerun it before writing a baseline"
);
let mut findings: Vec<Accepted> = report
.files
.iter()
.flat_map(|file| {
file.findings.iter().map(|f| Accepted {
fingerprint: f.fingerprint.clone(),
rule: f.rule.clone(),
path: file.path.clone(),
line: Some(f.line),
strength: Some(f.strength),
message: f.message.clone(),
reason,
})
// A suppressed finding is accepted where its comment is; removing
// the comment brings it back.
file.findings
.iter()
.filter(|f| f.suppressed.is_none())
.map(|f| Accepted {
fingerprint: f.fingerprint.clone(),
rule: f.rule.clone(),
path: file.path.clone(),
line: Some(f.line),
strength: Some(f.strength),
message: f.message.clone(),
reason,
})
})
.collect();
let accepted = findings.len();
let mut kept = 0;
let previous = read_baseline(root)?;
if let Some(previous) = &previous {
let reasons: BTreeMap<&str, Disposition> = previous
.findings
.iter()
.filter_map(|f| Some((f.fingerprint.as_str(), f.reason?)))
.collect();
for finding in &mut findings {
if let Some(earlier) = reasons.get(finding.fingerprint.as_str()) {
finding.reason = Some(*earlier);
}
}
}
if merge && let Some(previous) = previous {
let covered: BTreeSet<&Path> = report
.files
.iter()
.map(|f| f.path.as_path())
.chain(report.deleted_files.iter().map(|p| p.as_path()))
.collect();
let earlier: Vec<Accepted> = previous
.findings
.into_iter()
.filter(|f| !covered.contains(f.path.as_path()))
.collect();
kept = earlier.len();
findings.extend(earlier);
}
findings.sort_by(|a, b| (&a.path, &a.fingerprint).cmp(&(&b.path, &b.fingerprint)));
findings.dedup_by(|a, b| a.fingerprint == b.fingerprint);
let path = save_baseline(
root,
&Baseline {
version: 1,
created_at: crate::schema::now(),
findings,
},
)?;
Ok(Written {
path,
accepted,
kept,
})
}

Check warning on line 153 in src/baseline.rs

View workflow job for this annotation

GitHub Actions / review

JevGate consider [maintainability/function-simplification]

`write` likely mixes separate jobs; splitting it may make it easier to understand (0.93). → Consider extracting each separate job into its own named function

fn save_baseline(root: &Path, baseline: &Baseline) -> Result<std::path::PathBuf> {
let path = root.join(BASELINE_FILE);
Expand Down
1 change: 1 addition & 0 deletions src/changes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ mod tests {
fingerprint: fingerprint.into(),
rank: 1.0,
baselined: false,
suppressed: None,
}
}

Expand Down
13 changes: 11 additions & 2 deletions src/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ pub struct Gate {
pub reasons: Vec<String>,
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.
Expand All @@ -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,
});
}

Expand All @@ -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)))
Expand Down Expand Up @@ -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(())
Expand Down
2 changes: 1 addition & 1 deletion src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion src/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> = 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)?;
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ mod sarif;
mod schema;
mod server;
mod storage;
mod suppress;
mod syntax;
mod test_locations;
mod token_budget;
Expand Down
1 change: 1 addition & 0 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/options/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 6 additions & 9 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
" {} {} {}",
Expand Down
2 changes: 1 addition & 1 deletion src/report.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/sarif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
11 changes: 11 additions & 0 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

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)]
Expand Down
Loading
Loading