diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md
index 97a9a1c2d9..d701649dd2 100644
--- a/crates/ty/docs/cli.md
+++ b/crates/ty/docs/cli.md
@@ -56,6 +56,7 @@ over all configuration files.
--exit-zeroAlways use exit code 0, even when there are error-level diagnostics
Additional path to use as a module-resolution source (can be passed multiple times).
This is an advanced option that should usually only be used for first-party or third-party modules that are not installed into your Python environment in a conventional way. Use --python to point ty to your Python environment if it is in an unusual location.
+--force-excludeEnforce exclusions, even for paths passed to ty directly on the command-line. Use --no-force-exclude to disable
--help, -hPrint help (see a summary with '-h')
--ignore ruleDisables the rule. Can be specified multiple times.
--no-progressHide all progress outputs.
diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs
index 37ae9b0bae..0e6d587029 100644
--- a/crates/ty/src/args.rs
+++ b/crates/ty/src/args.rs
@@ -154,6 +154,17 @@ pub(crate) struct CheckCommand {
#[clap(long, overrides_with("respect_ignore_files"), hide = true)]
no_respect_ignore_files: bool,
+ /// Enforce exclusions, even for paths passed to ty directly on the command-line.
+ /// Use `--no-force-exclude` to disable.
+ #[arg(
+ long,
+ overrides_with("no_force_exclude"),
+ help_heading = "File selection"
+ )]
+ force_exclude: bool,
+ #[clap(long, overrides_with("force_exclude"), hide = true)]
+ no_force_exclude: bool,
+
/// Glob patterns for files to exclude from type checking.
///
/// Uses gitignore-style syntax to exclude files and directories from type checking.
@@ -178,6 +189,10 @@ pub(crate) struct CheckCommand {
}
impl CheckCommand {
+ pub(crate) fn force_exclude(&self) -> bool {
+ resolve_bool_arg(self.force_exclude, self.no_progress).unwrap_or_default()
+ }
+
pub(crate) fn into_options(self) -> Options {
let rules = if self.rules.is_empty() {
None
@@ -435,3 +450,12 @@ impl ConfigsArg {
self.0
}
}
+
+fn resolve_bool_arg(yes: bool, no: bool) -> Option {
+ match (yes, no) {
+ (true, false) => Some(true),
+ (false, true) => Some(false),
+ (false, false) => None,
+ (..) => unreachable!("Clap should make this impossible"),
+ }
+}
diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs
index 01fc1ad1a7..bc3029be41 100644
--- a/crates/ty/src/lib.rs
+++ b/crates/ty/src/lib.rs
@@ -118,6 +118,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result {
.config_file
.as_ref()
.map(|path| SystemPath::absolute(path, &cwd));
+ let force_exclude = args.force_exclude();
let mut project_metadata = match &config_file {
Some(config_file) => ProjectMetadata::from_config_file(config_file.clone(), &system)?,
@@ -130,11 +131,13 @@ fn run_check(args: CheckCommand) -> anyhow::Result {
project_metadata.apply_overrides(&project_options_overrides);
let mut db = ProjectDatabase::new(project_metadata, system)?;
+ let project = db.project();
+
+ project.set_verbose(&mut db, verbosity >= VerbosityLevel::Verbose);
+ project.set_force_exclude(&mut db, force_exclude);
- db.project()
- .set_verbose(&mut db, verbosity >= VerbosityLevel::Verbose);
if !check_paths.is_empty() {
- db.project().set_included_paths(&mut db, check_paths);
+ project.set_included_paths(&mut db, check_paths);
}
let (main_loop, main_loop_cancellation_token) =
diff --git a/crates/ty/tests/cli/file_selection.rs b/crates/ty/tests/cli/file_selection.rs
index 46f9106c21..683790f443 100644
--- a/crates/ty/tests/cli/file_selection.rs
+++ b/crates/ty/tests/cli/file_selection.rs
@@ -589,6 +589,128 @@ fn explicit_path_overrides_exclude() -> anyhow::Result<()> {
Ok(())
}
+/// Test behavior when explicitly checking a path that matches an exclude pattern and `--force-exclude` is provided
+#[test]
+fn explicit_path_overrides_exclude_force_exclude() -> anyhow::Result<()> {
+ let case = CliTest::with_files([
+ (
+ "src/main.py",
+ r#"
+ print(undefined_var) # error: unresolved-reference
+ "#,
+ ),
+ (
+ "tests/generated.py",
+ r#"
+ print(dist_undefined_var) # error: unresolved-reference
+ "#,
+ ),
+ (
+ "dist/other.py",
+ r#"
+ print(other_undefined_var) # error: unresolved-reference
+ "#,
+ ),
+ (
+ "ty.toml",
+ r#"
+ [src]
+ exclude = ["tests/generated.py"]
+ "#,
+ ),
+ ])?;
+
+ // Explicitly checking a file in an excluded directory should still check that file
+ assert_cmd_snapshot!(case.command().arg("tests/generated.py").arg("src/main.py"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+ error[unresolved-reference]: Name `undefined_var` used when not defined
+ --> src/main.py:2:7
+ |
+ 2 | print(undefined_var) # error: unresolved-reference
+ | ^^^^^^^^^^^^^
+ |
+ info: rule `unresolved-reference` is enabled by default
+
+ error[unresolved-reference]: Name `dist_undefined_var` used when not defined
+ --> tests/generated.py:2:7
+ |
+ 2 | print(dist_undefined_var) # error: unresolved-reference
+ | ^^^^^^^^^^^^^^^^^^
+ |
+ info: rule `unresolved-reference` is enabled by default
+
+ Found 2 diagnostics
+
+ ----- stderr -----
+ ");
+
+ // Except when `--force-exclude` is set.
+ assert_cmd_snapshot!(case.command().arg("tests/generated.py").arg("src/main.py").arg("--force-exclude"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+ error[unresolved-reference]: Name `undefined_var` used when not defined
+ --> src/main.py:2:7
+ |
+ 2 | print(undefined_var) # error: unresolved-reference
+ | ^^^^^^^^^^^^^
+ |
+ info: rule `unresolved-reference` is enabled by default
+
+ Found 1 diagnostic
+
+ ----- stderr -----
+ ");
+
+ // Explicitly checking the entire excluded directory should check all files in it
+ assert_cmd_snapshot!(case.command().arg("dist/").arg("src/main.py"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+ error[unresolved-reference]: Name `other_undefined_var` used when not defined
+ --> dist/other.py:2:7
+ |
+ 2 | print(other_undefined_var) # error: unresolved-reference
+ | ^^^^^^^^^^^^^^^^^^^
+ |
+ info: rule `unresolved-reference` is enabled by default
+
+ error[unresolved-reference]: Name `undefined_var` used when not defined
+ --> src/main.py:2:7
+ |
+ 2 | print(undefined_var) # error: unresolved-reference
+ | ^^^^^^^^^^^^^
+ |
+ info: rule `unresolved-reference` is enabled by default
+
+ Found 2 diagnostics
+
+ ----- stderr -----
+ ");
+
+ // Except when using `--force-exclude`
+ assert_cmd_snapshot!(case.command().arg("dist/").arg("src/main.py").arg("--force-exclude"), @r"
+ success: false
+ exit_code: 1
+ ----- stdout -----
+ error[unresolved-reference]: Name `undefined_var` used when not defined
+ --> src/main.py:2:7
+ |
+ 2 | print(undefined_var) # error: unresolved-reference
+ | ^^^^^^^^^^^^^
+ |
+ info: rule `unresolved-reference` is enabled by default
+
+ Found 1 diagnostic
+
+ ----- stderr -----
+ ");
+
+ Ok(())
+}
+
#[test]
fn cli_and_configuration_exclude() -> anyhow::Result<()> {
let case = CliTest::with_files([
diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs
index 2bcb287b40..4aea0a683d 100644
--- a/crates/ty_project/src/lib.rs
+++ b/crates/ty_project/src/lib.rs
@@ -115,6 +115,10 @@ pub struct Project {
#[default]
verbose_flag: bool,
+
+ /// Whether to enforce exclusion rules even to files explicitly passed to ty on the command line.
+ #[default]
+ force_exclude_flag: bool,
}
/// A progress reporter.
@@ -380,6 +384,16 @@ impl Project {
self.verbose_flag(db)
}
+ pub fn set_force_exclude(self, db: &mut dyn Db, force: bool) {
+ if self.force_exclude_flag(db) != force {
+ self.set_force_exclude_flag(db).to(force);
+ }
+ }
+
+ pub fn force_exclude(self, db: &dyn Db) -> bool {
+ self.force_exclude_flag(db)
+ }
+
/// Returns the paths that should be checked.
///
/// The default is to check the entire project in which case this method returns
diff --git a/crates/ty_project/src/walk.rs b/crates/ty_project/src/walk.rs
index 16a73dd65f..e1d61e99ec 100644
--- a/crates/ty_project/src/walk.rs
+++ b/crates/ty_project/src/walk.rs
@@ -111,6 +111,8 @@ pub(crate) struct ProjectFilesWalker<'a> {
walker: WalkDirectoryBuilder,
filter: ProjectFilesFilter<'a>,
+
+ force_exclude: bool,
}
impl<'a> ProjectFilesWalker<'a> {
@@ -158,7 +160,11 @@ impl<'a> ProjectFilesWalker<'a> {
walker = walker.add(path);
}
- Some(Self { walker, filter })
+ Some(Self {
+ walker,
+ filter,
+ force_exclude: db.project().force_exclude(db),
+ })
}
/// Walks the project paths and collects the paths of all files that
@@ -179,7 +185,7 @@ impl<'a> ProjectFilesWalker<'a> {
// Skip excluded directories unless they were explicitly passed to the walker
// (which is the case passed to `ty check `).
if entry.file_type().is_directory() {
- if entry.depth() > 0 {
+ if entry.depth() > 0 || self.force_exclude {
let directory_included = filter
.is_directory_included(entry.path(), GlobFilterCheckMode::TopDown);
return match directory_included {
@@ -218,7 +224,7 @@ impl<'a> ProjectFilesWalker<'a> {
// For all files, except the ones that were explicitly passed to the walker (CLI),
// check if they're included in the project.
- if entry.depth() > 0 {
+ if entry.depth() > 0 || self.force_exclude {
match filter
.is_file_included(entry.path(), GlobFilterCheckMode::TopDown)
{