From aee9350df13a230276a6dfebe488485f82476a48 Mon Sep 17 00:00:00 2001 From: Brent Westbrook <36778786+ntBre@users.noreply.github.com> Date: Wed, 3 Sep 2025 09:08:12 -0400 Subject: [PATCH] [ty] Add GitLab output format (#20155) ## Summary This wires up the GitLab output format moved into `ruff_db` in https://github.com/astral-sh/ruff/pull/20117 to the ty CLI. While I was here, I made one unrelated change to the CLI docs. Clap was rendering the escapes around the `\[default\]` brackets for the `full` output, so I just switched those to parentheses: ``` --output-format The format to use for printing diagnostic messages Possible values: - full: Print diagnostics verbosely, with context and helpful hints \[default\] - concise: Print diagnostics concisely, one per line - gitlab: Print diagnostics in the JSON format expected by GitLab Code Quality reports ``` ## Test Plan New CLI test, and a manual test with `--config 'terminal.output-format = "gitlab"'` to make sure this works as a configuration option too. I also tried piping the output through jq to make sure it's at least valid JSON --- crates/ruff_db/src/diagnostic/mod.rs | 2 +- crates/ty/docs/cli.md | 3 +- crates/ty/src/args.rs | 6 ++- crates/ty/src/lib.rs | 51 +++++++++++------- crates/ty/tests/cli/main.rs | 65 +++++++++++++++++++++++ crates/ty_project/src/metadata/options.rs | 16 ++++++ ty.schema.json | 7 +++ 7 files changed, 127 insertions(+), 23 deletions(-) diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index 774b341f8b..c44dce2116 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -1444,7 +1444,7 @@ pub enum DiagnosticFormat { Junit, /// Print diagnostics in the JSON format used by GitLab [Code Quality] reports. /// - /// [Code Quality]: https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool + /// [Code Quality]: https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format #[cfg(feature = "serde")] Gitlab, } diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md index 9fff1e8876..c02bb6ea4b 100644 --- a/crates/ty/docs/cli.md +++ b/crates/ty/docs/cli.md @@ -60,8 +60,9 @@ over all configuration files.

--output-format output-format

The format to use for printing diagnostic messages

Possible values:

--project project

Run the command within the given project directory.

All pyproject.toml files will be discovered by walking up the directory tree from the given project directory, as will the project's virtual environment (.venv) unless the venv-path option is set.

Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.

diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index f518e028db..1a2eabf5f3 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -306,7 +306,7 @@ impl clap::Args for RulesArg { /// The diagnostic output format. #[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default, clap::ValueEnum)] pub enum OutputFormat { - /// Print diagnostics verbosely, with context and helpful hints \[default\]. + /// Print diagnostics verbosely, with context and helpful hints (default). /// /// Diagnostic messages may include additional context and /// annotations on the input to help understand the message. @@ -321,6 +321,9 @@ pub enum OutputFormat { /// dropped. #[value(name = "concise")] Concise, + /// Print diagnostics in the JSON format expected by GitLab Code Quality reports. + #[value(name = "gitlab")] + Gitlab, } impl From for ty_project::metadata::options::OutputFormat { @@ -328,6 +331,7 @@ impl From for ty_project::metadata::options::OutputFormat { match format { OutputFormat::Full => Self::Full, OutputFormat::Concise => Self::Concise, + OutputFormat::Gitlab => Self::Gitlab, } } } diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index 3e11a3bedf..4e3c5993d0 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -21,7 +21,7 @@ use clap::{CommandFactory, Parser}; use colored::Colorize; use crossbeam::channel as crossbeam_channel; use rayon::ThreadPoolBuilder; -use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, Severity}; +use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, DisplayDiagnostics, Severity}; use ruff_db::files::File; use ruff_db::max_parallelism; use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf}; @@ -319,37 +319,48 @@ impl MainLoop { return Ok(ExitStatus::Success); } + let is_human_readable = terminal_settings.output_format.is_human_readable(); + if result.is_empty() { - writeln!( - self.printer.stream_for_success_summary(), - "{}", - "All checks passed!".green().bold() - )?; + if is_human_readable { + writeln!( + self.printer.stream_for_success_summary(), + "{}", + "All checks passed!".green().bold() + )?; + } if self.watcher.is_none() { return Ok(ExitStatus::Success); } } else { - let mut max_severity = Severity::Info; let diagnostics_count = result.len(); let mut stdout = self.printer.stream_for_details().lock(); - for diagnostic in result { - // Only render diagnostics if they're going to be displayed, since doing - // so is expensive. - if stdout.is_enabled() { - write!(stdout, "{}", diagnostic.display(db, &display_config))?; - } + let max_severity = result + .iter() + .map(Diagnostic::severity) + .max() + .unwrap_or(Severity::Info); - max_severity = max_severity.max(diagnostic.severity()); + // Only render diagnostics if they're going to be displayed, since doing + // so is expensive. + if stdout.is_enabled() { + write!( + stdout, + "{}", + DisplayDiagnostics::new(db, &display_config, &result) + )?; } - writeln!( - self.printer.stream_for_failure_summary(), - "Found {} diagnostic{}", - diagnostics_count, - if diagnostics_count > 1 { "s" } else { "" } - )?; + if is_human_readable { + writeln!( + self.printer.stream_for_failure_summary(), + "Found {} diagnostic{}", + diagnostics_count, + if diagnostics_count > 1 { "s" } else { "" } + )?; + } if max_severity.is_fatal() { tracing::warn!( diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index f85f294842..70d2859f8d 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -618,6 +618,71 @@ fn concise_diagnostics() -> anyhow::Result<()> { Ok(()) } +#[test] +fn gitlab_diagnostics() -> anyhow::Result<()> { + let case = CliTest::with_file( + "test.py", + r#" + print(x) # [unresolved-reference] + print(4[1]) # [non-subscriptable] + "#, + )?; + + let mut settings = insta::Settings::clone_current(); + settings.add_filter(r#"("fingerprint": ")[a-z0-9]+(",)"#, "$1[FINGERPRINT]$2"); + let _s = settings.bind_to_scope(); + + assert_cmd_snapshot!(case.command().arg("--output-format=gitlab").arg("--warn").arg("unresolved-reference"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + [ + { + "check_name": "unresolved-reference", + "description": "unresolved-reference: Name `x` used when not defined", + "severity": "minor", + "fingerprint": "[FINGERPRINT]", + "location": { + "path": "test.py", + "positions": { + "begin": { + "line": 2, + "column": 7 + }, + "end": { + "line": 2, + "column": 8 + } + } + } + }, + { + "check_name": "non-subscriptable", + "description": "non-subscriptable: Cannot subscript object of type `Literal[4]` with no `__getitem__` method", + "severity": "major", + "fingerprint": "[FINGERPRINT]", + "location": { + "path": "test.py", + "positions": { + "begin": { + "line": 3, + "column": 7 + }, + "end": { + "line": 3, + "column": 8 + } + } + } + } + ] + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "#); + + Ok(()) +} + /// This tests the diagnostic format for revealed type. /// /// This test was introduced because changes were made to diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 6e5d614de4..1ec0bb2edb 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -1055,6 +1055,21 @@ pub enum OutputFormat { /// /// This may use color when printing to a `tty`. Concise, + /// Print diagnostics in the JSON format expected by GitLab [Code Quality] reports. + /// + /// [Code Quality]: https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format + Gitlab, +} + +impl OutputFormat { + /// Returns `true` if this format is intended for users to read directly, in contrast to + /// machine-readable or structured formats. + /// + /// This can be used to check whether information beyond the diagnostics, such as a header or + /// `Found N diagnostics` footer, should be included. + pub const fn is_human_readable(&self) -> bool { + matches!(self, OutputFormat::Full | OutputFormat::Concise) + } } impl From for DiagnosticFormat { @@ -1062,6 +1077,7 @@ impl From for DiagnosticFormat { match value { OutputFormat::Full => Self::Full, OutputFormat::Concise => Self::Concise, + OutputFormat::Gitlab => Self::Gitlab, } } } diff --git a/ty.schema.json b/ty.schema.json index a9261dfefb..45cc92b023 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -164,6 +164,13 @@ "enum": [ "concise" ] + }, + { + "description": "Print diagnostics in the JSON format expected by GitLab [Code Quality] reports.\n\n[Code Quality]: https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format", + "type": "string", + "enum": [ + "gitlab" + ] } ] },