mirror of
https://github.com/astral-sh/ruff.git
synced 2026-10-28 10:45:37 +08:00
[ty] Add diagnostic baseline support (#27636)
This commit is contained in:
1 parent
6840db323c
commit
207ec3affe
28 files changed
+1300
-20
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
@@ -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>
|
||||
|
||||
Generated
+29
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
mod analysis_options;
|
||||
mod baseline;
|
||||
mod config_option;
|
||||
mod exit_code;
|
||||
mod file_selection;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+11
@@ -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": [
|
||||
|
||||
Reference in new issue
Block a user