diff --git a/CLAUDE.md b/CLAUDE.md index ce723fc..2737841 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -277,7 +277,10 @@ reflect the order features were added, not execution order (`Step 6` appears twi Uses `oxc_resolver` with TypeScript-aware configuration: - Looks for `tsconfig.base.json` in workspace root for path mappings -- Supports extensions: `.ts`, `.tsx`, `.js`, `.jsx`, `.d.ts` +- Supports extensions: `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.d.ts`, `.d.mts`, `.d.cts` +- TypeScript variants are preferred over their JS counterparts, and `.mjs`/`.cjs` + specifiers resolve to `.mts`/`.cts` sources respectively (via `extension_alias`), + matching TypeScript's "import with output extension" convention - Handles both relative imports and workspace path aliases ### Workspace Specifier Matching (Known Pitfall) diff --git a/src/semantic/analyzer.rs b/src/semantic/analyzer.rs index 80e9d1a..18731d0 100644 --- a/src/semantic/analyzer.rs +++ b/src/semantic/analyzer.rs @@ -352,14 +352,7 @@ impl WorkspaceAnalyzer { e.map_err(|err| warn!("Failed to read directory entry: {}", err)) .ok() }) - .filter(|e| { - e.file_type().is_file() - && e - .path() - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| matches!(ext, "ts" | "tsx" | "js" | "jsx")) - }) + .filter(|e| e.file_type().is_file() && crate::utils::is_source_file(e.path())) .map(|e| { let abs_path = e.into_path(); let rel_path = abs_path diff --git a/src/semantic/mod.rs b/src/semantic/mod.rs index 1c79c3a..5838787 100644 --- a/src/semantic/mod.rs +++ b/src/semantic/mod.rs @@ -13,7 +13,9 @@ pub(crate) use resolve_options::is_workspace_specifier; pub(crate) use resolve_options::parse_tsconfig_path_prefixes; /// Shared fallback resolution for relative imports when oxc_resolver fails. -/// Handles .js/.jsx → .ts/.tsx remapping and standard extension probing. +/// Handles .js/.jsx/.mjs/.cjs → TypeScript-equivalent remapping and standard +/// extension probing. Mirrors the `extensions` / `extension_alias` config in +/// `create_resolve_options` — kept in sync with it deliberately. pub(crate) fn simple_resolve_relative( cwd: &Path, context: &Path, @@ -31,8 +33,29 @@ pub(crate) fn simple_resolve_relative( } }; - // 1. .js/.jsx → .ts/.tsx remapping (ESM convention) - if let Some(stem) = specifier.strip_suffix(".js") { + // 1. .js/.jsx/.mjs/.cjs → TypeScript-equivalent remapping (ESM convention). + // .mjs and .cjs are handled as separate branches (rather than merged into the + // .js branch) so an ESM-explicit specifier can't silently resolve to a + // CJS-explicit source or vice versa. + if let Some(stem) = specifier.strip_suffix(".mjs") { + let stem_path = context.join(stem); + let stem_str = stem_path.to_string_lossy(); + for ext in &[".mts", ".mjs"] { + let candidate = PathBuf::from(format!("{}{}", stem_str, ext)); + if let Some(p) = try_candidate(&candidate) { + return Some(p); + } + } + } else if let Some(stem) = specifier.strip_suffix(".cjs") { + let stem_path = context.join(stem); + let stem_str = stem_path.to_string_lossy(); + for ext in &[".cts", ".cjs"] { + let candidate = PathBuf::from(format!("{}{}", stem_str, ext)); + if let Some(p) = try_candidate(&candidate) { + return Some(p); + } + } + } else if let Some(stem) = specifier.strip_suffix(".js") { let stem_path = context.join(stem); let stem_str = stem_path.to_string_lossy(); for ext in &[".ts", ".tsx", ".js"] { @@ -52,18 +75,28 @@ pub(crate) fn simple_resolve_relative( } } - // 2. Standard extension probing + index file resolution + // 2. Standard extension probing + index file resolution. + // TypeScript variants precede their JS counterparts, matching the ordering + // in `create_resolve_options`. let base = context.join(specifier); let base_str = base.to_string_lossy(); for suffix in &[ ".ts", ".tsx", + ".mts", + ".cts", ".js", ".jsx", + ".mjs", + ".cjs", "/index.ts", "/index.tsx", + "/index.mts", + "/index.cts", "/index.js", "/index.jsx", + "/index.mjs", + "/index.cjs", ] { let candidate = if let Some(stripped) = suffix.strip_prefix('/') { base.join(stripped) diff --git a/src/semantic/reference_finder.rs b/src/semantic/reference_finder.rs index 3c2d673..a8b9cb7 100644 --- a/src/semantic/reference_finder.rs +++ b/src/semantic/reference_finder.rs @@ -532,6 +532,84 @@ mod tests { ); } + #[test] + fn test_simple_resolve_mjs_to_mts_remapping() { + // Test that imports with .mjs extensions resolve to .mts files through the + // `simple_resolve_relative` fallback (used when oxc_resolver fails to resolve). + // This exercises the ESM "import with output extension" convention, e.g. + // import { helper } from './utils.mjs' where the actual file is utils.mts. + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let cwd = temp_dir.path(); + + // Create a test file: src/utils.mts (but NOT src/utils.mjs) + let src_dir = cwd.join("src"); + fs::create_dir_all(&src_dir).expect("Failed to create src dir"); + let utils_file = src_dir.join("utils.mts"); + fs::write(&utils_file, "export function helper() {}").expect("Failed to write test file"); + + let profiler = Arc::new(Profiler::new(false)); + let analyzer = + WorkspaceAnalyzer::new(vec![], cwd, profiler.clone()).expect("Failed to create analyzer"); + let reference_finder = ReferenceFinder::new(&analyzer, cwd, profiler); + + // Test: resolve "./utils.mjs" from src directory + // Should find utils.mts by stripping .mjs and trying .mts + let context = src_dir.as_path(); + let specifier = "./utils.mjs"; + let resolved = reference_finder.simple_resolve(context, specifier); + + assert!( + resolved.is_some(), + "Expected to resolve utils.mjs to utils.mts" + ); + let resolved_path = resolved.unwrap(); + assert_eq!( + resolved_path, + PathBuf::from("src/utils.mts"), + "Expected to resolve ./utils.mjs to utils.mts" + ); + } + + #[test] + fn test_simple_resolve_cjs_to_cts_remapping() { + // Test that imports with .cjs extensions resolve to .cts files through the + // `simple_resolve_relative` fallback (used when oxc_resolver fails to resolve). + // This exercises the CJS "import with output extension" convention, e.g. + // import { helper } from './helper.cjs' where the actual file is helper.cts. + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let cwd = temp_dir.path(); + + // Create a test file: src/helper.cts (but NOT src/helper.cjs) + let src_dir = cwd.join("src"); + fs::create_dir_all(&src_dir).expect("Failed to create src dir"); + let helper_file = src_dir.join("helper.cts"); + fs::write(&helper_file, "export function helper() {}").expect("Failed to write test file"); + + let profiler = Arc::new(Profiler::new(false)); + let analyzer = + WorkspaceAnalyzer::new(vec![], cwd, profiler.clone()).expect("Failed to create analyzer"); + let reference_finder = ReferenceFinder::new(&analyzer, cwd, profiler); + + // Test: resolve "./helper.cjs" from src directory + // Should find helper.cts by stripping .cjs and trying .cts + let context = src_dir.as_path(); + let specifier = "./helper.cjs"; + let resolved = reference_finder.simple_resolve(context, specifier); + + assert!( + resolved.is_some(), + "Expected to resolve helper.cjs to helper.cts" + ); + let resolved_path = resolved.unwrap(); + assert_eq!( + resolved_path, + PathBuf::from("src/helper.cts"), + "Expected to resolve ./helper.cjs to helper.cts" + ); + } + #[test] fn test_simple_resolve_js_to_tsx_remapping() { // Test that .js imports can resolve to .tsx files diff --git a/src/semantic/resolve_options.rs b/src/semantic/resolve_options.rs index 45626f5..28668d4 100644 --- a/src/semantic/resolve_options.rs +++ b/src/semantic/resolve_options.rs @@ -63,22 +63,41 @@ pub fn create_resolve_options(cwd: &Path, projects: &[Project]) -> ResolveOption .collect::>(); ResolveOptions { + // TypeScript variants are listed before their JS counterparts (mirroring the + // existing .ts-before-.js ordering) so that when both a TS and a JS file could + // satisfy an extension-less specifier, the TypeScript source wins. The explicit + // ESM/CJS variants (.mts/.cts/.mjs/.cjs) are common in strict-ESM monorepos and + // dual-package (ESM+CJS) libraries; .d.mts/.d.cts fill the same declaration-file + // slot as .d.ts. extensions: vec![ ".ts".into(), ".tsx".into(), + ".mts".into(), + ".cts".into(), ".js".into(), ".jsx".into(), + ".mjs".into(), + ".cjs".into(), ".d.ts".into(), + ".d.mts".into(), + ".d.cts".into(), ], - // Map .js/.jsx imports to their TypeScript equivalents. + // Map .js/.jsx/.mjs/.cjs imports to their TypeScript equivalents. // Handles the common ESM pattern where .ts files import with .js extensions - // (e.g., import { foo } from './bar.js' where the actual file is bar.ts). + // (e.g., import { foo } from './bar.js' where the actual file is bar.ts), and + // the analogous TypeScript "import with output extension" convention where a + // .mts/.cts source is imported via its compiled .mjs/.cjs extension. + // .mjs/.cjs are kept separate from .js (rather than folded into one shared + // alias) so an ESM-explicit specifier can't silently resolve to a CJS-explicit + // source or vice versa. extension_alias: vec![ ( ".js".into(), vec![".ts".into(), ".tsx".into(), ".js".into()], ), (".jsx".into(), vec![".tsx".into(), ".jsx".into()]), + (".mjs".into(), vec![".mts".into(), ".mjs".into()]), + (".cjs".into(), vec![".cts".into(), ".cjs".into()]), ], // Resolve bare package imports to source roots within the monorepo. alias, diff --git a/src/utils.rs b/src/utils.rs index f755ebd..6094c42 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,8 +4,13 @@ use rustc_hash::{FxHashMap, FxHashSet}; use std::path::{Path, PathBuf}; use tracing::debug; -/// Extensions considered as source files (analyzed by Oxc parser) -const SOURCE_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx"]; +/// Extensions considered as source files (analyzed by Oxc parser). +/// +/// Includes the explicit ESM/CJS variants (`.mts`/`.cts`/`.mjs`/`.cjs`) used by +/// strict-ESM monorepos and dual-package (ESM+CJS) libraries. `Path::extension()` +/// only returns the final segment, so this list also covers `.d.mts`/`.d.cts` +/// (which report as `mts`/`cts`) without needing separate entries. +const SOURCE_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"]; /// Check if a file is a source file (TypeScript/JavaScript) /// These are files that can be parsed by the Oxc parser @@ -231,6 +236,13 @@ mod tests { assert!(is_source_file(Path::new("app.jsx"))); assert!(is_source_file(Path::new("path/to/file.ts"))); + // ESM/CJS explicit-extension source files (strict-ESM monorepos, dual-package libs) + assert!(is_source_file(Path::new("utils.mts"))); + assert!(is_source_file(Path::new("utils.cts"))); + assert!(is_source_file(Path::new("utils.mjs"))); + assert!(is_source_file(Path::new("utils.cjs"))); + assert!(is_source_file(Path::new("path/to/file.mts"))); + // Non-source files assert!(!is_source_file(Path::new("styles.css"))); assert!(!is_source_file(Path::new("template.html"))); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 6bf9254..96a37c4 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2420,6 +2420,140 @@ export function run() { ); } +/// Regression test: `.mts` files must be treated as first-class source files, not assets. +/// +/// Reproduces a strict-ESM monorepo scenario where a shared library exposes a `.mts` +/// module. Before the fix, `.mts` was not in `SOURCE_EXTENSIONS`, so the file was +/// classified as an "asset" — its owning project (`proj-a`) was still marked affected +/// via the asset-fallback path, but its exports were never traced through the import +/// index, so downstream consumers (`proj-b`) were silently missed. +/// +/// Note: `proj-b`'s import is deliberately extension-less; this relies on domino's +/// deliberately-permissive extension-less resolution — real TypeScript under +/// `node16`/`nodenext` module resolution would require the explicit `.mts` extension. +#[test] +fn test_mts_source_file_traced_across_projects() { + let tmp = TempDir::new().expect("Failed to create temp dir"); + let root = tmp + .path() + .canonicalize() + .expect("Failed to canonicalize temp dir"); + + let proj_a_src = root.join("proj-a/src"); + let proj_b_src = root.join("proj-b/src"); + let proj_c_src = root.join("proj-c/src"); + fs::create_dir_all(&proj_a_src).unwrap(); + fs::create_dir_all(&proj_b_src).unwrap(); + fs::create_dir_all(&proj_c_src).unwrap(); + + fs::write( + proj_a_src.join("utils.mts"), + r#"export function computeValue(): number { + return 1; +} +"#, + ) + .unwrap(); + + // Deliberately extension-less: proj-b's source text never contains the literal + // string "utils.mts", so the naive filename-text asset-reference fallback (which + // greps quoted strings for the changed file's basename) cannot find this consumer. + // Only proper source-file symbol tracing (which requires utils.mts to be scanned + // into the semantic analyzer and resolved via the extensions probing list) can. + fs::write( + proj_b_src.join("index.ts"), + r#"import { computeValue } from '../../proj-a/src/utils'; + +export function run() { + return computeValue(); +} +"#, + ) + .unwrap(); + + fs::write( + proj_c_src.join("index.ts"), + r#"export function unrelated() { + return 'unrelated'; +} +"#, + ) + .unwrap(); + + git_in(&root, &["init"]); + git_in(&root, &["config", "user.email", "test@test.com"]); + git_in(&root, &["config", "user.name", "Test"]); + git_in(&root, &["branch", "-M", "main"]); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "initial"]); + + git_in(&root, &["checkout", "-b", "feature"]); + fs::write( + proj_a_src.join("utils.mts"), + r#"export function computeValue(): number { + return 2; +} +"#, + ) + .unwrap(); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "modify computeValue"]); + + let config = TrueAffectedConfig { + cwd: root.to_path_buf(), + base: "main".to_string(), + head: None, + projects: vec![ + Project { + name: "proj-a".to_string(), + root: PathBuf::from("proj-a"), + source_root: PathBuf::from("proj-a/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "proj-b".to_string(), + root: PathBuf::from("proj-b"), + source_root: PathBuf::from("proj-b/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "proj-c".to_string(), + root: PathBuf::from("proj-c"), + source_root: PathBuf::from("proj-c/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + ], + lockfile_strategy: LockfileStrategy::None, + }; + + let profiler = Arc::new(Profiler::new(false)); + let result = find_affected(config, profiler).expect("find_affected failed"); + let affected = result.affected_projects; + + assert!( + affected.contains(&"proj-a".to_string()), + "proj-a should be affected (utils.mts was changed). Got: {:?}", + affected + ); + assert!( + affected.contains(&"proj-b".to_string()), + "proj-b should be affected (imports computeValue from proj-a's utils.mts, which must be \ + traced as a source file, not dropped as an asset). Got: {:?}", + affected + ); + assert!( + !affected.contains(&"proj-c".to_string()), + "proj-c is unrelated and must NOT be affected. Got: {:?}", + affected + ); +} + /// Regression/characterization test for batching multiple changed assets in a /// single diff: each asset's references must still be attributed to the /// correct project, and unrelated projects must not be falsely marked @@ -2561,6 +2695,378 @@ export function Panel() { ); } +/// Regression test: `.mjs` files must be treated as first-class source files, not assets. +/// +/// Same shape as [`test_mts_source_file_traced_across_projects`] but for plain +/// JavaScript ESM modules (`.mjs`), common in dual-package (ESM+CJS) libraries. +/// +/// Note: `proj-b`'s import is deliberately extension-less; this relies on domino's +/// deliberately-permissive extension-less resolution — real TypeScript under +/// `node16`/`nodenext` module resolution would require the explicit `.mjs` extension. +#[test] +fn test_mjs_source_file_traced_across_projects() { + let tmp = TempDir::new().expect("Failed to create temp dir"); + let root = tmp + .path() + .canonicalize() + .expect("Failed to canonicalize temp dir"); + + let proj_a_src = root.join("proj-a/src"); + let proj_b_src = root.join("proj-b/src"); + let proj_c_src = root.join("proj-c/src"); + fs::create_dir_all(&proj_a_src).unwrap(); + fs::create_dir_all(&proj_b_src).unwrap(); + fs::create_dir_all(&proj_c_src).unwrap(); + + fs::write( + proj_a_src.join("utils.mjs"), + r#"export function computeValue() { + return 1; +} +"#, + ) + .unwrap(); + + // Deliberately extension-less: see the .mts variant of this test for why this + // avoids the naive filename-text asset-reference fallback. + fs::write( + proj_b_src.join("index.js"), + r#"import { computeValue } from '../../proj-a/src/utils'; + +export function run() { + return computeValue(); +} +"#, + ) + .unwrap(); + + fs::write( + proj_c_src.join("index.js"), + r#"export function unrelated() { + return 'unrelated'; +} +"#, + ) + .unwrap(); + + git_in(&root, &["init"]); + git_in(&root, &["config", "user.email", "test@test.com"]); + git_in(&root, &["config", "user.name", "Test"]); + git_in(&root, &["branch", "-M", "main"]); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "initial"]); + + git_in(&root, &["checkout", "-b", "feature"]); + fs::write( + proj_a_src.join("utils.mjs"), + r#"export function computeValue() { + return 2; +} +"#, + ) + .unwrap(); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "modify computeValue"]); + + let config = TrueAffectedConfig { + cwd: root.to_path_buf(), + base: "main".to_string(), + head: None, + projects: vec![ + Project { + name: "proj-a".to_string(), + root: PathBuf::from("proj-a"), + source_root: PathBuf::from("proj-a/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "proj-b".to_string(), + root: PathBuf::from("proj-b"), + source_root: PathBuf::from("proj-b/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "proj-c".to_string(), + root: PathBuf::from("proj-c"), + source_root: PathBuf::from("proj-c/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + ], + lockfile_strategy: LockfileStrategy::None, + }; + + let profiler = Arc::new(Profiler::new(false)); + let result = find_affected(config, profiler).expect("find_affected failed"); + let affected = result.affected_projects; + + assert!( + affected.contains(&"proj-a".to_string()), + "proj-a should be affected (utils.mjs was changed). Got: {:?}", + affected + ); + assert!( + affected.contains(&"proj-b".to_string()), + "proj-b should be affected (imports computeValue from proj-a's utils.mjs, which must be \ + traced as a source file, not dropped as an asset). Got: {:?}", + affected + ); + assert!( + !affected.contains(&"proj-c".to_string()), + "proj-c is unrelated and must NOT be affected. Got: {:?}", + affected + ); +} + +/// Regression test: relative imports using the TypeScript "import with output extension" +/// convention (`./utils.mjs` on disk as `utils.mts`) must resolve via `extension_alias`, +/// mirroring the existing `.js` -> `.ts` alias. +#[test] +fn test_mjs_import_resolves_to_mts_source() { + let tmp = TempDir::new().expect("Failed to create temp dir"); + let root = tmp + .path() + .canonicalize() + .expect("Failed to canonicalize temp dir"); + + let lib_src = root.join("lib/src"); + let app_src = root.join("app/src"); + let other_src = root.join("other/src"); + fs::create_dir_all(&lib_src).unwrap(); + fs::create_dir_all(&app_src).unwrap(); + fs::create_dir_all(&other_src).unwrap(); + + fs::write( + lib_src.join("utils.mts"), + r#"export function helper(): string { + return 'original'; +} +"#, + ) + .unwrap(); + + // app imports with the .mjs (output) extension while the source on disk is .mts + fs::write( + app_src.join("index.mts"), + r#"import { helper } from '../../lib/src/utils.mjs'; + +export function main() { + return helper(); +} +"#, + ) + .unwrap(); + + fs::write( + other_src.join("index.mts"), + r#"export function unrelated() { + return 'unrelated'; +} +"#, + ) + .unwrap(); + + git_in(&root, &["init"]); + git_in(&root, &["config", "user.email", "test@test.com"]); + git_in(&root, &["config", "user.name", "Test"]); + git_in(&root, &["branch", "-M", "main"]); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "initial"]); + + git_in(&root, &["checkout", "-b", "feature"]); + fs::write( + lib_src.join("utils.mts"), + r#"export function helper(): string { + return 'modified'; +} +"#, + ) + .unwrap(); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "modify helper"]); + + let config = TrueAffectedConfig { + cwd: root.to_path_buf(), + base: "main".to_string(), + head: None, + projects: vec![ + Project { + name: "lib".to_string(), + root: PathBuf::from("lib"), + source_root: PathBuf::from("lib/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "app".to_string(), + root: PathBuf::from("app"), + source_root: PathBuf::from("app/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "other".to_string(), + root: PathBuf::from("other"), + source_root: PathBuf::from("other/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + ], + lockfile_strategy: LockfileStrategy::None, + }; + + let profiler = Arc::new(Profiler::new(false)); + let result = find_affected(config, profiler).expect("find_affected failed"); + let affected = result.affected_projects; + + assert!( + affected.contains(&"lib".to_string()), + "lib should be affected (utils.mts was changed). Got: {:?}", + affected + ); + assert!( + affected.contains(&"app".to_string()), + "app should be affected (imports lib/src/utils.mts via .mjs output-extension import, \ + which requires extension_alias to resolve). Got: {:?}", + affected + ); + assert!( + !affected.contains(&"other".to_string()), + "other is unrelated and must NOT be affected. Got: {:?}", + affected + ); +} + +/// Regression test: relative imports using the TypeScript "import with output extension" +/// convention (`./helper.cjs` on disk as `helper.cts`) must resolve via `extension_alias`, +/// mirroring [`test_mjs_import_resolves_to_mts_source`] but for the CJS side of the alias. +#[test] +fn test_cjs_import_resolves_to_cts_source() { + let tmp = TempDir::new().expect("Failed to create temp dir"); + let root = tmp + .path() + .canonicalize() + .expect("Failed to canonicalize temp dir"); + + let lib_src = root.join("lib/src"); + let app_src = root.join("app/src"); + let other_src = root.join("other/src"); + fs::create_dir_all(&lib_src).unwrap(); + fs::create_dir_all(&app_src).unwrap(); + fs::create_dir_all(&other_src).unwrap(); + + fs::write( + lib_src.join("helper.cts"), + r#"export function helper(): string { + return 'original'; +} +"#, + ) + .unwrap(); + + // app imports with the .cjs (output) extension while the source on disk is .cts + fs::write( + app_src.join("index.cts"), + r#"import { helper } from '../../lib/src/helper.cjs'; + +export function main() { + return helper(); +} +"#, + ) + .unwrap(); + + fs::write( + other_src.join("index.cts"), + r#"export function unrelated() { + return 'unrelated'; +} +"#, + ) + .unwrap(); + + git_in(&root, &["init"]); + git_in(&root, &["config", "user.email", "test@test.com"]); + git_in(&root, &["config", "user.name", "Test"]); + git_in(&root, &["branch", "-M", "main"]); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "initial"]); + + git_in(&root, &["checkout", "-b", "feature"]); + fs::write( + lib_src.join("helper.cts"), + r#"export function helper(): string { + return 'modified'; +} +"#, + ) + .unwrap(); + git_in(&root, &["add", "."]); + git_in(&root, &["commit", "-m", "modify helper"]); + + let config = TrueAffectedConfig { + cwd: root.to_path_buf(), + base: "main".to_string(), + head: None, + projects: vec![ + Project { + name: "lib".to_string(), + root: PathBuf::from("lib"), + source_root: PathBuf::from("lib/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "app".to_string(), + root: PathBuf::from("app"), + source_root: PathBuf::from("app/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + Project { + name: "other".to_string(), + root: PathBuf::from("other"), + source_root: PathBuf::from("other/src"), + ts_config: None, + implicit_dependencies: vec![], + targets: vec![], + }, + ], + lockfile_strategy: LockfileStrategy::None, + }; + + let profiler = Arc::new(Profiler::new(false)); + let result = find_affected(config, profiler).expect("find_affected failed"); + let affected = result.affected_projects; + + assert!( + affected.contains(&"lib".to_string()), + "lib should be affected (helper.cts was changed). Got: {:?}", + affected + ); + assert!( + affected.contains(&"app".to_string()), + "app should be affected (imports lib/src/helper.cts via .cjs output-extension import, \ + which requires extension_alias to resolve). Got: {:?}", + affected + ); + assert!( + !affected.contains(&"other".to_string()), + "other is unrelated and must NOT be affected. Got: {:?}", + affected + ); +} + /// Integration test: multiple projects sharing the same sourceRoot are all reported as affected. /// /// This tests the scenario described in issue #38 where variant builds (e.g., MV2 vs MV3)