From 8dddb4a5682066d9edb43542f4e665cbd1d28e12 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 24 Jul 2026 14:39:21 -0400 Subject: [PATCH] [ty] Add configurable standalone script exclusion --- crates/ty/docs/cli.md | 1 + crates/ty/docs/configuration.md | 27 ++++++ crates/ty/src/args.rs | 18 ++++ crates/ty/tests/cli/file_selection.rs | 88 +++++++++++++++++++ crates/ty_project/src/metadata.rs | 2 +- crates/ty_project/src/metadata/options.rs | 13 +++ crates/ty_project/src/metadata/settings.rs | 2 + crates/ty_project/src/walk.rs | 14 +++ .../e2e__commands__debug_command.snap | 1 + ty.schema.json | 7 ++ 10 files changed, 172 insertions(+), 1 deletion(-) diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md index d68791569d48ad..c8306663a3c592 100644 --- a/crates/ty/docs/cli.md +++ b/crates/ty/docs/cli.md @@ -56,6 +56,7 @@ over all configuration files.

Cannot be used in combination with --exit-zero or --exit-zero-on-warning.

--exclude exclude

Glob patterns for files to exclude from type checking.

Uses gitignore-style syntax to exclude files and directories from type checking. Supports patterns like tests/, *.tmp, **/__pycache__/**.

+
--exclude-scripts

Exclude files containing PEP 723 inline script metadata unless passed explicitly. Use --include-scripts to disable

--exit-zero

Always use exit code 0, even when there are error-level diagnostics.

Cannot be used in combination with --error-on-warning.

--exit-zero-on-warning

Use exit code 0 if there are no error-level diagnostics.

diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index 8f2950fdc8302e..fcd90c42b1e276 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -948,6 +948,33 @@ to re-include `dist` use `exclude = ["!dist"]` --- +### `exclude-scripts` + +Whether to exclude files containing PEP 723 inline script metadata unless they are +explicitly passed on the command line. + +**Default value**: `false` + +**Type**: `bool` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty.src] + exclude-scripts = true + ``` + +=== "ty.toml" + + ```toml + [src] + exclude-scripts = true + ``` + +--- + ### `include` A list of files and directories to check. The `include` option diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index d3a576211df13d..f954ed3390acba 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -204,6 +204,19 @@ pub(crate) struct CheckCommand { #[clap(long, overrides_with("force_exclude"), hide = true)] no_force_exclude: bool, + /// Exclude files containing PEP 723 inline script metadata unless passed explicitly. + /// Use `--include-scripts` to disable. + #[arg( + long, + overrides_with("include_scripts"), + help_heading = "File selection", + default_missing_value = "true", + num_args = 0..1 + )] + exclude_scripts: Option, + #[clap(long, overrides_with("exclude_scripts"), hide = true)] + include_scripts: bool, + /// Glob patterns for files to exclude from type checking. /// /// Uses gitignore-style syntax to exclude files and directories from type checking. @@ -251,6 +264,10 @@ impl CheckCommand { .no_respect_ignore_files .then_some(false) .or(self.respect_ignore_files); + let exclude_scripts = self + .include_scripts + .then_some(false) + .or(self.exclude_scripts); let error_on_warning = self .exit_zero_on_warning .then_some(false) @@ -279,6 +296,7 @@ impl CheckCommand { }), src: Some(SrcOptions { respect_ignore_files, + exclude_scripts, exclude: self.exclude.map(|excludes| { RangedValue::cli(excludes.iter().map(RelativeGlobPattern::cli).collect()) }), diff --git a/crates/ty/tests/cli/file_selection.rs b/crates/ty/tests/cli/file_selection.rs index 47b68548c20dec..7046d1deb19752 100644 --- a/crates/ty/tests/cli/file_selection.rs +++ b/crates/ty/tests/cli/file_selection.rs @@ -2,6 +2,94 @@ use insta_cmd::assert_cmd_snapshot; use crate::CliTest; +#[test] +fn exclude_scripts_only_applies_to_implicitly_discovered_files() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ("main.py", "value: int = 'project'"), + ( + "script.py", + r#" + # /// script + # dependencies = [] + # /// + value: int = "script" + "#, + ), + ( + "nested/script.py", + r#" + # /// script + # dependencies = [] + # /// + value: int = "nested-script" + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command().env("TY_OUTPUT_FORMAT", "concise"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + main.py:1:14: error[invalid-assignment] Object of type `Literal["project"]` is not assignable to `int` + nested/script.py:5:14: error[invalid-assignment] Object of type `Literal["nested-script"]` is not assignable to `int` + script.py:5:14: error[invalid-assignment] Object of type `Literal["script"]` is not assignable to `int` + Found 3 diagnostics + + ----- stderr ----- + "#); + + assert_cmd_snapshot!(case.command().env("TY_OUTPUT_FORMAT", "concise").arg("--exclude-scripts"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + main.py:1:14: error[invalid-assignment] Object of type `Literal["project"]` is not assignable to `int` + Found 1 diagnostic + + ----- stderr ----- + "#); + + assert_cmd_snapshot!(case.command().env("TY_OUTPUT_FORMAT", "concise").arg("--exclude-scripts").arg("script.py"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + script.py:5:14: error[invalid-assignment] Object of type `Literal["script"]` is not assignable to `int` + Found 1 diagnostic + + ----- stderr ----- + "#); + + case.write_file( + "ty.toml", + r#" + [src] + exclude-scripts = true + "#, + )?; + + assert_cmd_snapshot!(case.command().env("TY_OUTPUT_FORMAT", "concise"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + main.py:1:14: error[invalid-assignment] Object of type `Literal["project"]` is not assignable to `int` + Found 1 diagnostic + + ----- stderr ----- + "#); + assert_cmd_snapshot!(case.command().env("TY_OUTPUT_FORMAT", "concise").arg("--include-scripts"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + main.py:1:14: error[invalid-assignment] Object of type `Literal["project"]` is not assignable to `int` + nested/script.py:5:14: error[invalid-assignment] Object of type `Literal["nested-script"]` is not assignable to `int` + script.py:5:14: error[invalid-assignment] Object of type `Literal["script"]` is not assignable to `int` + Found 3 diagnostics + + ----- stderr ----- + "#); + + Ok(()) +} + /// Test exclude CLI argument functionality #[test] fn exclude_argument() -> anyhow::Result<()> { diff --git a/crates/ty_project/src/metadata.rs b/crates/ty_project/src/metadata.rs index b87c086ff403f3..e6439cf14df433 100644 --- a/crates/ty_project/src/metadata.rs +++ b/crates/ty_project/src/metadata.rs @@ -24,7 +24,7 @@ mod configuration_file; pub mod options; pub mod pyproject; pub mod python_version; -mod script; +pub(crate) mod script; pub mod settings; mod uv; pub mod value; diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 8c586ea8ce9c62..14cf81fcfbb2ca 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -943,6 +943,18 @@ pub struct SrcOptions { #[serde(skip_serializing_if = "Option::is_none")] pub respect_ignore_files: Option, + /// Whether to exclude files containing PEP 723 inline script metadata unless they are + /// explicitly passed on the command line. + #[option( + default = r#"false"#, + value_type = r#"bool"#, + example = r#" + exclude-scripts = true + "# + )] + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude_scripts: Option, + /// A list of files and directories to check. The `include` option /// follows a similar syntax to `.gitignore` but reversed: /// Including a file or directory will make it so that it (and its contents) @@ -1062,6 +1074,7 @@ impl SrcOptions { Ok(SrcSettings { respect_ignore_files: self.respect_ignore_files.unwrap_or(true), + exclude_scripts: self.exclude_scripts.unwrap_or(false), files, }) } diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index 9ba362b57d27a1..e2e4b0c74bd769 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -81,12 +81,14 @@ impl Default for TerminalSettings { #[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] pub struct SrcSettings { pub respect_ignore_files: bool, + pub exclude_scripts: bool, pub files: IncludeExcludeFilter, } impl SrcSettings { pub(crate) fn default() -> Self { Self { respect_ignore_files: true, + exclude_scripts: false, files: IncludeExcludeFilter::default(), } } diff --git a/crates/ty_project/src/walk.rs b/crates/ty_project/src/walk.rs index 6efea29574048c..91e775544aadbc 100644 --- a/crates/ty_project/src/walk.rs +++ b/crates/ty_project/src/walk.rs @@ -1,4 +1,5 @@ use crate::glob::IncludeExcludeFilter; +use crate::metadata::script::script_metadata; use crate::{Db, GlobFilterCheckMode, IncludeResult, Project}; use ruff_db::diagnostic::{Diagnostic, DiagnosticId, Severity}; use ruff_db::files::{File, system_path_to_file}; @@ -167,6 +168,7 @@ impl ProjectFilesWalker { }; let filter = ProjectFilesFilter::from_project(db, project); + let exclude_scripts = project.settings(db).src().exclude_scripts; let files = std::sync::Mutex::new(Vec::new()); let diagnostics = std::sync::Mutex::new(Vec::new()); @@ -259,6 +261,18 @@ impl ProjectFilesWalker { // If this returns `Err`, then the file was deleted between now and when the walk callback was called. // We can ignore this. if let Ok(file) = system_path_to_file(&*db, entry.path()) { + if entry.depth() > 0 + && exclude_scripts + && script_metadata(&*db, file).is_some() + { + tracing::debug!( + "Ignoring implicitly discovered PEP 723 script `{path}` \ + because `exclude-scripts` is enabled.", + path = entry.path() + ); + return WalkState::Skip; + } + files.lock().unwrap().push(file); } } diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index b570aef0b48c16..464020fa4fb6f6 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -46,6 +46,7 @@ Settings: Settings { }, src: SrcSettings { respect_ignore_files: true, + exclude_scripts: false, files: IncludeExcludeFilter { include: IncludeFilter( [ diff --git a/ty.schema.json b/ty.schema.json index ef68c1c4b0a32b..be1fa4be1c305a 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1586,6 +1586,13 @@ } ] }, + "exclude-scripts": { + "description": "Whether to exclude files containing PEP 723 inline script metadata unless they are\nexplicitly passed on the command line.", + "type": [ + "boolean", + "null" + ] + }, "include": { "description": "A list of files and directories to check. The `include` option\nfollows a similar syntax to `.gitignore` but reversed:\nIncluding a file or directory will make it so that it (and its contents)\nare type checked.\n\n- `./src/` matches only a directory\n- `./src` matches both files and directories\n- `src` matches a file or directory named `src`\n- `*` matches any (possibly empty) sequence of characters (except `/`).\n- `**` matches zero or more path components.\n This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error.\n A sequence of more than two consecutive `*` characters is also invalid.\n- `?` matches any single character except `/`\n- `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode,\n so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.\n\nAll paths are anchored relative to the project root (`src` only\nmatches `/src` and not `/test/src`).\n\n`exclude` takes precedence over `include`.", "anyOf": [