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-zero

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

--extra-search-path path

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-exclude

Enforce exclusions, even for paths passed to ty directly on the command-line. Use --no-force-exclude to disable

--help, -h

Print help (see a summary with '-h')

--ignore rule

Disables the rule. Can be specified multiple times.

--no-progress

Hide 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) {