From 1994d6e3b7570c93be52115527625c5dae1b281c Mon Sep 17 00:00:00 2001 From: Long Ho Date: Mon, 27 Jul 2026 15:12:31 -0400 Subject: [PATCH] fix: enforce case-sensitive module resolution --- crates/codescythe/analyze/resolver.rs | 84 ++++++++++++++++++++++++--- crates/codescythe/analyze/tests.rs | 27 +++++++++ 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/crates/codescythe/analyze/resolver.rs b/crates/codescythe/analyze/resolver.rs index b11e3c1..fc2abb1 100644 --- a/crates/codescythe/analyze/resolver.rs +++ b/crates/codescythe/analyze/resolver.rs @@ -8,7 +8,7 @@ use std::cell::RefCell; use std::time::{Duration, Instant}; pub(super) struct ModuleResolver { - resolver: ResolverGeneric, + resolver: ResolverGeneric, index_by_path: HashMap, resolution_cache: RefCell>, #[cfg(feature = "profiling")] @@ -37,21 +37,42 @@ impl ResolverProfileStats { } } -struct IgnoredResolverMetadataFileSystem { +struct ResolverFileSystem { cwd: PathBuf, ignore: GlobSet, inner: FileSystemOs, + project_paths: HashSet, + folded_project_paths: HashSet, } -impl IgnoredResolverMetadataFileSystem { - fn for_config(cwd: &Path, config: &CodescytheConfig) -> Result { +impl ResolverFileSystem { + fn for_config( + cwd: &Path, + project_files: &[PathBuf], + config: &CodescytheConfig, + ) -> Result { Ok(Self { cwd: cwd.to_path_buf(), ignore: build_glob_set(&config.ignore)?, inner: FileSystemOs::new(), + project_paths: project_files + .iter() + .map(|path| normalize_path(path)) + .collect(), + folded_project_paths: project_files + .iter() + .filter_map(|path| folded_relative_path(cwd, path)) + .collect(), }) } + fn has_project_path_case_mismatch(&self, path: &Path) -> bool { + let normalized = normalize_path(path); + !self.project_paths.contains(&normalized) + && folded_relative_path(&self.cwd, &normalized) + .is_some_and(|path| self.folded_project_paths.contains(&path)) + } + fn ignores_resolver_metadata(&self, path: &Path) -> bool { if !path .file_name() @@ -78,14 +99,23 @@ impl IgnoredResolverMetadataFileSystem { format!("ignored by codescythe config: {}", path.display()), ) } + + fn case_mismatch_error(path: &Path) -> io::Error { + io::Error::new( + io::ErrorKind::NotFound, + format!("project path casing mismatch: {}", path.display()), + ) + } } -impl FileSystem for IgnoredResolverMetadataFileSystem { +impl FileSystem for ResolverFileSystem { fn new() -> Self { Self { cwd: PathBuf::new(), ignore: build_glob_set(&[]).expect("empty glob set is valid"), inner: FileSystemOs::new(), + project_paths: HashSet::new(), + folded_project_paths: HashSet::new(), } } @@ -107,14 +137,24 @@ impl FileSystem for IgnoredResolverMetadataFileSystem { if self.ignores_resolver_metadata(path) { return Err(Self::ignored_error(path)); } - self.inner.metadata(path) + let metadata = self.inner.metadata(path)?; + if metadata.is_file() && self.has_project_path_case_mismatch(path) { + return Err(Self::case_mismatch_error(path)); + } + Ok(metadata) } fn symlink_metadata(&self, path: &Path) -> io::Result { if self.ignores_resolver_metadata(path) { return Err(Self::ignored_error(path)); } - self.inner.symlink_metadata(path) + let metadata = self.inner.symlink_metadata(path)?; + if (metadata.is_file() || metadata.is_symlink()) + && self.has_project_path_case_mismatch(path) + { + return Err(Self::case_mismatch_error(path)); + } + Ok(metadata) } fn read_link(&self, path: &Path) -> Result { @@ -140,7 +180,7 @@ impl ModuleResolver { config: &CodescytheConfig, ) -> Result { let resolver = ResolverGeneric::new_with_file_system( - IgnoredResolverMetadataFileSystem::for_config(cwd, config)?, + ResolverFileSystem::for_config(cwd, project_files, config)?, ResolveOptions { cwd: Some(cwd.to_path_buf()), tsconfig: Some(TsconfigDiscovery::Auto), @@ -330,6 +370,34 @@ impl ModuleResolver { } } +fn folded_relative_path(cwd: &Path, path: &Path) -> Option { + normalize_path(path) + .strip_prefix(cwd) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/").to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_project_path_case_mismatches() { + let cwd = Path::new("/workspace"); + let project_files = [ + cwd.join("src/ExploreApiCard.tsx"), + cwd.join("src/exploreApiCard.ts"), + ]; + let file_system = + ResolverFileSystem::for_config(cwd, &project_files, &CodescytheConfig::default()) + .unwrap(); + + assert!(file_system.has_project_path_case_mismatch(&cwd.join("src/ExploreApiCard.ts"))); + assert!(!file_system.has_project_path_case_mismatch(&cwd.join("src/ExploreApiCard.tsx"))); + assert!(!file_system.has_project_path_case_mismatch(&cwd.join("src/exploreApiCard.ts"))); + } +} + fn config_aliases(cwd: &Path, config: &CodescytheConfig) -> Vec<(String, Vec)> { config .aliases diff --git a/crates/codescythe/analyze/tests.rs b/crates/codescythe/analyze/tests.rs index d864922..90492ad 100644 --- a/crates/codescythe/analyze/tests.rs +++ b/crates/codescythe/analyze/tests.rs @@ -199,6 +199,33 @@ fn follows_oxc_resolution_rules_for_project_imports() { assert!(analysis.issues.exports["app/extension.ts"].contains_key("unusedExtension")); } +#[test] +fn resolves_case_distinct_module_basenames() { + let analysis = analyze_inline_project_with_config( + r#"{ + "entry": "src/ExploreApisSection.tsx", + "project": ["src/**/*.ts", "src/**/*.tsx"] + }"#, + &[ + ( + "src/ExploreApisSection.tsx", + "import { ExploreApiCard } from './ExploreApiCard.js';\nconsole.log(ExploreApiCard);\n", + ), + ( + "src/ExploreApiCard.tsx", + "import type { ExploreApiCardConfig } from './exploreApiCard.js';\nexport const ExploreApiCard = (card: ExploreApiCardConfig) => card.id;\n", + ), + ( + "src/exploreApiCard.ts", + "export interface ExploreApiCardConfig { id: string }\n", + ), + ], + ); + + assert!(!analysis.issues.files.contains_key("src/ExploreApiCard.tsx")); + assert_no_unused_export(&analysis, "src/ExploreApiCard.tsx", "ExploreApiCard"); +} + #[test] fn reads_package_json_imports_by_default() { let tempdir = tempfile::tempdir().unwrap();