[ty] Add diagnostic baseline support (#27636)

This commit is contained in:
Micha Reiser authored and GitHub committed 2026-10-26 13:34:01 -07:00
1 parent 6840db323c
commit 207ec3affe
28 files changed
+1300 -20

No files matched your search

Generated
+2
View File
@@ -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",
+11 -1
View File
@@ -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<crate::files::FileRange> 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"?
@@ -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};")?;
@@ -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),
@@ -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",
};
@@ -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`
@@ -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",
@@ -318,7 +318,7 @@ enum SarifLevel {
impl From<Severity> 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,
}
+1
View File
@@ -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,
+2
View File
@@ -39,6 +39,7 @@ ty check [OPTIONS] [PATH]...
<h3 class="cli-reference">Options</h3>
<dl class="cli-reference"><dt id="ty-check--add-ignore"><a href="#ty-check--add-ignore"><code>--add-ignore</code></a></dt><dd><p>Adds <code>ty: ignore</code> comments to suppress all rule diagnostics</p>
</dd><dt id="ty-check--baseline"><a href="#ty-check--baseline"><code>--baseline</code></a> <i>path</i></dt><dd><p>Path to a JSON diagnostic baseline (preview)</p>
</dd><dt id="ty-check--color"><a href="#ty-check--color"><code>--color</code></a> <i>when</i></dt><dd><p>Control when colored output is used</p>
<p>Possible values:</p>
<ul>
@@ -108,6 +109,7 @@ over all configuration files.</p>
</ul></dd><dt id="ty-check--quiet"><a href="#ty-check--quiet"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output (or <code>-qq</code> for silent output)</p>
</dd><dt id="ty-check--respect-ignore-files"><a href="#ty-check--respect-ignore-files"><code>--respect-ignore-files</code></a></dt><dd><p>Respect file exclusions via <code>.gitignore</code> and other standard ignore files. Use <code>--no-respect-ignore-files</code> to disable</p>
</dd><dt id="ty-check--typeshed"><a href="#ty-check--typeshed"><code>--typeshed</code></a>, <code>--custom-typeshed-dir</code> <i>path</i></dt><dd><p>Custom directory to use for stdlib typeshed stubs</p>
</dd><dt id="ty-check--update-baseline"><a href="#ty-check--update-baseline"><code>--update-baseline</code></a></dt><dd><p>Create or update the configured diagnostic baseline (preview)</p>
</dd><dt id="ty-check--verbose"><a href="#ty-check--verbose"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output (or <code>-vv</code> and <code>-vvv</code> for more verbose output)</p>
</dd><dt id="ty-check--warn"><a href="#ty-check--warn"><code>--warn</code></a> <i>rule</i></dt><dd><p>Treat the given rule as having severity 'warn'. Can be specified multiple times. Use 'all' to apply to all rules.</p>
</dd><dt id="ty-check--watch"><a href="#ty-check--watch"><code>--watch</code></a>, <code>-W</code></dt><dd><p>Watch files for changes and recheck files related to the changed files</p>
+29
View File
@@ -1,6 +1,35 @@
<!-- WARNING: This file is auto-generated (cargo dev generate-all). Update the doc comments on the 'Options' struct in 'crates/ty_project/src/metadata/options.rs' if you want to change anything here. -->
# 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.
+9
View File
@@ -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<SystemPathBuf>,
/// 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
+45 -2
View File
@@ -132,6 +132,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
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<ExitStatus> {
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<Diagnostic>) {
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
+549
View File
@@ -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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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 <PATH> --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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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 `<temp_dir>/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(())
}
+1 -1
View File
@@ -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 <COMMAND>
+99
View File
@@ -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 `<temp_dir>/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 `<temp_dir>/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(())
}
+1
View File
@@ -1,4 +1,5 @@
mod analysis_options;
mod baseline;
mod config_option;
mod exit_code;
mod file_selection;
+2
View File
@@ -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 }
+434
View File
@@ -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<Diagnostic> {
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<usize>| {
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<Diagnostic>,
}
/// 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<Diagnostic>,
) -> anyhow::Result<WriteResult> {
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<FileBaseline<'_>> {
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<Option<BaselineFile>, 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<SystemPathBuf, Box<[BaselineEntry]>>,
}
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<Diagnostic>) -> (Self, Vec<Diagnostic>) {
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<usize> {
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<File>,
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<Self> {
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(())
}
}
+6 -1
View File
@@ -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])),
}
}
+17
View File
@@ -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<RelativePathBuf>,
/// 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,
+6 -1
View File
@@ -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<SystemPathBuf>,
pub(super) rules: Arc<RuleSelection>,
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
}
+14 -10
View File
@@ -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<ApplicableFix> {
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,
@@ -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,
@@ -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();
@@ -40,6 +40,7 @@ Program:
vendored://stdlib,
]
Settings: Settings {
baseline: None,
rules: <RULES>,
terminal: TerminalSettings {
output_format: Full,
+2
View File
@@ -1212,11 +1212,13 @@ pub enum Severity {
Warning,
Error,
Fatal,
Hint,
}
impl From<diagnostic::Severity> 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,
+11
View File
@@ -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": [