diff --git a/crates/pyrefly_config/src/config.rs b/crates/pyrefly_config/src/config.rs index 1984ccee34..685124f6fc 100644 --- a/crates/pyrefly_config/src/config.rs +++ b/crates/pyrefly_config/src/config.rs @@ -133,6 +133,8 @@ pub enum OutputFormat { Json, /// Emit GitHub Actions workflow commands Github, + /// Emit CodeClimate issues in a JSON array (e.g. for GitLab Code Quality reports) + Codeclimate, /// Only show error count, omitting individual errors OmitErrors, } diff --git a/pyrefly/lib/commands/check.rs b/pyrefly/lib/commands/check.rs index a5a75cabe7..8e63aaeca8 100644 --- a/pyrefly/lib/commands/check.rs +++ b/pyrefly/lib/commands/check.rs @@ -59,6 +59,7 @@ use crate::commands::files::FilesArgs; use crate::commands::util::CommandExitStatus; use crate::config::error_kind::Severity; use crate::config::finder::ConfigFinder; +use crate::error::codeclimate::CodeClimateIssues; use crate::error::error::Error; use crate::error::error::print_error_counts; use crate::error::legacy::LegacyError; @@ -397,6 +398,7 @@ fn write_errors_to_file( OutputFormat::FullText => write_error_text_to_file(path, relative_to, errors, true), OutputFormat::Json => write_error_json_to_file(path, relative_to, errors), OutputFormat::Github => write_error_github_to_file(path, errors), + OutputFormat::Codeclimate => write_error_codeclimate_to_file(path, relative_to, errors), OutputFormat::OmitErrors => Ok(()), } } @@ -411,6 +413,7 @@ fn write_errors_to_console( OutputFormat::FullText => write_error_text_to_console(relative_to, errors, true), OutputFormat::Json => write_error_json_to_console(relative_to, errors), OutputFormat::Github => write_error_github_to_console(errors), + OutputFormat::Codeclimate => write_error_codeclimate_to_console(relative_to, errors), OutputFormat::OmitErrors => Ok(()), } } @@ -550,6 +553,44 @@ fn escape_workflow_property(value: &str) -> String { utf8_percent_encode(value, WORKFLOW_PROPERTY_ENCODE_SET).to_string() } +fn write_error_codeclimate( + writer: &mut impl Write, + relative_to: &Path, + errors: &[Error], +) -> anyhow::Result<()> { + let issues = CodeClimateIssues::from_errors(relative_to, errors); + serde_json::to_writer_pretty(writer, &issues)?; + Ok(()) +} + +fn buffered_write_error_codeclimate( + writer: impl Write, + relative_to: &Path, + errors: &[Error], +) -> anyhow::Result<()> { + let mut writer = BufWriter::new(writer); + write_error_codeclimate(&mut writer, relative_to, errors)?; + writer.flush()?; + Ok(()) +} + +fn write_error_codeclimate_to_file( + path: &Path, + relative_to: &Path, + errors: &[Error], +) -> anyhow::Result<()> { + fn f(path: &Path, relative_to: &Path, errors: &[Error]) -> anyhow::Result<()> { + let file = File::create(path)?; + buffered_write_error_codeclimate(file, relative_to, errors) + } + f(path, relative_to, errors) + .with_context(|| format!("while writing CodeClimate issues to `{}`", path.display())) +} + +fn write_error_codeclimate_to_console(relative_to: &Path, errors: &[Error]) -> anyhow::Result<()> { + buffered_write_error_codeclimate(stdout(), relative_to, errors) +} + /// A data structure to facilitate the creation of handles for all the files we want to check. pub struct Handles { /// A mapping from a file to all other information needed to create a `Handle`. diff --git a/pyrefly/lib/error/codeclimate.rs b/pyrefly/lib/error/codeclimate.rs new file mode 100644 index 0000000000..57b2ec9a4e --- /dev/null +++ b/pyrefly/lib/error/codeclimate.rs @@ -0,0 +1,119 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +use std::hash::DefaultHasher; +use std::hash::Hash as _; +use std::hash::Hasher as _; +use std::path::Path; + +use pyrefly_config::error_kind::Severity; +use pyrefly_util::prelude::SliceExt; +use serde::Deserialize; +use serde::Serialize; + +use crate::error::error::Error; + +pub(crate) fn severity_to_str(severity: Severity) -> String { + match severity { + Severity::Ignore => "info".to_owned(), // This is the lowest valid severity level + Severity::Info => "info".to_owned(), + Severity::Warn => "minor".to_owned(), + Severity::Error => "major".to_owned(), + } +} + +/// The structure for a CodeClimate issue +/// . +/// +/// Used to serialize errors for platforms that expect the CodeClimate format, like GitLab CI/CD's +/// Code Quality report artifact . +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct CodeClimateIssue { + #[serde(rename = "type")] + issue_type: String, + check_name: String, + description: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + categories: Vec, + location: CodeClimateIssueLocation, + severity: String, + fingerprint: String, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +struct CodeClimateIssueContent { + body: String, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +struct CodeClimateIssueLocation { + path: String, + positions: CodeClimateIssuePositions, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +struct CodeClimateIssuePositions { + begin: CodeClimateIssuePosition, + end: CodeClimateIssuePosition, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +struct CodeClimateIssuePosition { + line: u32, + column: u32, +} + +impl CodeClimateIssue { + pub fn from_error(relative_to: &Path, error: &Error) -> Self { + let error_range = error.display_range(); + let error_path = error.path().as_path(); + + let mut hasher = DefaultHasher::new(); + error.hash(&mut hasher); + let fingerprint = format!("{:x}", hasher.finish()); + + Self { + issue_type: "issue".to_owned(), + check_name: format!("Pyrefly/{}", error.error_kind()), + description: error.msg_header().to_owned(), + content: error.msg_details().map(|details| CodeClimateIssueContent { + body: details.to_owned(), + }), + categories: vec!["Bug Risk".to_owned()], + location: CodeClimateIssueLocation { + path: error_path + .strip_prefix(relative_to) + .unwrap_or(error_path) + .to_string_lossy() + .into_owned(), + positions: CodeClimateIssuePositions { + begin: CodeClimateIssuePosition { + line: error_range.start.line_within_cell().get(), + column: error_range.start.column().get(), + }, + end: CodeClimateIssuePosition { + line: error_range.end.line_within_cell().get(), + column: error_range.end.column().get(), + }, + }, + }, + severity: severity_to_str(error.severity()), + fingerprint, + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(transparent)] +pub struct CodeClimateIssues(Vec); + +impl CodeClimateIssues { + pub fn from_errors(relative_to: &Path, errors: &[Error]) -> Self { + Self(errors.map(|e| CodeClimateIssue::from_error(relative_to, e))) + } +} diff --git a/pyrefly/lib/error/mod.rs b/pyrefly/lib/error/mod.rs index bcc0a01036..11e3fbab75 100644 --- a/pyrefly/lib/error/mod.rs +++ b/pyrefly/lib/error/mod.rs @@ -6,6 +6,7 @@ */ pub mod baseline; +pub mod codeclimate; pub mod collector; pub mod context; pub mod display; diff --git a/test/snippet.md b/test/snippet.md index cf1b49effc..898aa499ea 100644 --- a/test/snippet.md +++ b/test/snippet.md @@ -119,6 +119,38 @@ $ $PYREFLY snippet "x: int = 'hello'" --output-format=json [1] ``` +## Snippet with CodeClimate output format + +```scrut +$ $PYREFLY snippet "x: int = 'hello'" --output-format=codeclimate +[ + { + "type": "issue", + "check_name": "Pyrefly/BadAssignment", + "description": "`Literal['hello']` is not assignable to `int`", + "categories": [ + "Bug Risk" + ], + "location": { + "path": "snippet", + "positions": { + "begin": { + "line": 1, + "column": 10 + }, + "end": { + "line": 1, + "column": 17 + } + } + }, + "severity": "major", + "fingerprint": "*" (glob) + } +] (no-eol) +[1] +``` + ## Snippet with config file ```scrut {output_stream: stderr} diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index bf6ffcb686..3826be3c6a 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -487,7 +487,7 @@ min-severity = "warn" Default format for `pyrefly check` error output when `--output-format` is not set on the CLI. -- Type: `"min-text" | "full-text" | "json" | "github" | "omit-errors"` +- Type: `"min-text" | "full-text" | "json" | "github" | "codeclimate" | "omit-errors"` - Default: `full-text` - Flag equivalent: `--output-format` - Notes: