diff --git a/Cargo.lock b/Cargo.lock index cc6bc4ad45..7a3c5d0184 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4866,6 +4866,7 @@ dependencies = [ "ruff_python_formatter", "ruff_python_trivia", "ruff_ranged_value", + "ruff_source_file", "ruff_text_size", "rustc-hash", "salsa", @@ -4873,6 +4874,7 @@ dependencies = [ "serde", "serde_json", "shellexpand", + "similar 3.2.0", "strum", "strum_macros", "tempfile", diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index 3576305f82..12194caff0 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -286,6 +286,11 @@ impl Diagnostic { self.inner.severity } + /// Sets the severity of this diagnostic. + pub fn set_severity(&mut self, severity: Severity) { + Arc::make_mut(&mut self.inner).severity = severity; + } + /// Returns a shared borrow of the "primary" annotation of this diagnostic /// if one exists. /// @@ -1098,6 +1103,9 @@ pub enum DiagnosticId { /// Use of an invalid command-line option. InvalidCliOption, + /// The configured diagnostic baseline is invalid. + InvalidBaseline, + /// Experimental feature requires preview mode. PreviewFeature, @@ -1156,6 +1164,7 @@ impl DiagnosticId { DiagnosticId::UnsupportedPythonVersion => "unsupported-python-version", DiagnosticId::Unformatted => "unformatted", DiagnosticId::InvalidCliOption => "invalid-cli-option", + DiagnosticId::InvalidBaseline => "invalid-baseline", DiagnosticId::PreviewFeature => "preview-feature", DiagnosticId::InternalError => "internal-error", } @@ -1327,6 +1336,7 @@ impl From for Span { #[cfg_attr(feature = "serde", derive(Serialize))] #[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))] pub enum Severity { + Hint, Info, Warning, Error, @@ -1336,7 +1346,7 @@ pub enum Severity { impl Severity { fn to_annotate(self) -> AnnotateLevel<'static> { match self { - Severity::Info => AnnotateLevel::INFO, + Severity::Hint | Severity::Info => AnnotateLevel::INFO, Severity::Warning => AnnotateLevel::WARNING, Severity::Error => AnnotateLevel::ERROR, // NOTE: Should we really collapse this to "error"? diff --git a/crates/ruff_db/src/diagnostic/render/azure.rs b/crates/ruff_db/src/diagnostic/render/azure.rs index 05128c9d88..e946a2a61e 100644 --- a/crates/ruff_db/src/diagnostic/render/azure.rs +++ b/crates/ruff_db/src/diagnostic/render/azure.rs @@ -22,7 +22,7 @@ impl Renderer for AzureRenderer<'_> { fn render(&self, f: &mut std::fmt::Formatter, diagnostics: &[Diagnostic]) -> std::fmt::Result { for diag in diagnostics { let severity = match diag.severity() { - Severity::Info | Severity::Warning => "warning", + Severity::Hint | Severity::Info | Severity::Warning => "warning", Severity::Error | Severity::Fatal => "error", }; write!(f, "##vso[task.logissue type={severity};")?; diff --git a/crates/ruff_db/src/diagnostic/render/concise.rs b/crates/ruff_db/src/diagnostic/render/concise.rs index dd2da2d23d..2774607d4e 100644 --- a/crates/ruff_db/src/diagnostic/render/concise.rs +++ b/crates/ruff_db/src/diagnostic/render/concise.rs @@ -92,6 +92,7 @@ impl Renderer for ConciseRenderer<'_> { } } else { let (severity, severity_style) = match diag.severity() { + Severity::Hint => ("hint", stylesheet.info), Severity::Info => ("info", stylesheet.info), Severity::Warning => ("warning", stylesheet.warning), Severity::Error => ("error", stylesheet.error), diff --git a/crates/ruff_db/src/diagnostic/render/github.rs b/crates/ruff_db/src/diagnostic/render/github.rs index 6be37b3b09..8f86fcec6a 100644 --- a/crates/ruff_db/src/diagnostic/render/github.rs +++ b/crates/ruff_db/src/diagnostic/render/github.rs @@ -22,7 +22,7 @@ impl Renderer for GithubRenderer<'_> { fn render(&self, f: &mut std::fmt::Formatter, diagnostics: &[Diagnostic]) -> std::fmt::Result { for diagnostic in diagnostics { let severity = match diagnostic.severity() { - Severity::Info => "notice", + Severity::Hint | Severity::Info => "notice", Severity::Warning => "warning", Severity::Error | Severity::Fatal => "error", }; diff --git a/crates/ruff_db/src/diagnostic/render/gitlab.rs b/crates/ruff_db/src/diagnostic/render/gitlab.rs index 5fef761917..e1a6d42c3d 100644 --- a/crates/ruff_db/src/diagnostic/render/gitlab.rs +++ b/crates/ruff_db/src/diagnostic/render/gitlab.rs @@ -104,7 +104,7 @@ impl Serialize for SerializedMessages<'_> { diagnostic.secondary_code_or_id() }; let severity = match diagnostic.severity() { - Severity::Info => "info", + Severity::Hint | Severity::Info => "info", Severity::Warning => "minor", Severity::Error => "major", // Another option here is `blocker` diff --git a/crates/ruff_db/src/diagnostic/render/junit.rs b/crates/ruff_db/src/diagnostic/render/junit.rs index aa4be4a418..2107b95d83 100644 --- a/crates/ruff_db/src/diagnostic/render/junit.rs +++ b/crates/ruff_db/src/diagnostic/render/junit.rs @@ -110,6 +110,7 @@ impl Renderer for JunitRenderer<'_> { const fn severity_name(severity: Severity) -> &'static str { match severity { + Severity::Hint => "hint", Severity::Info => "info", Severity::Warning => "warning", Severity::Error => "error", diff --git a/crates/ruff_db/src/diagnostic/render/sarif.rs b/crates/ruff_db/src/diagnostic/render/sarif.rs index 9fff659c38..41fd1f5df3 100644 --- a/crates/ruff_db/src/diagnostic/render/sarif.rs +++ b/crates/ruff_db/src/diagnostic/render/sarif.rs @@ -318,7 +318,7 @@ enum SarifLevel { impl From for SarifLevel { fn from(value: Severity) -> Self { match value { - Severity::Info => SarifLevel::Note, + Severity::Hint | Severity::Info => SarifLevel::Note, Severity::Warning => SarifLevel::Warning, Severity::Error | Severity::Fatal => SarifLevel::Error, } diff --git a/crates/ruff_server/src/lint.rs b/crates/ruff_server/src/lint.rs index 539fd12ba3..30df709812 100644 --- a/crates/ruff_server/src/lint.rs +++ b/crates/ruff_server/src/lint.rs @@ -393,6 +393,7 @@ fn to_lsp_diagnostic( match diagnostic.severity() { // Map lint diagnostics without codes to warning, like in `severity`. _ if diagnostic.id().is_lint() => lsp_types::DiagnosticSeverity::Warning, + ruff_db::diagnostic::Severity::Hint => lsp_types::DiagnosticSeverity::Hint, ruff_db::diagnostic::Severity::Info => lsp_types::DiagnosticSeverity::Information, ruff_db::diagnostic::Severity::Warning => lsp_types::DiagnosticSeverity::Warning, ruff_db::diagnostic::Severity::Error => lsp_types::DiagnosticSeverity::Error, diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md index ee0300ae54..a4ecac7036 100644 --- a/crates/ty/docs/cli.md +++ b/crates/ty/docs/cli.md @@ -39,6 +39,7 @@ ty check [OPTIONS] [PATH]...

Options

--add-ignore

Adds ty: ignore comments to suppress all rule diagnostics

+
--baseline path

Path to a JSON diagnostic baseline (preview)

--color when

Control when colored output is used

Possible values:

    @@ -108,6 +109,7 @@ over all configuration files.

--quiet, -q

Use quiet output (or -qq for silent output)

--respect-ignore-files

Respect file exclusions via .gitignore and other standard ignore files. Use --no-respect-ignore-files to disable

--typeshed, --custom-typeshed-dir path

Custom directory to use for stdlib typeshed stubs

+
--update-baseline

Create or update the configured diagnostic baseline (preview)

--verbose, -v

Use verbose output (or -vv and -vvv for more verbose output)

--warn rule

Treat the given rule as having severity 'warn'. Can be specified multiple times. Use 'all' to apply to all rules.

--watch, -W

Watch files for changes and recheck files related to the changed files

diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index 5468150ded..7d0b1dfbf4 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -1,6 +1,35 @@ # Configuration +## `baseline` + +Path to the JSON file that records existing lint diagnostics for this project. +Relative paths are resolved against the project root. Use `ty check --update-baseline` +to create or update the file. + +Diagnostic baselines are in preview. The file format may change between ty releases. + +**Default value**: `null` + +**Type**: `str` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty] + baseline = "ty-baseline.json" + ``` + +=== "ty.toml" + + ```toml + baseline = "ty-baseline.json" + ``` + +--- + ## `rules` Configures the enabled rules and their severity. diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index 27a2a8a514..f155bcc9d5 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -77,6 +77,14 @@ pub(crate) struct CheckCommand { #[arg(long, conflicts_with("fix"))] pub(crate) add_ignore: bool, + /// Path to a JSON diagnostic baseline (preview). + #[arg(long, value_name = "PATH")] + baseline: Option, + + /// Create or update the configured diagnostic baseline (preview). + #[arg(long, conflicts_with_all = ["watch", "fix", "add_ignore"])] + pub(crate) update_baseline: bool, + /// 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, @@ -273,6 +281,7 @@ impl CheckCommand { .then_some(false) .or(self.error_on_warning); let options = Options { + baseline: self.baseline.map(RelativePathBuf::cli), environment: Some(EnvironmentOptions { python_version: self.python_version.map(Into::into).map(RangedValue::cli), python_platform: self diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index d194f07e95..bc4f8e9637 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -132,6 +132,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result { MainLoopMode::Fix(FixMode::ApplyFixes) } else if args.add_ignore { MainLoopMode::Fix(FixMode::AddIgnore) + } else if args.update_baseline { + MainLoopMode::UpdateBaseline } else { MainLoopMode::Check }; @@ -176,6 +178,12 @@ fn run_check(args: CheckCommand) -> anyhow::Result { let mut db = ProjectDatabase::fallible(project_metadata, system)?; let project = db.project(); + if matches!(mode, MainLoopMode::UpdateBaseline) && project.settings(&db).baseline().is_none() { + return Err(anyhow!( + "`--update-baseline` requires a baseline path from `--baseline` or configuration" + )); + } + project.set_verbose(&mut db, verbosity >= VerbosityLevel::Verbose); project.set_force_exclude(&mut db, force_exclude); @@ -415,6 +423,11 @@ impl MainLoop { tracing::warn!("No python files found under the given path(s)"); } + let mut result = result; + if !matches!(self.mode, MainLoopMode::UpdateBaseline) { + retain_visible_diagnostics(&mut result); + } + let result = match self.mode { MainLoopMode::Check => { // TODO: We should have an official flag to silence workspace diagnostics. @@ -443,7 +456,8 @@ impl MainLoop { ), }; - if let Ok(result) = result { + if let Ok(mut result) = result { + retain_visible_diagnostics(&mut result.diagnostics); let fixed_diagnostics = match mode { FixMode::AddIgnore => None, FixMode::ApplyFixes => Some(result.count), @@ -473,6 +487,28 @@ impl MainLoop { Err(Canceled) } } + MainLoopMode::UpdateBaseline => { + let settings = db.project().settings(db); + let Some(baseline_path) = settings.baseline() else { + return Err(anyhow!( + "`--update-baseline` requires a baseline path from `--baseline` or configuration" + )); + }; + let result = ty_project::baseline::write(db, baseline_path, result)?; + if !result.diagnostics.is_empty() { + self.write_diagnostics(db, &result.diagnostics, None)?; + } + + if settings.terminal().output_format.is_human_readable() { + let count = result.count; + writeln!( + self.printer.stream_for_requested_summary(), + "Updated baseline `{baseline_path}` with {count} diagnostic{}.", + if count == 1 { "" } else { "s" } + )?; + } + Ok(result.diagnostics) + } }; let exit_status = match result.as_deref() { @@ -673,6 +709,7 @@ impl MainLoop { enum MainLoopMode { Check, Fix(FixMode), + UpdateBaseline, } #[derive(Copy, Clone, Debug)] @@ -681,6 +718,12 @@ enum FixMode { ApplyFixes, } +fn retain_visible_diagnostics(diagnostics: &mut Vec) { + diagnostics.retain(|diagnostic| { + !(diagnostic.severity() == Severity::Hint && diagnostic.id().is_lint()) + }); +} + fn exit_status_from_diagnostics( diagnostics: &[Diagnostic], terminal_settings: &TerminalSettings, @@ -702,7 +745,7 @@ fn exit_status_from_diagnostics( } match max_severity { - Severity::Info => ExitStatus::Success, + Severity::Hint | Severity::Info => ExitStatus::Success, Severity::Warning => { if terminal_settings.error_on_warning { ExitStatus::Failure diff --git a/crates/ty/tests/cli/baseline.rs b/crates/ty/tests/cli/baseline.rs new file mode 100644 index 0000000000..249c7a40a7 --- /dev/null +++ b/crates/ty/tests/cli/baseline.rs @@ -0,0 +1,549 @@ +use std::fs; + +use insta::assert_snapshot; +use insta_cmd::assert_cmd_snapshot; + +use crate::CliTest; + +#[test] +fn update_and_use_configured_baseline() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r" + [tool.ty] + baseline = 'baseline.json' + ", + ), + ( + "test.py", + r#" + from typing_extensions import reveal_type + + x: int = "wrong" + reveal_type(x) + "#, + ), + ])?; + + // `reveal_type` is not a lint, so it remains visible when the assignment error is baselined. + assert_cmd_snapshot!(case.command().arg("--update-baseline"), @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> test.py:5:13 + | + 5 | reveal_type(x) + | ^ `int` + + Found 1 diagnostic + Updated baseline `/baseline.json` with 1 diagnostic. + + ----- stderr ----- + "); + + let serialized = fs::read_to_string(case.root().join("baseline.json"))?; + assert_snapshot!(serialized, @r#" + { + "version": 0, + "files": { + "test.py": [ + { + "rule": "invalid-assignment", + "startColumn": 9, + "endColumn": 16, + "lineCount": 1 + } + ] + } + } + "#); + + assert_cmd_snapshot!(case.command(), @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> test.py:5:13 + | + 5 | reveal_type(x) + | ^ `int` + + Found 1 diagnostic + + ----- stderr ----- + "); + + case.write_file( + "test.py", + r#" + from typing_extensions import reveal_type + + x: int = "wrong" + y: int = "new" + reveal_type(x) + "#, + )?; + assert_cmd_snapshot!(case.command().arg("--output-format=concise"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + test.py:5:10: error[invalid-assignment] Object of type `Literal["new"]` is not assignable to `int` + test.py:6:13: info[revealed-type] Revealed type: `int` + Found 2 diagnostics + + ----- stderr ----- + "#); + + assert_cmd_snapshot!(case.command().arg("--update-baseline"), @" + success: true + exit_code: 0 + ----- stdout ----- + info[revealed-type]: Revealed type + --> test.py:6:13 + | + 6 | reveal_type(x) + | ^ `int` + + Found 1 diagnostic + Updated baseline `/baseline.json` with 2 diagnostics. + + ----- stderr ----- + "); + Ok(()) +} + +#[test] +fn matches_duplicate_ranges_in_source_order() -> anyhow::Result<()> { + let source = "\ +𐐀: int = '''a +🍎''' +x: int = 1.5 +"; + let case = CliTest::with_file("test.py", source)?; + + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json", "--update-baseline"]), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 2 diagnostics. + + ----- stderr ----- + "); + assert_snapshot!(fs::read_to_string(case.root().join("baseline.json"))?, @r#" + { + "version": 0, + "files": { + "test.py": [ + { + "rule": "invalid-assignment", + "startColumn": 9, + "endColumn": 4, + "lineCount": 2 + }, + { + "rule": "invalid-assignment", + "startColumn": 9, + "endColumn": 12, + "lineCount": 1 + } + ] + } + } + "#); + + // The inserted assignment has the same rule and columns as the final assignment. + // The multiline diagnostic between them lets Myers distinguish the two occurrences. + case.write_file("test.py", &format!("y: int = 0.5\n{source}"))?; + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json", "--output-format=concise"]), @" + success: false + exit_code: 1 + ----- stdout ----- + test.py:1:10: error[invalid-assignment] Object of type `float*` is not assignable to `int` + Found 1 diagnostic + + ----- stderr ----- + "); + Ok(()) +} + +#[test] +fn update_baseline_is_idempotent() -> anyhow::Result<()> { + let case = CliTest::with_file( + "test.py", + r" + x: int = 'wrong' + print(missing) + ", + )?; + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json", "--update-baseline", "--warn", "invalid-assignment"]), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 2 diagnostics. + + ----- stderr ----- + "); + let baseline = fs::read_to_string(case.root().join("baseline.json"))?; + + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json", "--update-baseline", "--warn", "unresolved-reference"]), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 2 diagnostics. + + ----- stderr ----- + "); + assert_eq!( + fs::read_to_string(case.root().join("baseline.json"))?, + baseline + ); + Ok(()) +} + +#[test] +fn ignore_comment_replaces_baseline_entry() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r" + [tool.ty] + baseline = 'baseline.json' + + [tool.ty.rules] + unused-ignore-comment = 'error' + ", + ), + ( + "test.py", + r" + x: int = 'wrong' + ", + ), + ])?; + + assert_cmd_snapshot!(case.command().arg("--update-baseline"), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 1 diagnostic. + + ----- stderr ----- + "); + + case.write_file( + "test.py", + r" + x: int = 'wrong' # ty: ignore[invalid-assignment] + ", + )?; + assert_cmd_snapshot!(case.command(), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); + + assert_cmd_snapshot!(case.command().arg("--update-baseline"), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 0 diagnostics. + + ----- stderr ----- + "); + assert_snapshot!(fs::read_to_string(case.root().join("baseline.json"))?, @r#" + { + "version": 0, + "files": {} + } + "#); + Ok(()) +} + +#[test] +fn cli_baseline_path() -> anyhow::Result<()> { + let source_path = r"nested/a\b.py"; + let case = CliTest::with_file( + source_path, + r" + x: int = 'wrong' + ", + )?; + + assert_cmd_snapshot!(case.command().args(["--baseline", "state/baseline.json", "--update-baseline"]), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/state/baseline.json` with 1 diagnostic. + + ----- stderr ----- + "); + let baseline: serde_json::Value = + serde_json::from_slice(&fs::read(case.root().join("state/baseline.json"))?)?; + // A backslash separates directories on Windows and is part of the filename on Unix. + let expected_path = if cfg!(windows) { + "nested/a/b.py" + } else { + source_path + }; + assert_eq!( + baseline["files"][expected_path][0]["rule"], + "invalid-assignment" + ); + + assert_cmd_snapshot!(case.command().args(["--baseline", "state/baseline.json"]), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "); + Ok(()) +} + +#[test] +fn update_baseline_requires_path() -> anyhow::Result<()> { + let case = CliTest::with_file( + "test.py", + r" + x: int = 'wrong' + ", + )?; + assert_cmd_snapshot!(case.command().arg("--update-baseline"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + ty failed + Cause: `--update-baseline` requires a baseline path from `--baseline` or configuration + "); + Ok(()) +} + +#[test] +fn update_baseline_conflicts_with_fix() -> anyhow::Result<()> { + let case = CliTest::with_file( + "test.py", + r" + x: int = 'wrong' + ", + )?; + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json", "--update-baseline", "--fix"]), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: the argument '--update-baseline' cannot be used with '--fix' + + Usage: ty check --baseline --update-baseline [PATH]... + + For more information, try '--help'. + "); + Ok(()) +} + +#[test] +fn update_baseline_does_not_suppress_syntax_errors() -> anyhow::Result<()> { + let case = CliTest::with_file( + "test.py", + r" + x: int = 'wrong' + + if: + ", + )?; + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json", "--update-baseline"]), @" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-syntax]: Expected an expression + --> test.py:4:3 + | + 4 | if: + | ^ + + error[invalid-syntax]: Expected an indented block after `if` statement + --> test.py:4:4 + | + 4 | if: + | ^ + + Found 2 diagnostics + Updated baseline `/baseline.json` with 1 diagnostic. + + ----- stderr ----- + "); + + let baseline = fs::read_to_string(case.root().join("baseline.json"))?; + assert_snapshot!(baseline, @r#" + { + "version": 0, + "files": { + "test.py": [ + { + "rule": "invalid-assignment", + "startColumn": 9, + "endColumn": 16, + "lineCount": 1 + } + ] + } + } + "#); + Ok(()) +} + +#[test] +fn missing_baseline_is_reported() -> anyhow::Result<()> { + let case = CliTest::with_file("test.py", "")?; + assert_cmd_snapshot!(case.command().args(["--baseline", "missing.json", "--output-format=concise"]), @" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-baseline] Failed to read baseline `/missing.json` + Found 1 diagnostic + + ----- stderr ----- + "); + Ok(()) +} + +#[test] +fn unsupported_baseline_version_is_reported() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("test.py", ""), + ("baseline.json", r#"{"version":999,"files":{}}"#), + ])?; + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json"]), @r#" + success: false + exit_code: 1 + ----- stdout ----- + error[invalid-baseline]: Unsupported baseline version `999` + --> baseline.json:1:1 + | + 1 | {"version":999,"files":{}} + | ^ + info: ty supports baseline version `0` + + Found 1 diagnostic + + ----- stderr ----- + "#); + Ok(()) +} + +#[test] +fn update_baseline_replaces_invalid_json() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r" + [tool.ty] + baseline = 'baseline.json' + ", + ), + ("baseline.json", "{ not json"), + ( + "test.py", + r" + x: int = 'wrong' + ", + ), + ])?; + + assert_cmd_snapshot!(case.command().arg("--output-format=concise"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + baseline.json:1:1: error[invalid-baseline] Failed to parse baseline `/baseline.json` + test.py:2:10: error[invalid-assignment] Object of type `Literal["wrong"]` is not assignable to `int` + Found 2 diagnostics + + ----- stderr ----- + "#); + + assert_cmd_snapshot!(case.command().arg("--update-baseline"), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 1 diagnostic. + + ----- stderr ----- + "); + let baseline = fs::read_to_string(case.root().join("baseline.json"))?; + assert_snapshot!(baseline, @r#" + { + "version": 0, + "files": { + "test.py": [ + { + "rule": "invalid-assignment", + "startColumn": 9, + "endColumn": 16, + "lineCount": 1 + } + ] + } + } + "#); + Ok(()) +} + +#[test] +fn baseline_entries_are_sorted_and_matched_by_file_path() -> anyhow::Result<()> { + let source = "x: int = 'wrong'\n"; + let case = CliTest::with_files([("b.py", source), ("a.py", source)])?; + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json", "--update-baseline"]), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 2 diagnostics. + + ----- stderr ----- + "); + + let baseline = fs::read_to_string(case.root().join("baseline.json"))?; + assert_snapshot!(baseline, @r#" + { + "version": 0, + "files": { + "a.py": [ + { + "rule": "invalid-assignment", + "startColumn": 9, + "endColumn": 16, + "lineCount": 1 + } + ], + "b.py": [ + { + "rule": "invalid-assignment", + "startColumn": 9, + "endColumn": 16, + "lineCount": 1 + } + ] + } + } + "#); + + // Both files have identical baseline entries. Only the error in the renamed file should reappear. + fs::rename(case.root().join("a.py"), case.root().join("renamed.py"))?; + assert_cmd_snapshot!(case.command().args(["--baseline", "baseline.json", "--output-format=concise"]), @r#" + success: false + exit_code: 1 + ----- stdout ----- + renamed.py:1:10: error[invalid-assignment] Object of type `Literal["wrong"]` is not assignable to `int` + Found 1 diagnostic + + ----- stderr ----- + "#); + Ok(()) +} diff --git a/crates/ty/tests/cli/config_option.rs b/crates/ty/tests/cli/config_option.rs index d89f5f34df..e716cfc0a2 100644 --- a/crates/ty/tests/cli/config_option.rs +++ b/crates/ty/tests/cli/config_option.rs @@ -123,7 +123,7 @@ fn cli_config_args_invalid_option() -> anyhow::Result<()> { | 1 | bad-option=true | ^^^^^^^^^^ - unknown field `bad-option`, expected one of `environment`, `src`, `rules`, `terminal`, `analysis`, `overrides` + unknown field `bad-option`, expected one of `baseline`, `environment`, `src`, `rules`, `terminal`, `analysis`, `overrides` Usage: ty diff --git a/crates/ty/tests/cli/fixes.rs b/crates/ty/tests/cli/fixes.rs index 4cf66c902c..e049ec788d 100644 --- a/crates/ty/tests/cli/fixes.rs +++ b/crates/ty/tests/cli/fixes.rs @@ -320,3 +320,102 @@ fn fix_clean_file() -> anyhow::Result<()> { Ok(()) } + +/// `--fix` respects the baseline so users can fix new diagnostics without also modifying code +/// for existing ones. Users can apply all available fixes by running without a baseline. +#[test] +fn fix_skips_baselined_diagnostics() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r" + [tool.ty] + baseline = 'baseline.json' + ", + ), + ( + "unused_ignore.py", + r" + x = 1 # ty: ignore[unresolved-reference] + ", + ), + ])?; + assert_cmd_snapshot!(case.command().args(["--warn", "unused-ignore-comment", "--update-baseline"]), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 1 diagnostic. + + ----- stderr ----- + "); + + case.write_file( + "unused_ignore.py", + r" + x = 1 # ty: ignore[unresolved-reference] + y = 2 # ty: ignore[unresolved-reference] + ", + )?; + assert_cmd_snapshot!(case.command().args(["--warn", "unused-ignore-comment", "--fix"]), @" + success: true + exit_code: 0 + ----- stdout ----- + Found 1 diagnostic (1 fixed, 0 remaining). + + ----- stderr ----- + "); + assert_snapshot!(fs::read_to_string(case.root().join("unused_ignore.py"))?, @" + x = 1 # ty: ignore[unresolved-reference] + y = 2 + "); + Ok(()) +} + +#[test] +fn add_ignore_skips_baselined_diagnostics() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r" + [tool.ty] + baseline = 'baseline.json' + ", + ), + ( + "errors.py", + r" + x: int = 'wrong' + ", + ), + ])?; + assert_cmd_snapshot!(case.command().arg("--update-baseline"), @" + success: true + exit_code: 0 + ----- stdout ----- + Updated baseline `/baseline.json` with 1 diagnostic. + + ----- stderr ----- + "); + + case.write_file( + "errors.py", + r" + x: int = 'wrong' + print(missing) + ", + )?; + assert_cmd_snapshot!(case.command().arg("--add-ignore"), @" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + Added 1 ignore comment + + ----- stderr ----- + "); + assert_snapshot!(fs::read_to_string(case.root().join("errors.py"))?, @r#" + x: int = 'wrong' + print(missing) # ty: ignore[unresolved-reference] + "#); + Ok(()) +} diff --git a/crates/ty/tests/cli/main.rs b/crates/ty/tests/cli/main.rs index 09a8c8b9bb..7352af2117 100644 --- a/crates/ty/tests/cli/main.rs +++ b/crates/ty/tests/cli/main.rs @@ -1,4 +1,5 @@ mod analysis_options; +mod baseline; mod config_option; mod exit_code; mod file_selection; diff --git a/crates/ty_project/Cargo.toml b/crates/ty_project/Cargo.toml index 11e5a3ac7d..8d93024437 100644 --- a/crates/ty_project/Cargo.toml +++ b/crates/ty_project/Cargo.toml @@ -24,6 +24,7 @@ ruff_options_metadata = { workspace = true } ruff_python_ast = { workspace = true, features = ["serde"] } ruff_python_formatter = { workspace = true, optional = true } ruff_ranged_value = { workspace = true, features = ["get-size"] } +ruff_source_file = { workspace = true } ruff_text_size = { workspace = true } ty_combine = { workspace = true } ty_module_resolver = { workspace = true } @@ -54,6 +55,7 @@ schemars = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } shellexpand = { workspace = true } +similar = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } diff --git a/crates/ty_project/src/baseline.rs b/crates/ty_project/src/baseline.rs new file mode 100644 index 0000000000..38f8ed3ebe --- /dev/null +++ b/crates/ty_project/src/baseline.rs @@ -0,0 +1,434 @@ +//! Loads, matches, and writes diagnostic baselines. +//! +//! Baselines let you adopt ty or enable a new rule without first fixing every existing error or +//! adding individual suppression comments. A baseline records those errors in a project-wide JSON +//! file, so you can focus on new diagnostics while working through the old ones over time. +//! Only lint diagnostics can go in the baseline. Other diagnostics, such as syntax errors and +//! messages from `reveal_type`, still appear. +//! +//! When a diagnostic matches a baseline entry, ty lowers its severity to [`Severity::Hint`]. +//! The CLI hides these hints, but the language server still shows them in your editor so you can +//! fix the code as you work. If you suppress a baselined diagnostic with an inline comment, +//! the comment takes precedence and the next baseline update drops the entry. +//! +//! # Recognizing an existing diagnostic +//! +//! As code changes, an existing error can move. For example, adding this import shifts the +//! assignment down by two lines but does nothing to fix the error: +//! +//! ```diff +//! +import logging +//! + +//! def get_name() -> str: ... +//! +//! name: int = get_name() +//! ``` +//! +//! ty records the rule, the start and end columns of the primary diagnostic range, and the number +//! of lines it spans. Adding the import changes the line number of `get_name()`, but preserves all +//! four values. Reindenting the assignment changes the columns and can make the error reappear. +//! Each entry can suppress one diagnostic in its original file. Renaming the file makes its errors +//! reappear. +//! +//! # Why match in source order? +//! +//! Suppose the baseline already covers an unresolved reference to `missing` and the assignment to +//! `name`. Then someone adds a second assignment with the same error above both of them: +//! +//! ```diff +//! +size: int = get_name() +//! print(missing) +//! name: int = get_name() +//! ``` +//! +//! Both calls to `get_name()` have the same rule and range shape. Matching the first one would hide +//! the new error on `size` and report the old error on `name`. Like [basedpyright], ty uses a Myers +//! diff to find the longest sequence of matching entries that still appear in the same order. +//! Since `missing` still precedes the call on `name`, both old errors match and ty reports only the +//! assignment to `size`. +//! +//! Order can't distinguish a new error that replaces an old one with the same rule and range shape. +//! Suppressing that new error is a false negative. Conversely, swapping two functions can break the +//! relative order of their diagnostics and cause an existing error to reappear (a false positive). +//! +//! Including surrounding code in each entry could distinguish some otherwise identical errors, but +//! it would also increase baseline churn. An edit near an unchanged error could then rewrite its +//! entry on the next baseline update. Recording just the rule and range shape keeps those edits out +//! of the baseline and makes updates on different branches easier to merge. +//! +//! # Format versions +//! +//! Baseline support is experimental and uses format version 0, which may change between ty releases. +//! We will bump the format version to 1 when promoting the feature to stable. From then on, ty must +//! be able to read and match baselines written by the previous ty version. Updating a baseline +//! may always write the current format. +//! +//! [basedpyright]: https://github.com/DetachHead/basedpyright/blob/fa4b52d9428c0f355c95af2056cfd4edd4e6e5da/packages/pyright-internal/src/baseline.ts#L212-L225 + +use std::collections::BTreeMap; +use std::ops::Range; + +use anyhow::Context; +use compact_str::CompactString; +use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span}; +use ruff_db::files::{File, FilePath, system_path_to_file}; +use ruff_db::source::source_text; +use ruff_db::system::{SystemPath, SystemPathBuf}; +use ruff_source_file::LineRanges; +use ruff_text_size::{TextRange, TextSize}; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; +use similar::{Algorithm, DiffTag, capture_diff_slices}; + +use crate::Db; + +const BASELINE_VERSION: u32 = 0; + +/// Returns a diagnostic if the configured baseline cannot be loaded. +pub(crate) fn settings_diagnostic(db: &dyn Db) -> Option { + db.project().settings(db).baseline()?; + baselines(db).err().map(Error::to_diagnostic) +} + +/// Demotes matched diagnostics to hints and adds a note identifying the baseline. +pub(crate) fn demote_diagnostics(db: &dyn Db, file: File, diagnostics: &mut [Diagnostic]) { + let Some(baseline) = baseline(db, file) else { + return; + }; + + let mut current: Vec<_> = diagnostics + .iter_mut() + .filter_map(|diagnostic| { + Some((BaselineDiagnostic::from_diagnostic(diagnostic)?, diagnostic)) + }) + .collect(); + current.sort_by_key(|(location, _)| location.sort_key()); + + let source = source_text(db, file); + let entries: Vec<_> = current + .iter() + .map(|(location, _)| location.entry(&source)) + .collect(); + + let mut demote = |range: Range| { + for (_, diagnostic) in &mut current[range] { + diagnostic.set_severity(Severity::Hint); + diagnostic.info(format_args!( + "Suppressed by baseline `{}`", + baseline.display_path + )); + } + }; + + if entries == baseline.entries { + demote(0..entries.len()); + return; + } + for op in capture_diff_slices(Algorithm::Myers, baseline.entries, &entries) { + if op.tag() == DiffTag::Equal { + demote(op.new_range()); + } + } +} + +#[derive(Debug)] +pub struct WriteResult { + /// The number of diagnostics written to the baseline. + pub count: usize, + /// Diagnostics that still need to be reported because the baseline cannot suppress them, + /// such as syntax errors. A successful write clears any error from loading the old baseline. + pub diagnostics: Vec, +} + +/// Writes a new baseline containing lint diagnostics with a primary source range in the project. +/// +/// Returns the number of entries written and the diagnostics still to report. +pub fn write( + db: &dyn Db, + path: &SystemPath, + diagnostics: Vec, +) -> anyhow::Result { + let (baseline, mut remaining) = BaselineFile::from_diagnostics(db, diagnostics); + let count = baseline.write(db, path)?; + // Errors from loading the old baseline no longer apply after a successful write. + remaining.retain(|diagnostic| diagnostic.id() != DiagnosticId::InvalidBaseline); + Ok(WriteResult { + count, + diagnostics: remaining, + }) +} + +/// A borrowed view of the entries in a project-wide [`BaselineFile`] for one source file. +#[derive(Debug)] +struct FileBaseline<'a> { + /// Path to the baseline file shown in diagnostic notes. + display_path: &'a SystemPath, + entries: &'a [BaselineEntry], +} + +/// Returns the baseline for `file` without depending on the query when baselines are disabled. +fn baseline(db: &dyn Db, file: File) -> Option> { + let project = db.project(); + let path = project.settings(db).baseline()?; + + let Ok(Some(baseline)) = baselines(db) else { + return None; + }; + let entries = baseline.for_file(db, file)?; + Some(FileBaseline { + display_path: path.strip_prefix(project.root(db)).unwrap_or(path), + entries, + }) +} + +/// Loads and validates the configured baseline. +#[salsa::tracked(returns(as_ref), heap_size = ruff_memory_usage::heap_size)] +fn baselines(db: &dyn Db) -> Result, Error> { + let project = db.project(); + let Some(path) = project.settings(db).baseline() else { + return Ok(None); + }; + + let file = system_path_to_file(db, path).map_err(|error| Error::Read { + path: path.to_path_buf(), + file: None, + detail: error.to_string(), + })?; + + let source = source_text(db, file); + if let Some(error) = source.read_error() { + return Err(Error::Read { + path: path.to_path_buf(), + file: Some(file), + detail: error.to_string(), + }); + } + + let baseline: BaselineFile = + serde_json::from_str(source.as_str()).map_err(|error| Error::Parse { + path: path.to_path_buf(), + file, + detail: error.to_string(), + })?; + + if baseline.version != BASELINE_VERSION { + return Err(Error::UnsupportedVersion { + file, + version: baseline.version, + }); + } + + Ok(Some(baseline)) +} + +/// The project-wide diagnostic baseline as stored in JSON. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, get_size2::GetSize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BaselineFile { + version: u32, + /// Map from source file path to its baseline entries. Paths are relative to the project root. + files: BTreeMap>, +} + +impl BaselineFile { + fn for_file(&self, db: &dyn Db, file: File) -> Option<&[BaselineEntry]> { + let path = project_relative_path(db, file)?; + self.files.get(path).map(Box::as_ref) + } + + /// Builds a baseline and returns the diagnostics it cannot record. + fn from_diagnostics(db: &dyn Db, mut diagnostics: Vec) -> (Self, Vec) { + let mut by_file = FxHashMap::default(); + diagnostics.retain(|diagnostic| { + let Some(diagnostic) = BaselineDiagnostic::from_diagnostic(diagnostic) else { + return true; + }; + let Some(path) = project_relative_path(db, diagnostic.file) else { + return true; + }; + let (_, file_diagnostics) = by_file + .entry(diagnostic.file) + .or_insert_with(|| (path, Vec::new())); + file_diagnostics.push(diagnostic); + false + }); + + let mut files = BTreeMap::new(); + #[expect( + clippy::iter_over_hash_type, + reason = "each file is processed independently and its entries are sorted" + )] + for (file, (path, mut diagnostics)) in by_file { + diagnostics.sort_by_key(BaselineDiagnostic::sort_key); + let source = source_text(db, file); + let entries = diagnostics + .iter() + .map(|diagnostic| diagnostic.entry(&source)) + .collect(); + let mut portable_path = String::with_capacity(path.as_str().len()); + for component in path.components() { + if !portable_path.is_empty() { + portable_path.push('/'); + } + portable_path.push_str(component.as_str()); + } + files.insert(SystemPathBuf::from(portable_path), entries); + } + + let baseline = Self { + version: BASELINE_VERSION, + files, + }; + (baseline, diagnostics) + } + + fn write(self, db: &dyn Db, path: &SystemPath) -> anyhow::Result { + let count = self.files.values().map(|entries| entries.len()).sum(); + let mut serialized = serde_json::to_string_pretty(&self)?; + serialized.push('\n'); + + let writable = db + .system() + .as_writable() + .context("The active file system is read-only")?; + if let Some(parent) = path.parent() { + writable + .create_directory_all(parent) + .with_context(|| format!("Failed to create baseline directory `{parent}`"))?; + } + writable + .write_file(path, &serialized) + .with_context(|| format!("Failed to write baseline `{path}`"))?; + Ok(count) + } +} + +/// The rule and shape of a diagnostic's primary range, independent of its line number. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, get_size2::GetSize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BaselineEntry { + rule: CompactString, + /// Zero-based columns counted in Unicode scalar values. The end is exclusive. + start_column: usize, + end_column: usize, + /// Includes both the start and end lines. + line_count: usize, +} + +#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error, get_size2::GetSize)] +enum Error { + #[error("Failed to read baseline `{path}`")] + Read { + path: SystemPathBuf, + file: Option, + detail: String, + }, + + #[error("Failed to parse baseline `{path}`")] + Parse { + path: SystemPathBuf, + file: File, + detail: String, + }, + + #[error("Unsupported baseline version `{version}`")] + UnsupportedVersion { file: File, version: u32 }, +} + +impl Error { + fn to_diagnostic(&self) -> Diagnostic { + let mut diagnostic = Diagnostic::new( + DiagnosticId::InvalidBaseline, + Severity::Error, + self.to_string(), + ); + let (file, detail) = match self { + Self::Read { file, detail, .. } => (*file, detail.clone()), + Self::Parse { file, detail, .. } => (Some(*file), detail.clone()), + Self::UnsupportedVersion { file, .. } => ( + Some(*file), + format!("ty supports baseline version `{BASELINE_VERSION}`"), + ), + }; + + if let Some(file) = file { + diagnostic.annotate(Annotation::primary( + Span::from(file).with_range(TextRange::default()), + )); + } + diagnostic.info(detail); + diagnostic + } +} + +fn project_relative_path(db: &dyn Db, file: File) -> Option<&SystemPath> { + let FilePath::System(path) = file.path(db) else { + return None; + }; + path.strip_prefix(db.project().root(db)).ok() +} + +struct BaselineDiagnostic { + rule: LintName, + file: File, + range: TextRange, +} + +impl BaselineDiagnostic { + fn from_diagnostic(diagnostic: &Diagnostic) -> Option { + let rule = diagnostic.id().as_lint()?; + let span = diagnostic.primary_span()?; + Some(Self { + rule, + file: span.expect_ty_file(), + range: span.range()?, + }) + } + + /// Break ties at the same position consistently when writing and matching the baseline. + fn sort_key(&self) -> (TextSize, TextSize, LintName) { + (self.range.start(), self.range.end(), self.rule) + } + + fn entry(&self, source: &str) -> BaselineEntry { + let through_end = &source[TextRange::up_to(self.range.end())]; + let column = |offset| { + let before = &through_end[TextRange::up_to(offset)]; + let line_start = before.line_start(offset).to_usize(); + before[line_start..].chars().count() + }; + BaselineEntry { + rule: CompactString::const_new(self.rule.as_str()), + start_column: column(self.range.start()), + end_column: column(self.range.end()), + line_count: 1 + through_end.count_lines(self.range) as usize, + } + } +} + +#[cfg(test)] +mod tests { + use ruff_db::diagnostic::Severity; + use ruff_db::files::system_path_to_file; + use ruff_db::system::DbWithWritableSystem as _; + use ruff_db::testing::assert_const_function_query_was_not_run; + + use crate::ProjectMetadata; + use crate::db::testing::TestDb; + use ty_python_semantic::Db as _; + + use super::baselines; + + #[test] + fn disabled_baseline_does_not_run_baseline_query() -> anyhow::Result<()> { + let mut db = TestDb::new(ProjectMetadata::new("test", "/project".into())); + db.write_file("/project/test.py", "x: int = 'wrong'\n")?; + let file = system_path_to_file(&db, "/project/test.py")?; + db.take_salsa_events(); + + assert_eq!(db.check_file(file)[0].severity(), Severity::Error); + let events = db.take_salsa_events(); + assert_const_function_query_was_not_run(&db, baselines, &events); + Ok(()) + } +} diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index 8876cb2909..92747887ec 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -40,6 +40,7 @@ pub use uv::{ uv_test_env_vars, }; +pub mod baseline; mod db; mod files; pub mod glob; @@ -866,6 +867,7 @@ impl Project { .iter() .map(OptionDiagnostic::to_diagnostic) .chain(uv_diagnostic) + .chain(baseline::settings_diagnostic(db)) .collect() } } @@ -1042,7 +1044,10 @@ pub(crate) fn check_file_impl( diagnostics.extend(dependency_diagnostic); Ok(diagnostics.into_boxed_slice()) }) { - Ok(result) => result, + Ok(result) => result.map(|mut diagnostics| { + baseline::demote_diagnostics(*db, source_file, &mut diagnostics); + diagnostics + }), Err(diagnostic) => Ok(Box::new([diagnostic])), } } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 7f2104e15a..52648b7967 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -64,6 +64,19 @@ use ty_static::EnvVars; #[serde(rename_all = "kebab-case", deny_unknown_fields)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct Options { + /// Path to the JSON file that records existing lint diagnostics for this project. + /// Relative paths are resolved against the project root. Use `ty check --update-baseline` + /// to create or update the file. + /// + /// Diagnostic baselines are in preview. The file format may change between ty releases. + #[serde(skip_serializing_if = "Option::is_none")] + #[option( + default = r#"null"#, + value_type = "str", + example = r#"baseline = "ty-baseline.json""# + )] + pub baseline: Option, + /// Configures the type checking environment. #[option_group] #[serde(skip_serializing_if = "Option::is_none")] @@ -544,6 +557,10 @@ impl Options { let overrides = strategy.fallback(overrides, |_| Vec::new())?; let settings = Settings { + baseline: self + .baseline + .as_ref() + .map(|path| path.absolute(context.configuration_root(), db.system())), rules: Arc::new(rules), terminal, src, diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index bcb8b64f73..072b8bea6a 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use ruff_db::files::File; -use ruff_db::system::SystemPath; +use ruff_db::system::{SystemPath, SystemPathBuf}; use ty_combine::Combine; use ty_python_semantic::AnalysisSettings; use ty_python_semantic::lint::RuleSelection; @@ -25,6 +25,7 @@ use crate::{Db, glob::IncludeExcludeFilter}; /// Settings that are part of [`ty_python_core::program::ProgramSettings`] are not included here. #[derive(Clone, Debug, Eq, PartialEq, get_size2::GetSize)] pub struct Settings { + pub(super) baseline: Option, pub(super) rules: Arc, pub(super) terminal: TerminalSettings, pub(super) src: SrcSettings, @@ -39,6 +40,10 @@ pub struct Settings { } impl Settings { + pub fn baseline(&self) -> Option<&SystemPath> { + self.baseline.as_deref() + } + fn rules(&self) -> &RuleSelection { &self.rules } diff --git a/crates/ty_python_semantic/src/fixes.rs b/crates/ty_python_semantic/src/fixes.rs index f404ed91b9..c98a504185 100644 --- a/crates/ty_python_semantic/src/fixes.rs +++ b/crates/ty_python_semantic/src/fixes.rs @@ -363,12 +363,20 @@ enum FixMode { impl FixMode { fn is_fixable(self, diagnostic: &Diagnostic) -> bool { + // The baseline demotes matching lint diagnostics to hints. Rechecking a file after an edit + // can return those hints again, so skip them here to keep `--fix` and `--add-ignore` from + // editing code for diagnostics the baseline already suppresses. + if diagnostic.severity() == Severity::Hint && diagnostic.id().is_lint() { + return false; + } + let Some(primary_span) = diagnostic.primary_span() else { return false; }; match self { FixMode::Suppress => { + // Don't suppress unused ignore comments. primary_span.range().is_some() && diagnostic .id() @@ -385,18 +393,16 @@ impl FixMode { file: PythonFile<'_>, file_diagnostics: &[Diagnostic], ) -> Vec { + let fixable_diagnostics = file_diagnostics + .iter() + .filter(|diagnostic| self.is_fixable(diagnostic)); + match self { FixMode::Suppress => { - let suppressable_diagnostics: Vec<_> = file_diagnostics - .iter() + let suppressable_diagnostics: Vec<_> = fixable_diagnostics .filter_map(|diagnostic| { let lint_id = diagnostic.id().as_lint()?; - // Don't suppress unused ignore comments. - if is_unused_ignore_comment_lint(lint_id) { - return None; - } - // We can't suppress diagnostics without a corresponding file or range. let span = diagnostic.primary_span()?; let range = span.range()?; @@ -418,9 +424,7 @@ impl FixMode { ) .collect() } - FixMode::ApplyFixes(applicability) => file_diagnostics - .iter() - .filter(|diagnostic| diagnostic.has_applicable_fix(applicability)) + FixMode::ApplyFixes(_) => fixable_diagnostics .filter_map(|diagnostic| { diagnostic.fix().cloned().map(|fix| ApplicableFix { fix, diff --git a/crates/ty_server/src/server/api/diagnostics.rs b/crates/ty_server/src/server/api/diagnostics.rs index a4d9d7ec16..fd86b8f88d 100644 --- a/crates/ty_server/src/server/api/diagnostics.rs +++ b/crates/ty_server/src/server/api/diagnostics.rs @@ -469,6 +469,7 @@ pub(super) fn to_lsp_diagnostic( }; let severity = match diagnostic.severity() { + Severity::Hint => DiagnosticSeverity::Hint, Severity::Info => DiagnosticSeverity::Information, Severity::Warning => DiagnosticSeverity::Warning, Severity::Error | Severity::Fatal => DiagnosticSeverity::Error, diff --git a/crates/ty_server/tests/e2e/pull_diagnostics.rs b/crates/ty_server/tests/e2e/pull_diagnostics.rs index 64b0ae2b12..1acc5b1092 100644 --- a/crates/ty_server/tests/e2e/pull_diagnostics.rs +++ b/crates/ty_server/tests/e2e/pull_diagnostics.rs @@ -43,6 +43,57 @@ def foo() -> str: Ok(()) } +#[test] +fn baselined_diagnostic_is_a_hint_and_refreshes() -> Result<()> { + let workspace_root = SystemPath::new("src"); + let foo = SystemPath::new("src/foo.py"); + let baseline_path = SystemPath::new("src/baselines/ty.json"); + let foo_content = "\ +def foo() -> str: + return 42 +"; + let baseline = r#"{ + "version": 0, + "files": { + "foo.py": [{ + "rule": "invalid-return-type", + "startColumn": 11, + "endColumn": 13, + "lineCount": 1 + }] + } + }"#; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_file( + "src/ty.toml", + "\ +baseline = 'baselines/ty.json' +", + )? + .with_file(baseline_path, baseline)? + .with_file(foo, foo_content)? + .build() + .wait_until_workspaces_are_initialized(); + + server.open_text_document(foo, foo_content, 1); + let diagnostics = server.document_diagnostic_request(foo, None); + insta::assert_debug_snapshot!(condensed_document_diagnostic_snapshot(diagnostics), @r#""1:11..1:13[HINT]: Return type does not match returned value: expected `str`, found `Literal[42]`\n\ninfo: Suppressed by baseline `baselines/ty.json`""#); + + server.write_file( + baseline_path, + r#"{ + "version": 0, + "files": {} + }"#, + )?; + server.did_change_watched_file(baseline_path, FileChangeType::Changed); + let diagnostics = server.document_diagnostic_request(foo, None); + insta::assert_debug_snapshot!(condensed_document_diagnostic_snapshot(diagnostics), @r#""1:11..1:13[ERROR]: Return type does not match returned value: expected `str`, found `Literal[42]`""#); + Ok(()) +} + #[test] fn unused_binding_has_unnecessary_hint_tag() -> Result<()> { let _filter = filter_result_id(); diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index 17489c5f94..dd09ea79d8 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -40,6 +40,7 @@ Program: vendored://stdlib, ] Settings: Settings { + baseline: None, rules: , terminal: TerminalSettings { output_format: Full, diff --git a/crates/ty_wasm/src/lib.rs b/crates/ty_wasm/src/lib.rs index 0f281656bd..c0f40ec9fa 100644 --- a/crates/ty_wasm/src/lib.rs +++ b/crates/ty_wasm/src/lib.rs @@ -1212,11 +1212,13 @@ pub enum Severity { Warning, Error, Fatal, + Hint, } impl From for Severity { fn from(value: diagnostic::Severity) -> Self { match value { + diagnostic::Severity::Hint => Self::Hint, diagnostic::Severity::Info => Self::Info, diagnostic::Severity::Warning => Self::Warning, diagnostic::Severity::Error => Self::Error, diff --git a/ty.schema.json b/ty.schema.json index 68b2b98cfe..3a4f9758f3 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -13,6 +13,17 @@ } ] }, + "baseline": { + "description": "Path to the JSON file that records existing lint diagnostics for this project.\nRelative paths are resolved against the project root. Use `ty check --update-baseline`\nto create or update the file.\n\nDiagnostic baselines are in preview. The file format may change between ty releases.", + "anyOf": [ + { + "$ref": "#/definitions/RelativePathBuf" + }, + { + "type": "null" + } + ] + }, "environment": { "description": "Configures the type checking environment.", "anyOf": [