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-ignoreAdds ty: ignore comments to suppress all rule diagnostics
+--baseline pathPath to a JSON diagnostic baseline (preview)
--color whenControl when colored output is used
Possible values:
@@ -108,6 +109,7 @@ over all configuration files.
--quiet, -qUse quiet output (or -qq for silent output)
--respect-ignore-filesRespect file exclusions via .gitignore and other standard ignore files. Use --no-respect-ignore-files to disable
--typeshed, --custom-typeshed-dir pathCustom directory to use for stdlib typeshed stubs
+--update-baselineCreate or update the configured diagnostic baseline (preview)
--verbose, -vUse verbose output (or -vv and -vvv for more verbose output)
--warn ruleTreat the given rule as having severity 'warn'. Can be specified multiple times. Use 'all' to apply to all rules.
--watch, -WWatch 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