diff --git a/Cargo.lock b/Cargo.lock index 14e96ee55..66b8206e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2672,7 +2672,9 @@ dependencies = [ "pbjson", "pbjson-types", "prost", + "schemars", "serde", + "serde_json", "tree-sitter", ] diff --git a/qlty-check/src/planner.rs b/qlty-check/src/planner.rs index 99e952a3a..9e3b801bf 100644 --- a/qlty-check/src/planner.rs +++ b/qlty-check/src/planner.rs @@ -11,15 +11,17 @@ use crate::planner::config_files::PluginConfigFile; use crate::Settings; use anyhow::{bail, Error, Result}; use check_filters::CheckFilters; +use console::style; use document_url_generator::DocumentUrlGenerator; use itertools::Itertools; use qlty_analysis::cache::{Cache, FilesystemCache, NullCache}; use qlty_analysis::git::{compute_upstream, DiffLineFilter}; use qlty_analysis::workspace_entries::TargetMode; use qlty_config::config::issue_transformer::IssueTransformer; -use qlty_config::config::{DriverType, PluginDef}; +use qlty_config::config::{DriverType, Match, PluginDef, Set, Triage}; use qlty_config::{QltyConfig, Workspace}; use qlty_types::analysis::v1::ExecutionVerb; +use qlty_types::{category_from_str, level_from_str}; use rayon::prelude::*; use std::collections::HashMap; use std::time::Instant; @@ -268,12 +270,48 @@ impl Planner { self.transformers .push(Box::new(IssueMuter::new(self.staging_area.clone()))); - // keep overrides last - for issue_override in &self.config.overrides { - self.transformers.push(Box::new(issue_override.clone())); + // keep triage last + let triages = self.build_triages(); + for issue_triage in &triages { + self.transformers.push(Box::new(issue_triage.clone())); } } + fn build_triages(&self) -> Vec { + let mut triages = self.config.triage.clone(); + + if !self.config.overrides.is_empty() { + eprintln!( + "{} The `{}` field in qlty.toml is deprecated. Please use `{}` instead.", + style("WARNING:").bold().yellow(), + style("override").bold(), + style("triage").bold() + ); + + for issue_override in &self.config.overrides { + triages.push(Triage { + set: Set { + level: issue_override.level.as_ref().map(|l| level_from_str(l)), + category: issue_override + .category + .as_ref() + .map(|c| category_from_str(c)), + mode: issue_override.mode, + ..Default::default() + }, + r#match: Match { + plugins: issue_override.plugins.clone(), + rules: issue_override.rules.clone(), + file_patterns: issue_override.file_patterns.clone(), + ..Default::default() + }, + }); + } + } + + triages + } + fn build_plan(&mut self) -> Result { let target_mode = self .target_mode diff --git a/qlty-cli/tests/cmd/check/override.in/.qlty/qlty.toml b/qlty-cli/tests/cmd/check/override.in/.qlty/qlty.toml index cf01ec03b..d083af6f0 100644 --- a/qlty-cli/tests/cmd/check/override.in/.qlty/qlty.toml +++ b/qlty-cli/tests/cmd/check/override.in/.qlty/qlty.toml @@ -12,4 +12,7 @@ batch = true [[plugin]] name = "exists" version = "1.0.0" + +[[override]] +plugin = "exists" mode = "monitor" diff --git a/qlty-cli/tests/cmd/check/override.stderr b/qlty-cli/tests/cmd/check/override.stderr index 4ec892e3f..e151fc9e4 100644 --- a/qlty-cli/tests/cmd/check/override.stderr +++ b/qlty-cli/tests/cmd/check/override.stderr @@ -1 +1,2 @@ +WARNING: The `override` field in qlty.toml is deprecated. Please use `triage` instead. ✖ 1 issue diff --git a/qlty-cli/tests/cmd/check/triage.in/.gitignore b/qlty-cli/tests/cmd/check/triage.in/.gitignore new file mode 100644 index 000000000..abbd1c70e --- /dev/null +++ b/qlty-cli/tests/cmd/check/triage.in/.gitignore @@ -0,0 +1,4 @@ +.qlty/results +.qlty/logs +.qlty/out +.qlty/sources diff --git a/qlty-cli/tests/cmd/check/triage.in/.qlty/qlty.toml b/qlty-cli/tests/cmd/check/triage.in/.qlty/qlty.toml new file mode 100644 index 000000000..b7b109f7f --- /dev/null +++ b/qlty-cli/tests/cmd/check/triage.in/.qlty/qlty.toml @@ -0,0 +1,33 @@ +config_version = "0" + +[plugins.definitions.triaged] +file_types = ["shell"] + +[plugins.definitions.triaged.drivers.lint] +script = "false" +success_codes = [0] +output = "pass_fail" +batch = true + +[[plugin]] +name = "triaged" +version = "1.0.0" + +[plugins.definitions.untriaged] +file_types = ["shell"] + +[plugins.definitions.untriaged.drivers.lint] +script = "false" +success_codes = [0] +output = "pass_fail" +batch = true + +[[plugin]] +name = "untriaged" +version = "1.0.0" + +[[triage]] +match.plugins = ["triaged"] +set.mode = "comment" +set.level = "low" +set.category = "structure" diff --git a/qlty-cli/tests/cmd/check/triage.in/sample.sh b/qlty-cli/tests/cmd/check/triage.in/sample.sh new file mode 100644 index 000000000..3aa740acf --- /dev/null +++ b/qlty-cli/tests/cmd/check/triage.in/sample.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "$foo" diff --git a/qlty-cli/tests/cmd/check/triage.in/sample2.sh b/qlty-cli/tests/cmd/check/triage.in/sample2.sh new file mode 100644 index 000000000..3ae12e588 --- /dev/null +++ b/qlty-cli/tests/cmd/check/triage.in/sample2.sh @@ -0,0 +1,15 @@ +#/usr/bin/env bash +rm -fr ~/.cache/qlty/tools/ruby ~/.cache/qlty/tools/rubocop + +export RUNTIME="ruby" +export RUNTIME_VERSION="3.2.2" + +runtime_directory="/Users/bhelmkamp/.cache/qlty/tools/$RUNTIME/$RUNTIME_VERSION" +mkdir -p $runtime_directory +cd $runtime_directory || exit + +download_filename="v20230330.tar.gz" +download_url="https://github.com/rbenv/ruby-build/archive/refs/tags/$download_filename" +wget "$download_url" +tar --strip-components=1 -xpvzf $download_filename +rm $download_filename diff --git a/qlty-cli/tests/cmd/check/triage.stderr b/qlty-cli/tests/cmd/check/triage.stderr new file mode 100644 index 000000000..eb07d1161 --- /dev/null +++ b/qlty-cli/tests/cmd/check/triage.stderr @@ -0,0 +1 @@ +✖ 2 issues diff --git a/qlty-cli/tests/cmd/check/triage.stdout b/qlty-cli/tests/cmd/check/triage.stdout new file mode 100644 index 000000000..0d43cda66 --- /dev/null +++ b/qlty-cli/tests/cmd/check/triage.stdout @@ -0,0 +1,18 @@ +[ + { + "tool": "untriaged", + "ruleKey": "fail", + "message": "untriaged failed", + "level": "LEVEL_HIGH", + "category": "CATEGORY_LINT", + "mode": "MODE_BLOCK" + }, + { + "tool": "triaged", + "ruleKey": "fail", + "message": "triaged failed", + "level": "LEVEL_LOW", + "category": "CATEGORY_STRUCTURE", + "mode": "MODE_COMMENT" + } +] diff --git a/qlty-cli/tests/cmd/check/triage.toml b/qlty-cli/tests/cmd/check/triage.toml new file mode 100644 index 000000000..2ad0713a4 --- /dev/null +++ b/qlty-cli/tests/cmd/check/triage.toml @@ -0,0 +1,3 @@ +bin.name = "qlty" +args = ["check", "--all", "--no-cache", "--json"] +status.code = 1 diff --git a/qlty-config/src/config.rs b/qlty-config/src/config.rs index 21eb1d781..abf8c2f7a 100644 --- a/qlty-config/src/config.rs +++ b/qlty-config/src/config.rs @@ -11,6 +11,7 @@ mod plugin; mod release; pub mod smells; mod source; +pub mod triage; pub use self::ignore::{Ignore, ALL_WILDCARD}; pub use self::overrides::Override; @@ -29,6 +30,7 @@ pub use plugin::{ }; pub use release::ReleaseDef; pub use source::SourceDef; +pub use triage::{Match, Set, Triage}; use crate::config::plugin::EnabledRuntimes; pub use crate::config::plugin::PluginsConfig; @@ -56,6 +58,10 @@ pub struct QltyConfig { #[serde(rename = "override")] // Since `override` is a reserved keyword pub overrides: Vec, + #[serde(default)] + #[serde(rename = "triage")] + pub triage: Vec, + #[serde(default)] pub file_types: HashMap, diff --git a/qlty-config/src/config/overrides.rs b/qlty-config/src/config/overrides.rs index 335ebbc08..03a2fafeb 100644 --- a/qlty-config/src/config/overrides.rs +++ b/qlty-config/src/config/overrides.rs @@ -1,14 +1,9 @@ -use super::ignore::is_rule_issue_match; use super::IssueMode; -use crate::config::issue_transformer::IssueTransformer; -use globset::{Glob, GlobSet, GlobSetBuilder}; -use qlty_types::category_from_str; -use qlty_types::{analysis::v1::Issue, level_from_str}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::sync::RwLock; -#[derive(Debug, Serialize, Deserialize, Default, JsonSchema)] +// DEPRECATED -- Use Triage instead +#[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] pub struct Override { #[serde(default)] pub level: Option, @@ -27,87 +22,4 @@ pub struct Override { #[serde(default)] pub mode: Option, - - #[serde(skip)] - glob_set: RwLock>, -} - -impl Clone for Override { - fn clone(&self) -> Self { - Self { - level: self.level.clone(), - category: self.category.clone(), - plugins: self.plugins.clone(), - rules: self.rules.clone(), - file_patterns: self.file_patterns.clone(), - mode: self.mode, - glob_set: RwLock::new(None), - } - } -} - -impl IssueTransformer for Override { - fn initialize(&self) { - let mut globset_builder = GlobSetBuilder::new(); - - for glob in &self.file_patterns { - globset_builder.add(Glob::new(glob).unwrap()); - } - - let mut glob_set = self.glob_set.write().unwrap(); - *glob_set = Some(globset_builder.build().unwrap()); - } - - fn transform(&self, issue: Issue) -> Option { - if self.applies_to_issue(&issue) { - let mut new_issue = issue.clone(); - - if let Some(level) = &self.level { - new_issue.level = level_from_str(level).into(); - } - - if let Some(category) = &self.category { - new_issue.category = category_from_str(category).into(); - } - - if let Some(mode) = &self.mode { - new_issue.mode = *mode as i32; - } - - Some(new_issue) - } else { - Some(issue) - } - } - - fn clone_box(&self) -> Box { - Box::new(self.clone()) - } -} - -impl Override { - fn applies_to_issue(&self, issue: &Issue) -> bool { - self.plugin_applies_to_issue(issue) - && is_rule_issue_match(&self.rules, issue) - && self.glob_applies_to_issue(issue) - } - - fn plugin_applies_to_issue(&self, issue: &Issue) -> bool { - self.plugins.is_empty() || self.plugins.contains(&issue.tool.to_string()) - } - - fn glob_applies_to_issue(&self, issue: &Issue) -> bool { - if self.file_patterns.is_empty() { - return true; - } - - let glob_set = self.glob_set.read().unwrap(); - - if let Some(path) = issue.path() { - glob_set.as_ref().unwrap().is_match(path) - } else { - // TODO: Issues without a path are not filterable - false - } - } } diff --git a/qlty-config/src/config/triage.rs b/qlty-config/src/config/triage.rs new file mode 100644 index 000000000..a353c6400 --- /dev/null +++ b/qlty-config/src/config/triage.rs @@ -0,0 +1,165 @@ +use super::ignore::is_rule_issue_match; +use super::IssueMode; +use crate::config::issue_transformer::IssueTransformer; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use qlty_types::analysis::v1::{Category, Issue, Level}; +use qlty_types::{category_from_str, level_from_str}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; +use std::sync::RwLock; + +#[derive(Debug, Serialize, Deserialize, Default, JsonSchema)] +pub struct Match { + #[serde(default)] + pub plugins: Vec, + + #[serde(default)] + pub rules: Vec, + + #[serde(default)] + pub file_patterns: Vec, + + #[serde(skip)] + pub glob_set: RwLock>, +} + +impl Clone for Match { + fn clone(&self) -> Self { + Self { + plugins: self.plugins.clone(), + rules: self.rules.clone(), + file_patterns: self.file_patterns.clone(), + glob_set: RwLock::new(None), + } + } +} + +#[derive(Debug, Serialize, Default, Clone, JsonSchema)] +pub struct Set { + #[serde(default)] + pub level: Option, + + #[serde(default)] + pub category: Option, + + #[serde(default)] + pub mode: Option, + + #[serde(default)] + pub ignored: bool, +} + +impl<'de> Deserialize<'de> for Set { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct SetHelper { + #[serde(default)] + level: Option, + + #[serde(default)] + category: Option, + + #[serde(default)] + mode: Option, + + #[serde(default)] + ignored: bool, + } + + let helper = SetHelper::deserialize(deserializer)?; + + Ok(Set { + level: helper.level.as_deref().map(level_from_str), + category: helper.category.as_deref().map(category_from_str), + mode: helper.mode, + ignored: helper.ignored, + }) + } +} + +#[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] +pub struct Triage { + #[serde(default)] + #[serde(rename = "match")] + pub r#match: Match, + + #[serde(default)] + pub set: Set, +} + +impl Match { + fn initialize(&self) { + let mut globset_builder = GlobSetBuilder::new(); + + for glob in &self.file_patterns { + globset_builder.add(Glob::new(glob).unwrap()); + } + + let mut glob_set = self.glob_set.write().unwrap(); + *glob_set = Some(globset_builder.build().unwrap()); + } + + fn applies_to_issue(&self, issue: &Issue) -> bool { + self.plugin_applies_to_issue(issue) + && is_rule_issue_match(&self.rules, issue) + && self.glob_applies_to_issue(issue) + } + + fn plugin_applies_to_issue(&self, issue: &Issue) -> bool { + self.plugins.is_empty() || self.plugins.contains(&issue.tool.to_string()) + } + + fn glob_applies_to_issue(&self, issue: &Issue) -> bool { + if self.file_patterns.is_empty() { + return true; + } + + let glob_set = self.glob_set.read().unwrap(); + + if let Some(path) = issue.path() { + glob_set.as_ref().unwrap().is_match(path) + } else { + // TODO: Issues without a path are not filterable + false + } + } +} + +impl IssueTransformer for Triage { + fn initialize(&self) { + self.r#match.initialize(); + } + + fn transform(&self, issue: Issue) -> Option { + if self.r#match.applies_to_issue(&issue) { + if self.set.ignored { + return None; + } + + let mut new_issue = issue.clone(); + + if let Some(level) = &self.set.level { + new_issue.level = *level as i32; + } + + if let Some(category) = &self.set.category { + new_issue.category = *category as i32; + } + + if let Some(mode) = &self.set.mode { + new_issue.mode = *mode as i32; + } + + Some(new_issue) + } else { + Some(issue) + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} diff --git a/qlty-types/Cargo.toml b/qlty-types/Cargo.toml index 9f5723e53..51ee25da5 100644 --- a/qlty-types/Cargo.toml +++ b/qlty-types/Cargo.toml @@ -19,5 +19,7 @@ clap.workspace = true pbjson-types.workspace = true pbjson.workspace = true prost.workspace = true +schemars.workspace = true serde.workspace = true +serde_json.workspace = true tree-sitter.workspace = true diff --git a/qlty-types/src/protos/qlty.analysis.v1.rs b/qlty-types/src/protos/qlty.analysis.v1.rs index 3b4e4cfa6..e2ec9cec5 100644 --- a/qlty-types/src/protos/qlty.analysis.v1.rs +++ b/qlty-types/src/protos/qlty.analysis.v1.rs @@ -497,6 +497,7 @@ impl MessageLevel { } } } +#[derive(schemars::JsonSchema)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum Level { @@ -535,6 +536,7 @@ impl Level { } } } +#[derive(schemars::JsonSchema)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum Category {