From 449abd25571daca7ef581d363ba910c03621c1e0 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:59:59 +0900 Subject: [PATCH] fix: resolve `baseUrl` / `paths` relative to the real path of the tsconfig --- .../project/node_modules/.gitkeep | 0 .../cases/extends-symlink/project/src/foo.ts | 1 + .../extends-symlink/project/tsconfig.json | 1 + .../project/tsconfig.relative.json | 1 + .../extends-symlink/real-configs/base.json | 8 ++ .../tsconfig/cases/extends-symlink/src/foo.ts | 1 + src/cache/cache_impl.rs | 9 +- src/tests/tsconfig_extends.rs | 131 +++++++++++++++++- src/tsconfig.rs | 32 ++++- src/tsconfig_resolver.rs | 47 ++++--- 10 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 fixtures/tsconfig/cases/extends-symlink/project/node_modules/.gitkeep create mode 100644 fixtures/tsconfig/cases/extends-symlink/project/src/foo.ts create mode 100644 fixtures/tsconfig/cases/extends-symlink/project/tsconfig.json create mode 100644 fixtures/tsconfig/cases/extends-symlink/project/tsconfig.relative.json create mode 100644 fixtures/tsconfig/cases/extends-symlink/real-configs/base.json create mode 100644 fixtures/tsconfig/cases/extends-symlink/src/foo.ts diff --git a/fixtures/tsconfig/cases/extends-symlink/project/node_modules/.gitkeep b/fixtures/tsconfig/cases/extends-symlink/project/node_modules/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/fixtures/tsconfig/cases/extends-symlink/project/src/foo.ts b/fixtures/tsconfig/cases/extends-symlink/project/src/foo.ts new file mode 100644 index 00000000..d2c34a3c --- /dev/null +++ b/fixtures/tsconfig/cases/extends-symlink/project/src/foo.ts @@ -0,0 +1 @@ +export const foo = "wrong"; diff --git a/fixtures/tsconfig/cases/extends-symlink/project/tsconfig.json b/fixtures/tsconfig/cases/extends-symlink/project/tsconfig.json new file mode 100644 index 00000000..e4740735 --- /dev/null +++ b/fixtures/tsconfig/cases/extends-symlink/project/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "shared-config/base" } diff --git a/fixtures/tsconfig/cases/extends-symlink/project/tsconfig.relative.json b/fixtures/tsconfig/cases/extends-symlink/project/tsconfig.relative.json new file mode 100644 index 00000000..ee3de99c --- /dev/null +++ b/fixtures/tsconfig/cases/extends-symlink/project/tsconfig.relative.json @@ -0,0 +1 @@ +{ "extends": "./configs/base.json" } diff --git a/fixtures/tsconfig/cases/extends-symlink/real-configs/base.json b/fixtures/tsconfig/cases/extends-symlink/real-configs/base.json new file mode 100644 index 00000000..380f5561 --- /dev/null +++ b/fixtures/tsconfig/cases/extends-symlink/real-configs/base.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": "..", + "paths": { + "@app/*": ["src/*"] + } + } +} diff --git a/fixtures/tsconfig/cases/extends-symlink/src/foo.ts b/fixtures/tsconfig/cases/extends-symlink/src/foo.ts new file mode 100644 index 00000000..61d366eb --- /dev/null +++ b/fixtures/tsconfig/cases/extends-symlink/src/foo.ts @@ -0,0 +1 @@ +export const foo = "foo"; diff --git a/src/cache/cache_impl.rs b/src/cache/cache_impl.rs index 4e8b1605..fde007ac 100644 --- a/src/cache/cache_impl.rs +++ b/src/cache/cache_impl.rs @@ -233,10 +233,13 @@ impl Cache { ResolveError::from(err) } })?; + let tsconfig_real_path = self + .canonicalize(&self.value(&tsconfig_path)) + .unwrap_or_else(|_| tsconfig_path.to_path_buf()); let mut tsconfig = - TsConfig::parse(root, &tsconfig_path, tsconfig_string).map_err(|error| { - ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error) - })?; + TsConfig::parse(root, &tsconfig_path, &tsconfig_real_path, tsconfig_string).map_err( + |error| ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error), + )?; // Run callback (extends/references processing) callback(&mut tsconfig)?; diff --git a/src/tests/tsconfig_extends.rs b/src/tests/tsconfig_extends.rs index f02a454f..07c0ac78 100644 --- a/src/tests/tsconfig_extends.rs +++ b/src/tests/tsconfig_extends.rs @@ -3,7 +3,10 @@ //! Tests the `extend_tsconfig` method which is responsible for inheriting //! settings from one tsconfig into another. -use std::path::{Path, PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use crate::{ ResolveOptions, Resolver, TsConfig, TsconfigDiscovery, TsconfigOptions, TsconfigReferences, @@ -180,8 +183,9 @@ fn test_extend_tsconfig_no_override_existing() { }) .to_string(); - let parent_tsconfig = TsConfig::parse(true, parent_path, parent_config).unwrap().build(); - let mut child_tsconfig = TsConfig::parse(true, child_path, child_config).unwrap(); + let parent_tsconfig = + TsConfig::parse(true, parent_path, parent_path, parent_config).unwrap().build(); + let mut child_tsconfig = TsConfig::parse(true, child_path, child_path, child_config).unwrap(); // Perform the extension child_tsconfig.extend_tsconfig(&parent_tsconfig); @@ -365,3 +369,124 @@ fn test_extend_package() { assert_eq!(compiler_options.target, Some("ES2020".to_string())); } } + +/// Create a directory symlink, cleaning up any stale one from previous runs. +/// Returns `false` if symlinks are not supported on this platform. +#[cfg_attr(target_family = "wasm", allow(dead_code))] +fn create_dir_symlink(target: &Path, link: &Path) -> bool { + let _ = fs::remove_file(link); + let _ = fs::remove_dir_all(link); + + #[cfg(target_family = "unix")] + { + std::os::unix::fs::symlink(target, link).unwrap(); + true + } + #[cfg(target_os = "windows")] + { + std::os::windows::fs::symlink_dir(target, link).unwrap(); + true + } + #[cfg(target_family = "wasm")] + { + false + } +} + +/// Assert that `@app/foo` resolves to `extends-symlink/src/foo.ts` (via the real +/// base config path), not `extends-symlink/project/src/foo.ts` (via the symlink). +fn assert_symlink_extends_resolves_correctly(config_file: PathBuf, resolve_dir: &Path) { + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigDiscovery::Manual(TsconfigOptions { + config_file, + references: TsconfigReferences::Disabled, + })), + extensions: vec![".ts".into(), ".js".into()], + ..ResolveOptions::default() + }); + + let resolved_path = resolver + .resolve(resolve_dir, "@app/foo") + .expect("should resolve @app/foo via tsconfig paths") + .full_path(); + assert!( + resolved_path.ends_with("src/foo.ts"), + "expected path ending with src/foo.ts, got {resolved_path:?}" + ); + assert!( + !resolved_path.to_string_lossy().contains("project/src"), + "should resolve to root src/foo.ts, not project/src/foo.ts, got {resolved_path:?}" + ); +} + +/// When a tsconfig extends another via a symlinked package name (e.g. pnpm workspace), +/// `baseUrl` and `paths` should be resolved relative to the real (canonical) path +/// of the extended tsconfig, matching TypeScript's behavior. +#[test] +#[cfg_attr(target_family = "wasm", ignore)] +fn test_extend_tsconfig_via_symlink_package() { + let f = super::fixture_root().join("tsconfig/cases/extends-symlink"); + let symlink_path = f.join("project/node_modules/shared-config"); + let real_target = f.join("real-configs").canonicalize().unwrap(); + + if !create_dir_symlink(&real_target, &symlink_path) { + return; + } + + // extends: "shared-config/base" + assert_symlink_extends_resolves_correctly(f.join("project/tsconfig.json"), &f.join("project")); + + let _ = fs::remove_file(&symlink_path); + let _ = fs::remove_dir_all(&symlink_path); +} + +/// Same as above but with a relative `extends` path going through a symlinked directory. +#[test] +#[cfg_attr(target_family = "wasm", ignore)] +fn test_extend_tsconfig_via_symlink_relative() { + let f = super::fixture_root().join("tsconfig/cases/extends-symlink"); + let symlink_path = f.join("project/configs"); + let real_target = f.join("real-configs").canonicalize().unwrap(); + + if !create_dir_symlink(&real_target, &symlink_path) { + return; + } + + // extends: "./configs/base.json" + assert_symlink_extends_resolves_correctly( + f.join("project/tsconfig.relative.json"), + &f.join("project"), + ); + + let _ = fs::remove_file(&symlink_path); + let _ = fs::remove_dir_all(&symlink_path); +} + +/// Same as above but with an absolute `extends` path going through a symlinked directory. +#[test] +#[cfg_attr(target_family = "wasm", ignore)] +fn test_extend_tsconfig_via_symlink_absolute() { + let f = super::fixture_root().join("tsconfig/cases/extends-symlink"); + // Use a unique symlink name to avoid racing with the relative test + let symlink_path = f.join("project/configs-abs"); + let real_target = f.join("real-configs").canonicalize().unwrap(); + + if !create_dir_symlink(&real_target, &symlink_path) { + return; + } + + // Write a tsconfig with an absolute extends path at runtime (not portable for fixtures) + let absolute_tsconfig = f.join("project/tsconfig.absolute.json"); + let absolute_extends = symlink_path.join("base.json"); + fs::write( + &absolute_tsconfig, + format!(r#"{{ "extends": "{}" }}"#, absolute_extends.to_string_lossy().replace('\\', "/")), + ) + .unwrap(); + + assert_symlink_extends_resolves_correctly(absolute_tsconfig.clone(), &f.join("project")); + + let _ = fs::remove_file(&absolute_tsconfig); + let _ = fs::remove_file(&symlink_path); + let _ = fs::remove_dir_all(&symlink_path); +} diff --git a/src/tsconfig.rs b/src/tsconfig.rs index 1306e488..9ad34773 100644 --- a/src/tsconfig.rs +++ b/src/tsconfig.rs @@ -53,6 +53,14 @@ pub struct TsConfig { #[serde(skip)] pub path: PathBuf, + /// Canonical (real) path to `tsconfig.json`, with symlinks resolved. + /// + /// TypeScript computes `baseUrl` and `paths` relative to the real path of the + /// tsconfig, not the symlink location. TypeScript's `preserveSymlinks` option + /// only affects module resolution, not tsconfig `extends` resolution. + #[serde(skip)] + pub real_path: PathBuf, + #[serde(default)] pub files: Option>, @@ -84,7 +92,12 @@ impl TsConfig { /// # Errors /// /// * Any error that can be returned by `serde_json::from_str()`. - pub fn parse(root: bool, path: &Path, json: String) -> Result { + pub fn parse( + root: bool, + path: &Path, + real_path: &Path, + json: String, + ) -> Result { let mut json = json.into_bytes(); replace_bom_with_whitespace(&mut json); _ = json_strip_comments::strip_slice(&mut json); @@ -95,14 +108,16 @@ impl TsConfig { }; tsconfig.root = root; tsconfig.path = path.to_path_buf(); + tsconfig.real_path = real_path.to_path_buf(); + let real_directory = tsconfig.real_directory(); tsconfig.compiler_options.paths_base = tsconfig.compiler_options.base_url.as_ref().map_or_else( - || tsconfig.directory().to_path_buf(), + || real_directory.to_path_buf(), |base_url| { if base_url.to_string_lossy().starts_with(TEMPLATE_VARIABLE) { base_url.clone() } else { - tsconfig.directory().normalize_with(base_url) + real_directory.normalize_with(base_url) } }, ); @@ -146,6 +161,17 @@ impl TsConfig { self.path.parent().unwrap() } + /// Directory to `tsconfig.json`, with symlinks resolved. + /// + /// # Panics + /// + /// * When the `tsconfig.json` real path is misconfigured. + #[must_use] + pub fn real_directory(&self) -> &Path { + debug_assert!(self.real_path.file_name().is_some()); + self.real_path.parent().unwrap() + } + /// Returns any paths to tsconfigs that should be extended by this tsconfig. pub(crate) fn extends(&self) -> impl Iterator { let specifiers = match &self.extends { diff --git a/src/tsconfig_resolver.rs b/src/tsconfig_resolver.rs index ec0cd3f7..7c58cc50 100644 --- a/src/tsconfig_resolver.rs +++ b/src/tsconfig_resolver.rs @@ -11,19 +11,30 @@ use crate::{ #[derive(Default)] pub struct TsconfigResolveContext { + /// Original paths, used for error messages. extended_configs: Vec, + /// Real (canonical) paths, used for circular detection so that symlinked + /// paths are correctly identified as the same file. + extended_configs_real: Vec, } impl TsconfigResolveContext { - pub fn with_extended_file R>(&mut self, path: PathBuf, cb: T) -> R { + pub fn with_extended_file R>( + &mut self, + path: PathBuf, + real_path: PathBuf, + cb: T, + ) -> R { self.extended_configs.push(path); + self.extended_configs_real.push(real_path); let result = cb(self); self.extended_configs.pop(); + self.extended_configs_real.pop(); result } - pub fn is_already_extended(&self, path: &Path) -> bool { - self.extended_configs.iter().any(|config| config == path) + pub fn is_already_extended(&self, real_path: &Path) -> bool { + self.extended_configs_real.iter().any(|config| config == real_path) } pub fn get_extended_configs_with(&self, path: PathBuf) -> Vec { @@ -184,7 +195,7 @@ impl ResolverGeneric { let directory = self.cache.value(tsconfig.directory()); tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig"); - if ctx.is_already_extended(tsconfig.path()) { + if ctx.is_already_extended(&tsconfig.real_path) { return Err(ResolveError::TsconfigCircularExtend( ctx.get_extended_configs_with(tsconfig.path().to_path_buf()).into(), )); @@ -196,18 +207,22 @@ impl ResolverGeneric { .map(|specifier| self.get_extended_tsconfig_path(&directory, tsconfig, specifier)) .collect::, _>>()?; if !extended_tsconfig_paths.is_empty() { - ctx.with_extended_file(tsconfig.path().to_owned(), |ctx| { - for extended_tsconfig_path in extended_tsconfig_paths { - let extended_tsconfig = self.load_tsconfig( - /* root */ false, - &extended_tsconfig_path, - TsconfigReferences::Disabled, - ctx, - )?; - tsconfig.extend_tsconfig(&extended_tsconfig); - } - Result::Ok::<(), ResolveError>(()) - })?; + ctx.with_extended_file( + tsconfig.path().to_owned(), + tsconfig.real_path.clone(), + |ctx| { + for extended_tsconfig_path in extended_tsconfig_paths { + let extended_tsconfig = self.load_tsconfig( + /* root */ false, + &extended_tsconfig_path, + TsconfigReferences::Disabled, + ctx, + )?; + tsconfig.extend_tsconfig(&extended_tsconfig); + } + Result::Ok::<(), ResolveError>(()) + }, + )?; } if tsconfig.load_references(references) {