From 4ffab3f7509883527969d92bd2e97468021febe2 Mon Sep 17 00:00:00 2001 From: shaharkazaz Date: Sun, 26 Jul 2026 10:01:25 +0300 Subject: [PATCH 1/4] fix(resolve): treat .mjs/.cjs/.mts/.cts as source files for symbol tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed .mts/.cts/.mjs/.cjs files were classified as "assets" instead of source files, so their own project was marked affected but their exports were never traced through the semantic import index — silently dropping downstream consumer projects (false negatives) in strict-ESM monorepos and dual-package (ESM+CJS) libraries. - utils.rs: add the four extensions to SOURCE_EXTENSIONS/is_source_file - semantic/analyzer.rs: collect_file_paths now delegates to is_source_file instead of using its own separate, now-stale hardcoded extension filter - semantic/resolve_options.rs: add the extensions to the oxc_resolver extensions list (TS variants before their JS counterparts, plus .d.mts/.d.cts), and add extension_alias entries so a .mjs/.cjs specifier resolves to its .mts/.cts source (TypeScript's "import with output extension" convention), kept separate from .js/.jsx so ESM- and CJS-explicit specifiers can't cross-resolve into each other - semantic/mod.rs: mirror the same extensions/aliases in simple_resolve_relative, the fallback resolver used when oxc_resolver fails Adds integration tests tracing symbol changes across projects through .mts and .mjs source files (using extension-less imports so the naive asset filename-text-match fallback can't mask a still-broken fix), plus a dedicated test for the .mjs-specifier-resolves-to-.mts-source alias, and unit test coverage for is_source_file with the new extensions. --- CLAUDE.md | 5 +- src/semantic/analyzer.rs | 9 +- src/semantic/mod.rs | 41 +++- src/semantic/resolve_options.rs | 23 +- src/utils.rs | 16 +- tests/integration_test.rs | 361 ++++++++++++++++++++++++++++++++ 6 files changed, 438 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe4a24b..5b8ae0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,10 @@ The true-affected detection follows this pipeline (see `src/core.rs`): 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 596d12a..8fd3206 100644 --- a/src/semantic/analyzer.rs +++ b/src/semantic/analyzer.rs @@ -238,14 +238,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/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 59a9d35..62543f2 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2339,6 +2339,367 @@ 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. +#[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, + root_ts_config: 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![], + }, + ], + include: vec![], + ignored_paths: 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 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. +#[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, + root_ts_config: 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![], + }, + ], + include: vec![], + ignored_paths: 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"); + fs::create_dir_all(&lib_src).unwrap(); + fs::create_dir_all(&app_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(); + + 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, + root_ts_config: 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![], + }, + ], + include: vec![], + ignored_paths: 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 + ); +} + /// 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) From ecef6f7685b09022dd8f83224f1836e3d57f7b14 Mon Sep 17 00:00:00 2001 From: shaharkazaz Date: Sun, 26 Jul 2026 10:15:32 +0300 Subject: [PATCH 2/4] =?UTF-8?q?test(resolve):=20cover=20the=20simple-resol?= =?UTF-8?q?ve=20fallback=20and=20the=20.cjs=E2=86=92.cts=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes code-review blocking items on the .mjs/.cjs/.mts/.cts extension support branch: - Add unit tests for the simple_resolve_relative fallback (used only when oxc_resolver fails), mirroring the existing js->ts remapping test: `./utils.mjs` -> on-disk `utils.mts` and `./helper.cjs` -> on-disk `helper.cts`. Verified these fail against main's src/semantic/mod.rs (fallback logic unmodified) and pass against the branch's version. - Add an integration test exercising the real oxc_resolver extension_alias for .cjs -> .cts, mirroring the existing .mjs -> .mts test, including an unrelated third project asserted as NOT affected. - Add the same unrelated-third-project assertion to the existing .mjs -> .mts extension_alias integration test. - Document, on the two extension-less source-tracing tests, that they rely on domino's deliberately-permissive extension-less resolution (real TS under node16/nodenext would require the explicit extension). --- src/semantic/reference_finder.rs | 78 +++++++++++++++ tests/integration_test.rs | 157 +++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) 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/tests/integration_test.rs b/tests/integration_test.rs index 62543f2..586269b 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2346,6 +2346,10 @@ export function run() { /// 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"); @@ -2476,6 +2480,10 @@ export function run() { /// /// 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"); @@ -2612,8 +2620,10 @@ fn test_mjs_import_resolves_to_mts_source() { 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"), @@ -2636,6 +2646,15 @@ export function main() { ) .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"]); @@ -2677,6 +2696,14 @@ export function main() { 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![], + }, ], include: vec![], ignored_paths: vec![], @@ -2698,6 +2725,136 @@ export function main() { 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, + root_ts_config: 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![], + }, + ], + include: vec![], + ignored_paths: 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. From 3effdbdef2654c2ecd3d006f214831ab95add508 Mon Sep 17 00:00:00 2001 From: shaharkazaz Date: Mon, 27 Jul 2026 17:00:06 +0300 Subject: [PATCH 3/4] fix: repair test functions silently mangled by automatic merge of origin/main The ort merge strategy interleaved test_mts_source_file_traced_across_projects (this branch) with test_batch_asset_scan_attributes_references_per_project (origin/main, from #84) into a single malformed function without leaving conflict markers. Restored both functions to their original, independent bodies and ran cargo fmt. --- tests/integration_test.rs | 167 ++++++++++++++++++++++++++++---------- 1 file changed, 125 insertions(+), 42 deletions(-) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 6a1068c..1864e7a 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2451,6 +2451,130 @@ export function run() { /// `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, + root_ts_config: 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![], + }, + ], + include: vec![], + ignored_paths: 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 @@ -2470,9 +2594,6 @@ fn test_batch_asset_scan_attributes_references_per_project() { .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"); // -- scaffold monorepo ------------------------------------------------ let assets_dir = root.join("assets"); let proj_a_src = root.join("proj-a/src"); @@ -2483,10 +2604,6 @@ fn test_batch_asset_scan_attributes_references_per_project() { 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; // Assets live outside any project root, so being "affected" here can only // come from the reference scan, not from direct ownership. fs::write(assets_dir.join("icon1.svg"), "one").unwrap(); @@ -2505,17 +2622,6 @@ export function Widget() { ) .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(); // proj-b references icon2.svg only fs::write( proj_b_src.join("panel.ts"), @@ -2528,10 +2634,6 @@ export function Panel() { ) .unwrap(); - fs::write( - proj_c_src.join("index.ts"), - r#"export function unrelated() { - return 'unrelated'; // proj-c references no asset at all fs::write( proj_c_src.join("other.ts"), @@ -2550,18 +2652,6 @@ export function Panel() { 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"]); - // -- create feature branch that changes ALL three assets in one commit -- git_in(&root, &["checkout", "-b", "feature"]); @@ -2580,7 +2670,6 @@ export function Panel() { projects: vec![ Project { name: "proj-a".to_string(), - root: PathBuf::from("proj-a"), root: PathBuf::from("proj-a/src"), source_root: PathBuf::from("proj-a/src"), ts_config: None, @@ -2589,7 +2678,6 @@ export function Panel() { }, Project { name: "proj-b".to_string(), - root: PathBuf::from("proj-b"), root: PathBuf::from("proj-b/src"), source_root: PathBuf::from("proj-b/src"), ts_config: None, @@ -2598,7 +2686,6 @@ export function Panel() { }, Project { name: "proj-c".to_string(), - root: PathBuf::from("proj-c"), root: PathBuf::from("proj-c/src"), source_root: PathBuf::from("proj-c/src"), ts_config: None, @@ -2617,20 +2704,17 @@ export function Panel() { assert!( affected.contains(&"proj-a".to_string()), - "proj-a should be affected (utils.mts was changed). Got: {:?}", "proj-a should be affected (references changed icon1.svg). 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: {:?}", "proj-b should be affected (references changed icon2.svg). Got: {:?}", affected ); assert!( !affected.contains(&"proj-c".to_string()), - "proj-c is unrelated and must NOT be affected. Got: {:?}", + "proj-c should NOT be affected (references no changed asset). Got: {:?}", affected ); } @@ -3012,7 +3096,6 @@ export function main() { assert!( !affected.contains(&"other".to_string()), "other is unrelated and must NOT be affected. Got: {:?}", - "proj-c should NOT be affected (references no changed asset). Got: {:?}", affected ); } From 263dbbc14acc3e6894e9604f581e8b5348236db2 Mon Sep 17 00:00:00 2001 From: shaharkazaz Date: Mon, 27 Jul 2026 23:07:09 +0300 Subject: [PATCH 4/4] test: drop removed TrueAffectedConfig fields from this branch's new tests #83 removed root_ts_config/include/ignored_paths; the tests added on this branch still set them, so the integration test binary stopped compiling once #83 landed on main. --- tests/integration_test.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9291b64..96a37c4 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2503,7 +2503,6 @@ export function run() { cwd: root.to_path_buf(), base: "main".to_string(), head: None, - root_ts_config: None, projects: vec![ Project { name: "proj-a".to_string(), @@ -2530,8 +2529,6 @@ export function run() { targets: vec![], }, ], - include: vec![], - ignored_paths: vec![], lockfile_strategy: LockfileStrategy::None, }; @@ -2648,7 +2645,6 @@ export function Panel() { cwd: root.to_path_buf(), base: "main".to_string(), head: None, - root_ts_config: None, projects: vec![ Project { name: "proj-a".to_string(), @@ -2675,8 +2671,6 @@ export function Panel() { targets: vec![], }, ], - include: vec![], - ignored_paths: vec![], lockfile_strategy: LockfileStrategy::None, }; @@ -2778,7 +2772,6 @@ export function run() { cwd: root.to_path_buf(), base: "main".to_string(), head: None, - root_ts_config: None, projects: vec![ Project { name: "proj-a".to_string(), @@ -2805,8 +2798,6 @@ export function run() { targets: vec![], }, ], - include: vec![], - ignored_paths: vec![], lockfile_strategy: LockfileStrategy::None, }; @@ -2903,7 +2894,6 @@ export function main() { cwd: root.to_path_buf(), base: "main".to_string(), head: None, - root_ts_config: None, projects: vec![ Project { name: "lib".to_string(), @@ -2930,8 +2920,6 @@ export function main() { targets: vec![], }, ], - include: vec![], - ignored_paths: vec![], lockfile_strategy: LockfileStrategy::None, }; @@ -3028,7 +3016,6 @@ export function main() { cwd: root.to_path_buf(), base: "main".to_string(), head: None, - root_ts_config: None, projects: vec![ Project { name: "lib".to_string(), @@ -3055,8 +3042,6 @@ export function main() { targets: vec![], }, ], - include: vec![], - ignored_paths: vec![], lockfile_strategy: LockfileStrategy::None, }; @@ -5041,10 +5026,7 @@ fn turbo_config_for(root: &Path, projects: Vec, base: &str) -> TrueAffe cwd: root.to_path_buf(), base: base.to_string(), head: None, - root_ts_config: None, projects, - include: vec![], - ignored_paths: vec![], lockfile_strategy: LockfileStrategy::None, } }