diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b7a50..5195d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +- `jevgate baseline mark REASON --note TEXT TARGET…` keeps a short note with the reason, such as the issue that will fix a `later` finding. Notes survive rewrites, `--merge` and marking again without one; the hook names them in the person's list of dismissals. Baseline entries also record the unit a finding names. Baselines without notes or units load unchanged. +- `jevgate baseline list [--reason …] [--rule …] [--format text|md|json]` lists accepted findings by path and line with their reason, rule, unit, fingerprint prefix and note; `--format md` prints a checklist to paste into a cleanup issue. +- The instructions `jevgate init --agent` writes and the plugin's skill tell agents to give a `later` mark the issue that will fix it with `--note`, and to export `later` marks with `baseline list --reason later --format md`. Run `jevgate init --agent` again to update them. + ## [0.33.0] - 2026-10-02 Findings have one level, `review`, and the maintainability rules ask broad look-here questions whose flags the person or coding agent reading them verifies: fix the code, or dismiss the finding with a reason, and a dismissed finding is not raised again while its code is unchanged. On a 32-file sample of a game backend and its web client, the look-here questions flagged the three functions and six of the nine repeats a reviewer wanted changed, where 0.32's questions flagged none of them. diff --git a/jevgate-baseline.json b/jevgate-baseline.json index f346d6b..ac288ae 100644 --- a/jevgate-baseline.json +++ b/jevgate-baseline.json @@ -407,6 +407,17 @@ "message": "This test file may test several separate subjects.", "reason": "intended" }, + { + "fingerprint": "477e510655e0f1bf20507decaef90abdb35418fdfa15c436e22ff891924c7360", + "rule": "maintainability/shared-logic", + "path": "src/baseline.rs", + "line": 275, + "unit": "`uncovered` (src/baseline.rs:275) and `list` (src/baseline.rs:437)", + "strength": "review", + "message": "`uncovered` (src/baseline.rs:275) and `list` (src/baseline.rs:437) may repeat one piece of logic, so a change to it would have to be made in each place.", + "reason": "wrong", + "note": "a filter over different collections with different tests" + }, { "fingerprint": "5cacb82760c55a2b30cdfaad510b3796cd571ba46bada152961e96613539f9a9", "rule": "maintainability/file-organization", @@ -416,6 +427,16 @@ "message": "This file may do several separate kinds of work, such as separate features, layers or integrations.", "reason": "wrong" }, + { + "fingerprint": "89c1deedd5fe8507aad0a494fda37caae01d5a80be6d0590ca591092f0592dbe", + "rule": "maintainability/file-organization", + "path": "src/baseline.rs", + "line": 18, + "strength": "review", + "message": "This file may do several separate kinds of work, such as separate features, layers or integrations.", + "reason": "wrong", + "note": "every function reads or writes jevgate-baseline.json; marked wrong on main before" + }, { "fingerprint": "b5974a3e8b0774e52416c53f1003281dc460cd55853041125750b311c3d71376", "rule": "maintainability/function-simplification", @@ -452,6 +473,16 @@ "message": "`configured` could likely be made simpler to read or change: it may be long, deeply nested, repetitive or mix separate jobs.", "reason": "intended" }, + { + "fingerprint": "39cf607b259539e80396bc6504520bac88b2559c0f226ab511ca4d217d2c2319", + "rule": "maintainability/file-organization", + "path": "src/command.rs", + "line": 14, + "strength": "review", + "message": "This file may do several separate kinds of work, such as separate features, layers or integrations.", + "reason": "wrong", + "note": "one function per command; marked wrong on main before" + }, { "fingerprint": "98a9bf691db26467975da17123bbcf702cd98b0dfd8b88ff7287734126ad036c", "rule": "maintainability/file-organization", @@ -459,7 +490,8 @@ "line": 14, "strength": "review", "message": "This file may do several separate kinds of work, such as separate features, layers or integrations.", - "reason": "wrong" + "reason": "wrong", + "note": "one function per command; marked wrong on main before" }, { "fingerprint": "382884074321b13f806f4b5070f135fe49e274a909ba255b56896ea99cbb3d52", @@ -812,6 +844,28 @@ "message": "`findings_after_edit` could likely be made simpler to read or change: it may be long, deeply nested, repetitive or mix separate jobs.", "reason": "intended" }, + { + "fingerprint": "bcfdb6990731fd8229fca0d548d243382da1f7836974bddb6a03a3c3da289220", + "rule": "maintainability/shared-logic", + "path": "src/hook/text.rs", + "line": 181, + "unit": "`findings_after_edit` (src/hook/text.rs:181), `passed` (src/hook/text.rs:409) and 2 more copies", + "strength": "review", + "message": "`findings_after_edit` (src/hook/text.rs:181), `passed` (src/hook/text.rs:409) and 2 more copies may repeat one piece of logic, so a change to it would have to be made in each place.", + "reason": "wrong", + "note": "a singular or plural word chosen by a count; the words differ at each site" + }, + { + "fingerprint": "df9713caafce9a632f15768a3840f9e336235077320120f16a378c72efaf7812", + "rule": "maintainability/shared-logic", + "path": "src/hook/text.rs", + "line": 98, + "unit": "`undecided_line` (src/hook/text.rs:98), `passed` (src/hook/text.rs:395) and 1 more copy", + "strength": "review", + "message": "`undecided_line` (src/hook/text.rs:98), `passed` (src/hook/text.rs:395) and 1 more copy may repeat one piece of logic, so a change to it would have to be made in each place.", + "reason": "wrong", + "note": "each formats a different line: an undecided unit, a dismissal, a finding" + }, { "fingerprint": "dc771fd3a45bb112b84587cc4c5fe126e563791c1643b1d8e995055a3d97da7b", "rule": "maintainability/file-organization", @@ -1091,6 +1145,16 @@ "message": "`is_test_attribute` could likely be made simpler to read or change: it may be long, deeply nested, repetitive or mix separate jobs.", "reason": "wrong" }, + { + "fingerprint": "fe11fb904733a7cf785aa7d81d3d07e239e8ded7b90e768e184f7304744896d4", + "rule": "maintainability/file-organization", + "path": "src/tests/gating.rs", + "line": 5, + "strength": "review", + "message": "This test file may test several separate subjects.", + "reason": "intended", + "note": "the gate and the baseline it reads are tested together, as the module doc says" + }, { "fingerprint": "e9f3f69f4ad38653eeaaa416736359c7e4a35351ca79d10c5086a2dc9597b48a", "rule": "maintainability/file-organization", diff --git a/plugin/skills/findings/SKILL.md b/plugin/skills/findings/SKILL.md index eea90de..d7e2af2 100644 --- a/plugin/skills/findings/SKILL.md +++ b/plugin/skills/findings/SKILL.md @@ -17,7 +17,7 @@ Every finding is a `review`: a place worth a look, flagged broadly so that you v 1. Read the code at `path:line` before changing anything. The why says what the finding rests on, and the sentence after it how often findings of its rule were right on projects JevGate was never tuned on ("Not yet measured." for broad look-here findings and when fewer than 20 were labeled); `Next` is a suggested step, not the only fix. 2. **Right**: fix it. A finding marked "(fails the gate)" also fails the person's CI. -3. **Mistaken, intended or for later**: dismiss it with a reason, `jevgate baseline mark wrong PATH:LINE` (or `intended`, or `later`). The dismissal counts at once, holds while the code it names is unchanged, and the person audits dismissals with `jevgate baseline stats`. JevGate does not block again when nothing changed. +3. **Mistaken, intended or for later**: dismiss it with a reason, `jevgate baseline mark wrong PATH:LINE` (or `intended`, or `later` with the issue that will fix it: `--note "#123"`). The dismissal counts at once, holds while the code it names is unchanged, and the person audits dismissals with `jevgate baseline stats`; `jevgate baseline list --reason later --format md` exports the `later` ones as a checklist. JevGate does not block again when nothing changed. ## Never, unless the person asks diff --git a/site/src/output.md b/site/src/output.md index 0976deb..3c99303 100644 --- a/site/src/output.md +++ b/site/src/output.md @@ -40,7 +40,7 @@ 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. +`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`. `baseline mark --note "#192"` keeps a one-line note with the reason, such as the issue a `later` finding will be fixed in. Reasons and notes survive later rewrites of the baseline. `jevgate baseline list --reason later --format md` prints the marks as a checklist for a cleanup issue (`text` and `json` too), and `jevgate baseline stats` reports each rule's share of findings marked wrong: labels from daily use, not the model's own probabilities. ## Guards diff --git a/src/baseline.rs b/src/baseline.rs index 5d9fc09..1b3c750 100644 --- a/src/baseline.rs +++ b/src/baseline.rs @@ -1,5 +1,6 @@ //! The baseline of accepted findings: writing it from the last check, marking -//! why findings were accepted, and counting those reasons per rule. +//! why findings were accepted, with an optional note, listing the marks, and +//! counting their reasons per rule. use crate::{ options::Disposition, schema::{Report, Scope, Strength}, @@ -27,12 +28,37 @@ struct Accepted { path: std::path::PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")] line: Option, + /// The function, type or other unit the finding names, when it names one. + #[serde(default, skip_serializing_if = "Option::is_none")] + unit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] strength: Option, message: String, /// Why it was accepted, when someone said. #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, + /// A short note with the reason, such as the issue a `later` finding + /// will be fixed in. + #[serde(default, skip_serializing_if = "Option::is_none")] + note: Option, +} + +/// A note is one line of at most this many characters. +pub const NOTE_CHARS: usize = 200; + +/// The note `baseline mark --note` records: one line, trimmed, of at most +/// [`NOTE_CHARS`] characters. An empty note clears the one a finding has. +pub fn note(text: &str) -> Result> { + let text = text.trim(); + ensure!( + !text.chars().any(char::is_control), + "A note is one line of text, without line breaks or tabs" + ); + ensure!( + text.chars().count() <= NOTE_CHARS, + "A note is at most {NOTE_CHARS} characters; link an issue for more" + ); + Ok((!text.is_empty()).then(|| text.to_string())) } /// A baseline is read whole up to this size. @@ -94,12 +120,19 @@ pub fn apply(root: &Path, report: &mut Report, as_of: Option<&str>) -> Result<() Ok(()) } +/// Why a finding was dismissed: its reason, and the note given with it. +#[derive(Clone, Debug, PartialEq)] +pub struct Dismissal { + pub reason: Disposition, + pub note: Option, +} + /// The findings the baseline dismisses with a reason that its version in Git /// tree `since` did not accept, by fingerprint: what an agent dismissed with /// `baseline mark` during a turn that began at `since`. Accepting a finding /// is otherwise the person's call, so an entry without a reason counts from /// the next turn; a person audits the reasons with `baseline stats`. -pub fn dismissed_since(root: &Path, since: &str) -> Result> { +pub fn dismissed_since(root: &Path, since: &str) -> Result> { let held: BTreeSet = baseline_in(root, since)? .map_or_else(Vec::new, |b| b.findings) .into_iter() @@ -109,7 +142,16 @@ pub fn dismissed_since(root: &Path, since: &str) -> Result Result { /// are replaced by what the check found. A check of changed lines judged only /// what its change touched, so the entries of the files it checked stay too, /// and only deleted files' entries go. A finding accepted before keeps its -/// reason; the others get `reason`. +/// reason and note; the others get `reason`. pub fn write(root: &Path, merge: bool, reason: Option) -> Result { let report = last_check(root, merge)?; let mut findings = to_accept(&report, reason); let accepted = findings.len(); let previous = read_baseline(root)?; if let Some(previous) = &previous { - keep_reasons(&mut findings, previous); + keep_marks(&mut findings, previous); } let earlier = match previous { Some(previous) if merge => uncovered(&report, previous), @@ -161,8 +203,7 @@ pub fn write(root: &Path, merge: bool, reason: Option) -> Result) -> Vec { rule: f.rule.clone(), path: file.path.clone(), line: Some(f.line), + unit: f.symbol.clone(), strength: Some(f.strength), message: f.message.clone(), reason, + note: None, }) }) .collect() } -/// Give each finding accepted before the reason it was accepted with. -fn keep_reasons(findings: &mut [Accepted], previous: &Baseline) { - let reasons: BTreeMap<&str, Disposition> = previous +/// Give each finding accepted before the reason it was accepted with, and +/// its note. +fn keep_marks(findings: &mut [Accepted], previous: &Baseline) { + let marks: BTreeMap<&str, &Accepted> = previous .findings .iter() - .filter_map(|f| Some((f.fingerprint.as_str(), f.reason?))) + .filter(|f| f.reason.is_some() || f.note.is_some()) + .map(|f| (f.fingerprint.as_str(), f)) .collect(); for finding in findings { - if let Some(earlier) = reasons.get(finding.fingerprint.as_str()) { - finding.reason = Some(*earlier); + if let Some(earlier) = marks.get(finding.fingerprint.as_str()) { + finding.reason = earlier.reason.or(finding.reason); + finding.note.clone_from(&earlier.note); } } } @@ -241,15 +287,28 @@ fn save_baseline(root: &Path, baseline: &Baseline) -> Result Ok(path) } -/// Record `reason` on the findings a target names whose rule is among -/// `rules` (every rule when empty); returns how many were marked. A target -/// is a path or directory, `PATH:LINE`, or a fingerprint prefix of at least -/// 8 characters. Accepted findings are marked; a finding of the last check -/// that the baseline does not hold yet is accepted with the reason when a -/// target names it by `PATH:LINE` or fingerprint: a coding agent dismisses -/// what its hook reported this way. A path or directory accepts nothing new, -/// so no finding is dismissed unread. -pub fn mark(root: &Path, reason: Disposition, targets: &[String], rules: &[&str]) -> Result { +/// What `baseline mark` records, and on which findings. +pub struct Mark<'a> { + pub reason: Disposition, + /// The note to record, already checked by [`note`]: `None` keeps the + /// note a finding has, `Some(None)` clears it. + pub note: Option>, + /// Paths or directories, `PATH:LINE`s or fingerprint prefixes. + pub targets: &'a [String], + /// Rule keys; every rule when empty. + pub rules: &'a [&'a str], +} + +/// Record a reason, and a note when given, on the findings a target names +/// whose rule is among the mark's rules; returns how many were marked. A +/// target is a path or directory, `PATH:LINE`, or a fingerprint prefix of +/// at least 8 characters. Accepted findings are marked; a finding of the +/// last check that the baseline does not hold yet is accepted with the +/// reason when a target names it by `PATH:LINE` or fingerprint: a coding +/// agent dismisses what its hook reported this way. A path or directory +/// accepts nothing new, so no finding is dismissed unread. +pub fn mark(root: &Path, mark: &Mark<'_>) -> Result { + let targets = mark.targets; // An empty path would be the start of every path. ensure!( targets @@ -262,25 +321,24 @@ pub fn mark(root: &Path, reason: Disposition, targets: &[String], rules: &[&str] created_at: crate::schema::now(), findings: Vec::new(), }); - let selected = |rule: &str| { - let key = match crate::catalog::find(rule) { - Some(found) => Some(found.key), - None => crate::catalog::custom(rule).then_some(rule), - }; - rules.is_empty() || key.is_some_and(|key| rules.contains(&key)) - }; let named = |finding: &Accepted, paths: bool| { - selected(&finding.rule) && targets.iter().any(|t| names(t, finding, paths)) + selected(&finding.rule, mark.rules) && targets.iter().any(|t| names(t, finding, paths)) }; let mut marked = 0; for finding in &mut baseline.findings { if named(finding, true) { - finding.reason = Some(reason); + finding.reason = Some(mark.reason); + if let Some(note) = &mark.note { + finding.note.clone_from(note); + } marked += 1; } } if let Ok(report) = crate::storage::read_latest(root) { - marked += add_dismissed(&mut baseline, &report, reason, |f| named(f, false)); + let note = mark.note.clone().flatten(); + marked += add_dismissed(&mut baseline, &report, (mark.reason, note), |f| { + named(f, false) + }); } ensure!( marked > 0, @@ -291,12 +349,23 @@ pub fn mark(root: &Path, reason: Disposition, targets: &[String], rules: &[&str] Ok(marked) } +/// Whether a finding of `rule` is among `rules`, rule keys: every rule when +/// there are none. +fn selected(rule: &str, rules: &[&str]) -> bool { + let key = match crate::catalog::find(rule) { + Some(found) => Some(found.key), + None => crate::catalog::custom(rule).then_some(rule), + }; + rules.is_empty() || key.is_some_and(|key| rules.contains(&key)) +} + /// Adds the findings of `report` that `named` picks and `baseline` does not -/// hold yet, dismissed for `reason`, and says how many it added. +/// hold yet, dismissed for the reason with the note, and says how many it +/// added. fn add_dismissed( baseline: &mut Baseline, report: &Report, - reason: Disposition, + (reason, note): (Disposition, Option), named: impl Fn(&Accepted) -> bool, ) -> usize { let held: BTreeSet = baseline @@ -307,17 +376,26 @@ fn add_dismissed( let mut dismissed: Vec = to_accept(report, Some(reason)) .into_iter() .filter(|f| !held.contains(&f.fingerprint) && named(f)) + .map(|f| Accepted { + note: note.clone(), + ..f + }) .collect(); - dismissed.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint)); - dismissed.dedup_by(|a, b| a.fingerprint == b.fingerprint); + in_file_order(&mut dismissed); let added = dismissed.len(); baseline.findings.extend(dismissed); - baseline - .findings - .sort_by(|a, b| (&a.path, &a.fingerprint).cmp(&(&b.path, &b.fingerprint))); + in_file_order(&mut baseline.findings); added } +/// The order the baseline file keeps its entries in, by path, then +/// fingerprint, each finding once. +fn in_file_order(findings: &mut Vec) { + findings.sort_by(|a, b| (&a.path, &a.fingerprint).cmp(&(&b.path, &b.fingerprint))); + // A fingerprint covers the path, so a finding's copies are adjacent. + findings.dedup_by(|a, b| a.fingerprint == b.fingerprint); +} + /// Whether a `mark` target names a finding: by fingerprint prefix or /// `PATH:LINE`, or with `paths`, by a path or directory holding it. fn names(target: &str, finding: &Accepted, paths: bool) -> bool { @@ -334,6 +412,127 @@ fn names(target: &str, finding: &Accepted, paths: bool) -> bool { paths && !target.is_empty() && finding.path.starts_with(target) } +/// An accepted finding as `baseline list` prints it. +#[derive(Debug, Serialize, PartialEq)] +pub struct Listed { + pub path: std::path::PathBuf, + pub line: Option, + pub reason: Option, + pub rule: String, + pub unit: Option, + pub fingerprint: String, + pub note: Option, + pub message: String, +} + +/// The fingerprint prefix the listing prints: as many characters as +/// `baseline mark` needs to name a finding. +const LISTED_PREFIX: usize = 8; + +/// The accepted findings with one of `reasons` (any, or none, when empty) +/// whose rule is among `rules` (every rule when empty), by path, then line. +pub fn list(root: &Path, reasons: &[Disposition], rules: &[&str]) -> Result> { + let baseline = read_baseline(root)? + .with_context(|| format!("No {BASELINE_FILE}; run jevgate baseline first"))?; + let mut listed: Vec = baseline + .findings + .into_iter() + .filter(|f| reasons.is_empty() || f.reason.is_some_and(|r| reasons.contains(&r))) + .filter(|f| selected(&f.rule, rules)) + .map(|f| Listed { + path: f.path, + line: f.line, + reason: f.reason, + rule: f.rule, + unit: f.unit, + fingerprint: f.fingerprint, + note: f.note, + message: f.message, + }) + .collect(); + listed + .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint))); + Ok(listed) +} + +impl Listed { + fn location(&self) -> String { + match self.line { + Some(line) => format!("{}:{line}", self.path.display()), + None => self.path.display().to_string(), + } + } + + fn reason(&self) -> String { + self.reason + .map_or_else(|| "no reason".into(), |r| crate::output::label(&r)) + } + + fn id(&self) -> &str { + self.fingerprint + .get(..LISTED_PREFIX) + .unwrap_or(&self.fingerprint) + } +} + +/// The listing for people: location, reason, rule and fingerprint prefix +/// in aligned columns, then the unit and the note. +pub fn list_text(listed: &[Listed]) -> String { + let rows: Vec<[String; 4]> = listed + .iter() + .map(|l| [l.location(), l.reason(), l.rule.clone(), l.id().to_string()]) + .collect(); + let widths: Vec = (0..4) + .map(|column| { + rows.iter() + .map(|row| row[column].chars().count()) + .max() + .unwrap_or(0) + }) + .collect(); + rows.iter() + .zip(listed) + .map(|(row, l)| { + let mut cells: Vec = row + .iter() + .zip(&widths) + .map(|(cell, &width)| format!("{cell:>() + .join("\n") +} + +/// The listing as a Markdown checklist to paste into a cleanup issue. +pub fn list_markdown(listed: &[Listed]) -> String { + // A unit named with its own code spans stays as it is. + let code = |text: &str| { + if text.contains('`') { + text.to_string() + } else { + format!("`{text}`") + } + }; + listed + .iter() + .map(|l| { + let mut line = format!("- [ ] {} {}", code(&l.location()), l.rule); + if let Some(unit) = &l.unit { + line.push_str(&format!(" {}", code(unit))); + } + line.push_str(&format!(" ({}, {})", l.reason(), code(l.id()))); + if let Some(note) = &l.note { + line.push_str(&format!(": {note}")); + } + line + }) + .collect::>() + .join("\n") +} + /// Accepted findings of one rule by reason. #[derive(Debug, Default, Serialize, PartialEq)] pub struct ReasonCounts { diff --git a/src/command.rs b/src/command.rs index a42fa39..66a6d51 100644 --- a/src/command.rs +++ b/src/command.rs @@ -153,6 +153,18 @@ fn rules(context: &ConfigContext, format: options::RulesFormat) -> Result { Ok(0) } +/// The rule keys `--rule` names select: each a rule ID, name, key or group. +fn rule_keys(context: &ConfigContext, names: &[String]) -> Result> { + let known = context.rules(); + let mut keys = Vec::new(); + for name in names { + keys.extend(catalog::select_in(&known, name).ok_or_else(|| { + anyhow::anyhow!("Unknown rule or group: {name}; `jevgate rules` lists the rules") + })?); + } + Ok(keys) +} + /// `baseline`: accept the last check's findings. fn accept( context: &ConfigContext, @@ -176,30 +188,47 @@ fn accept( Ok(0) } -/// `baseline mark` and `baseline stats`: offline edits and counts of the baseline. +/// `baseline mark`, `baseline list` and `baseline stats`: offline edits, +/// listings and counts of the baseline. fn baseline_action(context: &ConfigContext, action: options::BaselineAction) -> Result { match action { options::BaselineAction::Mark { reason, targets, rules, + note, } => { - let known = context.rules(); - let mut keys = Vec::new(); - for name in &rules { - keys.extend(catalog::select_in(&known, name).ok_or_else(|| { - anyhow::anyhow!( - "Unknown rule or group: {name}; `jevgate rules` lists the rules" - ) - })?); - } - let marked = baseline::mark(&context.root, reason, &targets, &keys)?; + let note = note.as_deref().map(baseline::note).transpose()?; + let keys = rule_keys(context, &rules)?; + let marked = baseline::mark( + &context.root, + &baseline::Mark { + reason, + note, + targets: &targets, + rules: &keys, + }, + )?; say!( "Marked {} as {}", output::count(marked, "finding"), output::label(&reason) ); } + options::BaselineAction::List { + reasons, + rules, + format, + } => { + let keys = rule_keys(context, &rules)?; + let listed = baseline::list(&context.root, &reasons, &keys)?; + match format { + options::ListFormat::Json => say!("{}", serde_json::to_string_pretty(&listed)?), + _ if listed.is_empty() => say!("No accepted findings match."), + options::ListFormat::Text => say!("{}", baseline::list_text(&listed)), + options::ListFormat::Md => say!("{}", baseline::list_markdown(&listed)), + } + } options::BaselineAction::Stats { format } => { let counts = baseline::stats(&context.root)?; match format { diff --git a/src/hook/review.rs b/src/hook/review.rs index 2858776..7145384 100644 --- a/src/hook/review.rs +++ b/src/hook/review.rs @@ -134,6 +134,8 @@ pub(super) struct Dismissed { pub line: usize, pub rule: String, pub reason: Disposition, + /// The note given with the reason, such as the issue that will fix it. + pub note: Option, } /// A changed file of code the check did not judge, and why: it reads as @@ -376,18 +378,20 @@ fn run( /// The findings of `report` the agent dismissed with a reason since the /// turn began at `start`, which the report's gate already accepts. fn dismissed(root: &Path, report: &Report, start: &str) -> Vec { - let reasons = crate::baseline::dismissed_since(root, start).unwrap_or_default(); + let dismissals = crate::baseline::dismissed_since(root, start).unwrap_or_default(); report .files .iter() .flat_map(|file| file.findings.iter().map(move |f| (file, f))) .filter(|(_, f)| f.baselined) .filter_map(|(file, f)| { + let dismissal = dismissals.get(&f.fingerprint)?; Some(Dismissed { path: file.path.clone(), line: f.line, rule: f.rule.clone(), - reason: *reasons.get(&f.fingerprint)?, + reason: dismissal.reason, + note: dismissal.note.clone(), }) }) .collect() diff --git a/src/hook/tests/mod.rs b/src/hook/tests/mod.rs index c4041f8..021ad0b 100644 --- a/src/hook/tests/mod.rs +++ b/src/hook/tests/mod.rs @@ -406,18 +406,22 @@ fn a_new_review_blocks_until_it_is_fixed_or_dismissed_with_a_reason() { checked.files[0].findings[0].gate, Some(crate::schema::Gating::Measuring) ); - // Dismissed with a reason, it counts at once and never blocks again. + // Dismissed with a reason, it counts at once and never blocks again; + // the person reads the note given with it. let dismissed = crate::baseline::mark( &project.0, - crate::options::Disposition::Intended, - &["lib.rs:1".into()], - &[], + &crate::baseline::Mark { + reason: crate::options::Disposition::Intended, + note: Some(Some("the rates are the published tariff".into())), + targets: &["lib.rs:1".into()], + rules: &[], + }, ); assert_eq!(dismissed.unwrap(), 1); let stopped = send(&project, &host, stop(true)); assert!(stopped.get("decision").is_none(), "{stopped}"); assert!( - message(&stopped).starts_with("JevGate: the findings that blocked this turn are fixed or dismissed. JevGate: the agent dismissed 1 finding in this turn's changes (lib.rs:1 maintainability/hardcoded-values as intended); `jevgate baseline stats` counts dismissals by rule and reason."), + message(&stopped).starts_with("JevGate: the findings that blocked this turn are fixed or dismissed. JevGate: the agent dismissed 1 finding in this turn's changes (lib.rs:1 maintainability/hardcoded-values as intended (the rates are the published tariff)); `jevgate baseline list` lists them and `jevgate baseline stats` counts them by rule and reason."), "{stopped}" ); assert!( diff --git a/src/hook/text.rs b/src/hook/text.rs index 5f83cd3..78f9779 100644 --- a/src/hook/text.rs +++ b/src/hook/text.rs @@ -160,21 +160,13 @@ pub(super) fn unreviewed_user(files: &[Unreviewed]) -> Option { if files.is_empty() { return None; } - let mut named: Vec = files - .iter() - .take(NAMED_GUARDS) - .map(|file| { - let why = file.why.split(['.', ';', ',']).next().unwrap_or_default(); - format!("{} ({})", file.path.display(), why.trim()) - }) - .collect(); - if files.len() > NAMED_GUARDS { - named.push(format!("{} more", files.len() - NAMED_GUARDS)); - } + let named = first_named(files, NAMED_GUARDS, |file| { + let why = file.why.split(['.', ';', ',']).next().unwrap_or_default(); + format!("{} ({})", file.path.display(), why.trim()) + }); Some(format!( - "JevGate did not review {} this turn changed: {}.", + "JevGate did not review {} this turn changed: {named}.", output::count(files.len(), "file"), - named.join("; ") )) } @@ -302,19 +294,8 @@ pub(super) fn guards_user(guards: &[Guard]) -> Option { if guards.is_empty() { return None; } - let mut named: Vec = guards - .iter() - .take(NAMED_GUARDS) - .map(|g| clip(&g.describe(), GUARD_CHARS)) - .collect(); - if guards.len() > NAMED_GUARDS { - named.push(format!("{} more", guards.len() - NAMED_GUARDS)); - } - let mut text = format!( - "JevGate: this turn {} ({}).", - guards::summary(guards), - named.join("; ") - ); + let named = first_named(guards, NAMED_GUARDS, |g| clip(&g.describe(), GUARD_CHARS)); + let mut text = format!("JevGate: this turn {} ({named}).", guards::summary(guards)); let edited = |g: &Guard| { matches!( g.kind, @@ -408,26 +389,19 @@ pub(super) fn passed( }); } if !dismissed.is_empty() { - let mut named: Vec = dismissed - .iter() - .take(NAMED_DISMISSED) - .map(|d| { - format!( - "{}:{} {} as {}", - d.path.display(), - d.line, - d.rule, - output::label(&d.reason) - ) - }) - .collect(); - if dismissed.len() > NAMED_DISMISSED { - named.push(format!("{} more", dismissed.len() - NAMED_DISMISSED)); - } + let named = first_named(dismissed, NAMED_DISMISSED, |d| { + let note = d.note.as_ref().map_or(String::new(), |n| format!(" ({n})")); + format!( + "{}:{} {} as {}{note}", + d.path.display(), + d.line, + d.rule, + output::label(&d.reason) + ) + }); notes.push(format!( - "JevGate: the agent dismissed {} in this turn's changes ({}); `jevgate baseline stats` counts dismissals by rule and reason.", + "JevGate: the agent dismissed {} in this turn's changes ({named}); `jevgate baseline list` lists them and `jevgate baseline stats` counts them by rule and reason.", output::count(dismissed.len(), "finding"), - named.join("; ") )); } if !optional.is_empty() { @@ -514,6 +488,16 @@ pub(super) fn named(files: &[PathBuf]) -> String { } } +/// The first `most` of `items` as `name` writes them, and how many more +/// there are, separated by semicolons. +fn first_named(items: &[T], most: usize, name: impl Fn(&T) -> String) -> String { + let mut named: Vec = items.iter().take(most).map(name).collect(); + if items.len() > most { + named.push(format!("{} more", items.len() - most)); + } + named.join("; ") +} + /// "none fails", "1 fails", "2 fail". fn fail(n: usize) -> String { match n { diff --git a/src/options/baseline.rs b/src/options/baseline.rs index 11776ed..e38fb80 100644 --- a/src/options/baseline.rs +++ b/src/options/baseline.rs @@ -1,6 +1,6 @@ //! The baseline actions: `baseline mark` (which findings to dismiss or -//! accepted findings to mark, and why) and `baseline stats` (how to print -//! the counts). +//! accepted findings to mark, and why), `baseline list` (which marks to +//! print, and how) and `baseline stats` (how to print the counts). use super::RulesFormat; use clap::{Subcommand, ValueEnum}; @@ -37,6 +37,27 @@ pub enum BaselineAction { /// Only findings of this rule ID, name, key or group (repeatable) #[arg(long = "rule", value_name = "RULE")] rules: Vec, + /// A short note to keep with the reason, such as the issue that will fix a `later` finding + /// + /// One line of at most 200 characters. Without it, a finding marked + /// again keeps its note; an empty note removes it. + #[arg(long, value_name = "TEXT")] + note: Option, + }, + /// List accepted findings with their reason, rule, location, unit, fingerprint and note + /// + /// Ordered by path, then line. `--format md` prints a checklist to paste + /// into a cleanup issue. + List { + /// Only findings marked with this reason (repeatable) + #[arg(long = "reason", value_enum, value_name = "REASON")] + reasons: Vec, + /// Only findings of this rule ID, name, key or group (repeatable) + #[arg(long = "rule", value_name = "RULE")] + rules: Vec, + /// `text` for people; `md` for a Markdown checklist; `json` for scripts + #[arg(long, value_enum, default_value_t = ListFormat::Text)] + format: ListFormat, }, /// Count accepted findings by rule and reason, with each rule's rate of wrong findings /// @@ -49,3 +70,14 @@ pub enum BaselineAction { format: RulesFormat, }, } + +/// How `baseline list` prints the findings. +#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)] +pub enum ListFormat { + /// Aligned columns + Text, + /// A Markdown checklist + Md, + /// A JSON array + Json, +} diff --git a/src/options/commands.rs b/src/options/commands.rs index a7dc962..d1bc18e 100644 --- a/src/options/commands.rs +++ b/src/options/commands.rs @@ -204,6 +204,8 @@ Examples: jevgate baseline --merge --reason later Accept a partial check's findings as known debt jevgate baseline mark wrong src/api/search.ts:41 A mistaken finding jevgate baseline mark intended scripts --rule maintainability/hardcoded-values + jevgate baseline mark later --note \"#192\" src/api/search.ts:41 + jevgate baseline list --reason later --format md A checklist for a cleanup issue jevgate baseline stats Wrong findings per rule"; /// Overview, workflow, exit codes and files, shown by `jevgate --help`. diff --git a/src/options/mod.rs b/src/options/mod.rs index 8e51018..e04c522 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -5,7 +5,7 @@ mod baseline; mod commands; mod rules; -pub use baseline::{BaselineAction, Disposition}; +pub use baseline::{BaselineAction, Disposition, ListFormat}; pub use commands::{JevCommand, OVERVIEW, RulesFormat}; pub use rules::{ProposeArgs, ProposeFormat, RulesAction, RulesTestArgs}; diff --git a/src/setup/instructions.md b/src/setup/instructions.md index e2e1028..ecc9fdf 100644 --- a/src/setup/instructions.md +++ b/src/setup/instructions.md @@ -6,7 +6,7 @@ JevGate reviews code as you edit it, through hooks. When they run, a session sta If that line is not in this session, JevGate's hooks are not running for you (they may not be trusted yet, or this agent reads these instructions but not the hooks), and no silence from JevGate is a pass: before you finish, run `jevgate check --base HEAD` and act on its findings as below, or say that JevGate did not check your changes. - Read the code at each finding before you change anything, and fix it when it is right; `Next` is a suggested step, not the only fix. -- When a finding is mistaken, the code is meant to be that way, or it is right but left for later, dismiss it with a reason: `jevgate baseline mark wrong PATH:LINE`, `intended` or `later`. The dismissal holds while the code it names is unchanged, the person audits dismissals with `jevgate baseline stats`, and JevGate does not block again when nothing changed. +- When a finding is mistaken, the code is meant to be that way, or it is right but left for later, dismiss it with a reason: `jevgate baseline mark wrong PATH:LINE`, `intended` or `later`; give a `later` mark the issue that will fix it, `--note "#123"`. The dismissal holds while the code it names is unchanged, the person audits dismissals with `jevgate baseline stats` and exports the `later` ones with `jevgate baseline list --reason later --format md`, and JevGate does not block again when nothing changed. - Do not edit `jevgate.toml`, the custom questions in `.jevgate/questions/` or `jevgate-baseline.json` by hand, run `jevgate baseline` without `mark`, add `jevgate: allow` comments, or delete or skip tests to clear a finding, unless the person asks: accepting findings wholesale is their decision. Within a turn such edits do not unblock it: JevGate reads those files as they were when the turn began, and tells the person of each edit. - "JevGate could not check …" means the code was not reviewed: say so in your reply, and do not treat it as a pass. - `jevgate check --base HEAD` lists the findings in your uncommitted changes, and `.jevgate/latest.json` holds the last report. diff --git a/src/tests/gating.rs b/src/tests/gating.rs index 7f03e28..7956898 100644 --- a/src/tests/gating.rs +++ b/src/tests/gating.rs @@ -127,6 +127,24 @@ fn a_scope_makes_its_paths_report_only_while_other_files_gate() { ); } +/// `baseline mark REASON TARGET` without a note, for findings of `rules`. +fn mark( + project: &Project, + reason: options::Disposition, + target: String, + rules: &[&str], +) -> anyhow::Result { + baseline::mark( + &project.0, + &baseline::Mark { + reason, + note: None, + targets: &[target], + rules, + }, + ) +} + /// Save a report as the last check, as `jevgate check` does. fn publish(project: &Project, report: &schema::Report) { storage::Store::open(&project.0) @@ -240,25 +258,16 @@ fn baseline_reasons_are_marked_counted_and_kept_across_rewrites() { (counts[rule].later, counts[rule].wrong_rate), (2, Some(0.0)) ); - assert_eq!( - baseline::mark(&project.0, Wrong, &["b.rs:1".into()], &[]).unwrap(), - 1 - ); - assert!(baseline::mark(&project.0, Wrong, &["c.rs".into()], &[]).is_err()); + assert_eq!(mark(&project, Wrong, "b.rs:1".into(), &[]).unwrap(), 1); + assert!(mark(&project, Wrong, "c.rs".into(), &[]).is_err()); for empty in ["", " ", "/"] { assert!( - baseline::mark(&project.0, Wrong, &[empty.into()], &[]).is_err(), + mark(&project, Wrong, empty.into(), &[]).is_err(), "{empty:?} names no finding" ); } assert!( - baseline::mark( - &project.0, - Wrong, - &["a.rs".into()], - &[crate::catalog::INJECTION] - ) - .is_err(), + mark(&project, Wrong, "a.rs".into(), &[crate::catalog::INJECTION]).is_err(), "the rule filter excludes it" ); let counts = baseline::stats(&project.0).unwrap(); @@ -270,6 +279,65 @@ fn baseline_reasons_are_marked_counted_and_kept_across_rewrites() { assert!(baseline::stats_table(&counts).contains("50%")); } +#[test] +fn a_mark_note_survives_rewrites_merges_and_marking_again() { + use options::Disposition::{Later, Wrong}; + let project = two_files(); + let mut options = args(); + let mut review = Mock { + level: 2, + ..Default::default() + }; + publish(&project, &run(&project, &options, &mut review)); + let noted = |note: Option>, reason| { + baseline::mark( + &project.0, + &baseline::Mark { + reason, + note, + targets: &["b.rs:1".into()], + rules: &[], + }, + ) + .unwrap() + }; + let note_of_b = || { + let listed = baseline::list(&project.0, &[], &[]).unwrap(); + let b = listed.iter().find(|l| l.path.ends_with("b.rs")).unwrap(); + (b.reason, b.note.clone()) + }; + // Dismissed from the last check with a note, before any baseline. + assert_eq!(noted(Some(Some("#192".into())), Later), 1); + assert_eq!(note_of_b(), (Some(Later), Some("#192".into()))); + // Rewriting the baseline from the whole check keeps it. + baseline::write(&project.0, false, None).unwrap(); + assert_eq!(note_of_b(), (Some(Later), Some("#192".into()))); + // So does a merge after a check of `a.rs` alone, and one of `b.rs`. + for checked in ["a.rs", "b.rs"] { + options.paths = vec![checked.into()]; + publish(&project, &run(&project, &options, &mut review)); + baseline::write(&project.0, true, None).unwrap(); + assert_eq!(note_of_b(), (Some(Later), Some("#192".into())), "{checked}"); + } + // Marked again without a note, it keeps the note; an empty one clears it. + assert_eq!(noted(None, Wrong), 1); + assert_eq!(note_of_b(), (Some(Wrong), Some("#192".into()))); + assert_eq!(noted(Some(baseline::note(" ").unwrap()), Wrong), 1); + assert_eq!(note_of_b(), (Some(Wrong), None)); +} + +#[test] +fn a_mark_note_is_one_short_line() { + assert_eq!( + baseline::note(" see #192 ").unwrap(), + Some("see #192".into()) + ); + assert_eq!(baseline::note("").unwrap(), None); + assert!(baseline::note("two\nlines").is_err()); + assert!(baseline::note(&"x".repeat(baseline::NOTE_CHARS)).is_ok()); + assert!(baseline::note(&"x".repeat(baseline::NOTE_CHARS + 1)).is_err()); +} + #[test] fn a_finding_of_the_last_check_is_dismissed_by_its_line_or_fingerprint() { use options::Disposition::Wrong; @@ -283,13 +351,10 @@ fn a_finding_of_the_last_check_is_dismissed_by_its_line_or_fingerprint() { publish(&project, &report); assert!(!project.0.join(baseline::BASELINE_FILE).exists()); assert!( - baseline::mark(&project.0, Wrong, &["b.rs".into()], &[]).is_err(), + mark(&project, Wrong, "b.rs".into(), &[]).is_err(), "a path dismisses no finding nobody read" ); - assert_eq!( - baseline::mark(&project.0, Wrong, &["b.rs:1".into()], &[]).unwrap(), - 1 - ); + assert_eq!(mark(&project, Wrong, "b.rs:1".into(), &[]).unwrap(), 1); let rule = "maintainability/function-simplification"; assert_eq!(baseline::stats(&project.0).unwrap()[rule].wrong, 1); let fingerprint = report @@ -301,7 +366,7 @@ fn a_finding_of_the_last_check_is_dismissed_by_its_line_or_fingerprint() { .fingerprint .clone(); assert_eq!( - baseline::mark(&project.0, Wrong, &[fingerprint[..8].to_string()], &[]).unwrap(), + mark(&project, Wrong, fingerprint[..8].to_string(), &[]).unwrap(), 1 ); let again = run(&project, &options, &mut review); diff --git a/tests/cli/baseline.rs b/tests/cli/baseline.rs new file mode 100644 index 0000000..6ce9941 --- /dev/null +++ b/tests/cli/baseline.rs @@ -0,0 +1,212 @@ +//! `baseline list` and `baseline mark --note` on a committed baseline. +use super::*; + +/// A baseline written before notes and units: two files, one finding +/// without a reason. +const OLD_BASELINE: &str = r#"{ + "version": 1, + "created_at": 1790976832, + "findings": [ + { + "fingerprint": "bbbbbbbb22222222", + "rule": "maintainability/shared-logic", + "path": "src/b.ts", + "line": 40, + "strength": "review", + "message": "Copies.", + "reason": "later" + }, + { + "fingerprint": "aaaaaaaa11111111", + "rule": "maintainability/function-simplification", + "path": "src/a.ts", + "line": 7, + "strength": "review", + "message": "Long.", + "reason": "wrong" + }, + { + "fingerprint": "cccccccc33333333", + "rule": "maintainability/shared-logic", + "path": "src/a.ts", + "line": 3, + "message": "Copies." + } + ] +} +"#; + +fn run(project: &Project, args: &[&str]) -> std::process::Output { + project.command().args(args).output().unwrap() +} + +fn stdout(project: &Project, args: &[&str]) -> String { + let output = run(project, args); + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn baseline_list_prints_text_markdown_and_json_by_path_then_line() { + let project = Project::new(); + std::fs::write(project.0.join("jevgate-baseline.json"), OLD_BASELINE).unwrap(); + assert_eq!( + stdout(&project, &["baseline", "list"]), + "\ +src/a.ts:3 no reason maintainability/shared-logic cccccccc +src/a.ts:7 wrong maintainability/function-simplification aaaaaaaa +src/b.ts:40 later maintainability/shared-logic bbbbbbbb +" + ); + assert_eq!( + stdout( + &project, + &["baseline", "list", "--reason", "later", "--format", "md"] + ), + "- [ ] `src/b.ts:40` maintainability/shared-logic (later, `bbbbbbbb`)\n" + ); + let json: serde_json::Value = serde_json::from_str(&stdout( + &project, + &[ + "baseline", + "list", + "--rule", + "shared-logic", + "--format", + "json", + ], + )) + .unwrap(); + let ids: Vec<&str> = json + .as_array() + .unwrap() + .iter() + .map(|f| f["fingerprint"].as_str().unwrap()) + .collect(); + assert_eq!(ids, ["cccccccc33333333", "bbbbbbbb22222222"]); + assert_eq!(json[1]["reason"], "later"); + assert_eq!(json[0]["reason"], serde_json::Value::Null); + + // A filter that matches nothing, and an unknown rule. + for format in ["text", "md"] { + assert_eq!( + stdout( + &project, + &[ + "baseline", "list", "--reason", "intended", "--format", format + ] + ), + "No accepted findings match.\n" + ); + } + assert_eq!( + stdout( + &project, + &["baseline", "list", "--rule", "security", "--format", "json"] + ), + "[]\n" + ); + assert!( + !run(&project, &["baseline", "list", "--rule", "nonsense"]) + .status + .success() + ); + + // An empty baseline lists nothing in each format. + std::fs::write( + project.0.join("jevgate-baseline.json"), + r#"{"version": 1, "created_at": 0, "findings": []}"#, + ) + .unwrap(); + assert_eq!( + stdout(&project, &["baseline", "list"]), + "No accepted findings match.\n" + ); + assert_eq!( + stdout(&project, &["baseline", "list", "--format", "md"]), + "No accepted findings match.\n" + ); + assert_eq!( + stdout(&project, &["baseline", "list", "--format", "json"]), + "[]\n" + ); +} + +#[test] +fn a_mark_note_is_kept_in_the_baseline_and_listed() { + let project = Project::new(); + let path = project.0.join("jevgate-baseline.json"); + std::fs::write(&path, OLD_BASELINE).unwrap(); + let marked = stdout( + &project, + &["baseline", "mark", "later", "--note", "#192", "src/a.ts:3"], + ); + assert_eq!(marked, "Marked 1 finding as later\n"); + let written: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let old: serde_json::Value = serde_json::from_str(OLD_BASELINE).unwrap(); + let entry = |value: &serde_json::Value, id: &str| { + value["findings"] + .as_array() + .unwrap() + .iter() + .find(|f| f["fingerprint"] == id) + .unwrap() + .clone() + }; + assert_eq!(entry(&written, "cccccccc33333333")["note"], "#192"); + // The entries it did not mark are written as they were. + for id in ["aaaaaaaa11111111", "bbbbbbbb22222222"] { + assert_eq!(entry(&written, id), entry(&old, id)); + } + assert_eq!( + stdout( + &project, + &["baseline", "list", "--format", "md", "--reason", "later"] + ), + "\ +- [ ] `src/a.ts:3` maintainability/shared-logic (later, `cccccccc`): #192 +- [ ] `src/b.ts:40` maintainability/shared-logic (later, `bbbbbbbb`) +" + ); + // A note of two lines is refused, and the baseline left as it was. + let refused = run( + &project, + &["baseline", "mark", "wrong", "--note", "a\nb", "src/a.ts:3"], + ); + assert!(!refused.status.success()); + assert_eq!( + entry( + &serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(), + "cccccccc33333333" + )["reason"], + "later" + ); + + // The unit a finding names follows the columns, before the note. + let text = std::fs::read_to_string(&path).unwrap().replace( + r#""line": 3,"#, + r#""line": 3, "unit": "`total` (src/a.ts:3) and `sum` (src/b.ts:40)","#, + ); + let text = text.replace(r#""line": 7,"#, r#""line": 7, "unit": "total","#); + std::fs::write(&path, text).unwrap(); + assert_eq!( + stdout(&project, &["baseline", "list", "--reason", "later"]), + "\ +src/a.ts:3 later maintainability/shared-logic cccccccc `total` (src/a.ts:3) and `sum` (src/b.ts:40) note: #192 +src/b.ts:40 later maintainability/shared-logic bbbbbbbb +" + ); + assert_eq!( + stdout(&project, &["baseline", "list", "--format", "md"]), + "\ +- [ ] `src/a.ts:3` maintainability/shared-logic `total` (src/a.ts:3) and `sum` (src/b.ts:40) (later, `cccccccc`): #192 +- [ ] `src/a.ts:7` maintainability/function-simplification `total` (wrong, `aaaaaaaa`) +- [ ] `src/b.ts:40` maintainability/shared-logic (later, `bbbbbbbb`) +" + ); +} diff --git a/tests/cli/main.rs b/tests/cli/main.rs index 485cbbc..43efb60 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -1,6 +1,7 @@ //! End-to-end tests of the `jevgate` binary, by command; the shared //! project helper is here. mod auth; +mod baseline; mod changes; mod commit; mod convention;