From e031e78368deaa37c0304b5e0273d5dbd1a4a076 Mon Sep 17 00:00:00 2001 From: Nitish Agarwal <1592163+nitishagar@users.noreply.github.com> Date: Mon, 18 May 2026 08:44:02 +0530 Subject: [PATCH] add junit-xml output format Adds OutputFormat::JunitXml producing a JUnit-XML document so pyrefly's report can be consumed by CI dashboards. Hand-rolled writer with proper attribute and CDATA escaping; no new deps. Fixes #3389 --- crates/pyrefly_config/src/config.rs | 6 ++ pyrefly/lib/commands/check.rs | 153 ++++++++++++++++++++++++++++ test/errors.md | 12 +++ website/docs/configuration.mdx | 4 +- 4 files changed, 174 insertions(+), 1 deletion(-) diff --git a/crates/pyrefly_config/src/config.rs b/crates/pyrefly_config/src/config.rs index 7ca5ce457f..7e5d4e0354 100644 --- a/crates/pyrefly_config/src/config.rs +++ b/crates/pyrefly_config/src/config.rs @@ -159,6 +159,8 @@ pub enum OutputFormat { Json, /// Emit GitHub Actions workflow commands Github, + /// Emit JUnit XML + JunitXml, /// Only show error count, omitting individual errors OmitErrors, } @@ -2091,6 +2093,10 @@ output-format = "omit-errors" "#; let config = ConfigFile::parse_config(config_str).unwrap(); assert_eq!(config.output_format, Some(OutputFormat::OmitErrors)); + + let config_str = r#"output-format = "junit-xml""#; + let config = ConfigFile::parse_config(config_str).unwrap(); + assert_eq!(config.output_format, Some(OutputFormat::JunitXml)); } #[test] diff --git a/pyrefly/lib/commands/check.rs b/pyrefly/lib/commands/check.rs index 8fb99afc7a..dcdd7f6018 100644 --- a/pyrefly/lib/commands/check.rs +++ b/pyrefly/lib/commands/check.rs @@ -431,6 +431,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::JunitXml => write_error_junit_xml_to_file(path, relative_to, errors), OutputFormat::OmitErrors => Ok(()), } } @@ -445,6 +446,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::JunitXml => write_error_junit_xml_to_console(relative_to, errors), OutputFormat::OmitErrors => Ok(()), } } @@ -537,6 +539,112 @@ fn write_error_github_to_console(errors: &[Error]) -> anyhow::Result<()> { buffered_write_error_github(stdout(), errors) } +fn xml_escape_attr(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + '\n' => out.push_str(" "), + '\r' => out.push_str(" "), + '\t' => out.push_str(" "), + c if (c as u32) < 0x20 => {} + c => out.push(c), + } + } + out +} + +fn xml_escape_cdata(s: &str) -> String { + // CDATA cannot contain "]]>"; split it across CDATA boundaries. + s.replace("]]>", "]]]]>") +} + +fn write_error_junit_xml( + mut writer: W, + relative_to: &Path, + errors: &[Error], +) -> anyhow::Result<()> { + let visible: Vec<&Error> = errors + .iter() + .filter(|e| matches!(e.severity(), Severity::Error | Severity::Warn)) + .collect(); + let n = visible.len(); + + writeln!(writer, r#""#)?; + writeln!(writer, "")?; + writeln!( + writer, + r#" "# + )?; + + for err in visible { + let error_path = err.path().as_path(); + let path = error_path + .strip_prefix(relative_to) + .unwrap_or(error_path) + .to_string_lossy() + .into_owned(); + let line = err.display_range().start.line_within_cell().get(); + let kind = err.error_kind().to_name(); + let header = err.msg_header(); + let full = err.msg(); + let failure_type = match err.severity() { + Severity::Warn => "warning".to_owned(), + _ => kind.to_owned(), + }; + + writeln!( + writer, + r#" "#, + xml_escape_attr(&path), + xml_escape_attr(kind), + line, + xml_escape_attr(&path), + line, + )?; + writeln!( + writer, + r#" "#, + xml_escape_attr(&failure_type), + xml_escape_attr(header), + xml_escape_cdata(&full), + )?; + writeln!(writer, " ")?; + } + + writeln!(writer, " ")?; + writeln!(writer, "")?; + Ok(()) +} + +fn buffered_write_error_junit_xml( + writer: impl std::io::Write, + relative_to: &Path, + errors: &[Error], +) -> anyhow::Result<()> { + let mut writer = BufWriter::new(writer); + write_error_junit_xml(&mut writer, relative_to, errors)?; + writer.flush()?; + Ok(()) +} + +fn write_error_junit_xml_to_file( + path: &Path, + relative_to: &Path, + errors: &[Error], +) -> anyhow::Result<()> { + let file = File::create(path)?; + buffered_write_error_junit_xml(file, relative_to, errors) +} + +fn write_error_junit_xml_to_console(relative_to: &Path, errors: &[Error]) -> anyhow::Result<()> { + buffered_write_error_junit_xml(stdout(), relative_to, errors) +} + fn severity_to_github_command(severity: Severity) -> Option<&'static str> { let normalized = severity_to_str(severity); match normalized.as_str() { @@ -1448,6 +1556,51 @@ mod tests { assert!(output.ends_with("::bad\n")); } + #[test] + fn junit_xml_output_format_writes_well_formed_xml() { + let errors = vec![ + sample_error("first error".into()), + sample_error("second error".into()), + ]; + let mut buf = Vec::new(); + write_error_junit_xml(&mut buf, Path::new("/"), &errors).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!( + output.starts_with(r#""#), + "missing XML declaration: {output}" + ); + assert!( + output.contains(r#"\n"), "missing closing tag: {output}"); + } + + #[test] + fn junit_xml_escapes_special_chars_in_messages() { + let errors = vec![sample_error(r#"a < b & c > d "e" 'f'"#.into())]; + let mut buf = Vec::new(); + write_error_junit_xml(&mut buf, Path::new("/"), &errors).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("<"), "< not escaped: {output}"); + assert!(output.contains("&"), "& not escaped: {output}"); + assert!(output.contains(">"), "> not escaped: {output}"); + assert!(output.contains("""), "\" not escaped: {output}"); + assert!(output.contains("'"), "' not escaped: {output}"); + + // CDATA split for ]]> + let errors2 = vec![sample_error("x ]]> y".into())]; + let mut buf2 = Vec::new(); + write_error_junit_xml(&mut buf2, Path::new("/"), &errors2).unwrap(); + let output2 = String::from_utf8(buf2).unwrap(); + assert!( + output2.contains("]]]]> was not split across CDATA boundaries: {output2}" + ); + } + #[test] fn output_args_inherit_output_format_from_config() { let mut output = OutputArgs::parse_from(["pyrefly-check"]); diff --git a/test/errors.md b/test/errors.md index e1245c22fd..7321536e83 100644 --- a/test/errors.md +++ b/test/errors.md @@ -127,3 +127,15 @@ $ echo "x: str = 0" > $TMPDIR/test.py && \ WARN */test.py:1:10-11: `Literal[0]` is not assignable to `str` [bad-assignment] (glob) [1] ``` + +## `--output-format junit-xml` emits well-formed XML + +```scrut {output_stream: stdout} +$ touch $TMPDIR/pyrefly.toml && \ +> echo "x: str = 0" > $TMPDIR/bad.py && \ +> $PYREFLY check --output-format junit-xml $TMPDIR/bad.py 2>/dev/null | head -3 + + + (glob) +[1] +``` diff --git a/website/docs/configuration.mdx b/website/docs/configuration.mdx index 89db0fa1da..3a72fbde29 100644 --- a/website/docs/configuration.mdx +++ b/website/docs/configuration.mdx @@ -495,13 +495,15 @@ 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" | "junit-xml" | "omit-errors"` - Default: `full-text` - Flag equivalent: `--output-format` - Notes: - `output-format` is a **project-level setting** and cannot be overridden in [`sub-config`](#sub-configs) sections. - A CLI `--output-format` flag still takes precedence over the config value. + - `"junit-xml"` emits a JUnit XML `` report suitable for CI + dashboards (Jenkins, GitLab MR widgets, CircleCI, Azure DevOps, etc.). ### `preset`