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 excludeGlob 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-scriptsExclude files containing PEP 723 inline script metadata unless passed explicitly. Use --include-scripts to disable
--exit-zeroAlways use exit code 0, even when there are error-level diagnostics.
Cannot be used in combination with --error-on-warning.
--exit-zero-on-warningUse 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": [