From 28e11ff3d95b02ef9240998fc81786564516c208 Mon Sep 17 00:00:00 2001 From: pshu Date: Wed, 13 May 2026 15:29:11 +0800 Subject: [PATCH 1/5] fix(tsconfig): honor transitive project references for paths resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the entry tsconfig declares `references: [B]` and B declares `references: [C]`, a file inside C should resolve via C's own `paths`, matching `tsc`'s "nearest tsconfig wins" semantics and webpack's recursive `references: "auto"` walk. Previously, rspack-resolver loaded only the directly-listed references (one level deep) and `TsConfig::resolve` only iterated those direct references. As a result, requests from a transitively-referenced project's directory would silently fall back to the entry tsconfig's `paths` and resolve incorrectly. Changes: - `load_tsconfig` extracts `load_references` which recursively loads nested project references. Existing self-reference detection (A → A) is preserved; cycle detection across multiple levels is intentionally out of scope for this change. - `TsConfig::resolve` recursively descends into nested references via `find_reference_paths`, returning the nearest reference whose `base_path` contains the requested path before falling back to the current tsconfig. --- .../app/aliased/index.ts | 1 + .../references-transitive/app/tsconfig.json | 11 +++ .../project_b/src/aliased/index.ts | 1 + .../project_b/tsconfig.json | 12 +++ .../project_c/aliased/index.ts | 1 + .../project_c/tsconfig.json | 9 +++ src/lib.rs | 73 ++++++++++++------- src/tests/tsconfig_project_references.rs | 44 +++++++++++ src/tsconfig.rs | 20 ++++- 9 files changed, 144 insertions(+), 28 deletions(-) create mode 100644 fixtures/tsconfig/cases/references-transitive/app/aliased/index.ts create mode 100644 fixtures/tsconfig/cases/references-transitive/app/tsconfig.json create mode 100644 fixtures/tsconfig/cases/references-transitive/project_b/src/aliased/index.ts create mode 100644 fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json create mode 100644 fixtures/tsconfig/cases/references-transitive/project_c/aliased/index.ts create mode 100644 fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json diff --git a/fixtures/tsconfig/cases/references-transitive/app/aliased/index.ts b/fixtures/tsconfig/cases/references-transitive/app/aliased/index.ts new file mode 100644 index 00000000..aec3f835 --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/app/aliased/index.ts @@ -0,0 +1 @@ +export const from = "app"; diff --git a/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json new file mode 100644 index 00000000..06a87050 --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["./aliased/*"] + } + }, + "references": [ + { "path": "../project_b" } + ] +} diff --git a/fixtures/tsconfig/cases/references-transitive/project_b/src/aliased/index.ts b/fixtures/tsconfig/cases/references-transitive/project_b/src/aliased/index.ts new file mode 100644 index 00000000..2f74543f --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/project_b/src/aliased/index.ts @@ -0,0 +1 @@ +export const from = "project_b"; diff --git a/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json new file mode 100644 index 00000000..db197ddd --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./src", + "paths": { + "@/*": ["./aliased/*"] + } + }, + "references": [ + { "path": "../project_c" } + ] +} diff --git a/fixtures/tsconfig/cases/references-transitive/project_c/aliased/index.ts b/fixtures/tsconfig/cases/references-transitive/project_c/aliased/index.ts new file mode 100644 index 00000000..9aed6fb9 --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/project_c/aliased/index.ts @@ -0,0 +1 @@ +export const from = "project_c"; diff --git a/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json new file mode 100644 index 00000000..85b91efa --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./", + "paths": { + "@/*": ["./aliased/*"] + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 707a1f27..50184b80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1521,31 +1521,7 @@ impl ResolverGeneric { .collect(); } } - if !tsconfig.references.is_empty() { - let directory = tsconfig.directory().to_path_buf(); - for reference in &mut tsconfig.references { - let reference_tsconfig_path = directory.normalize_with(&reference.path); - let reference_tsconfig = self - .cache - .tsconfig( - /* root */ true, - &reference_tsconfig_path, - |reference_tsconfig| async { - if reference_tsconfig.path == tsconfig.path { - return Err(ResolveError::TsconfigSelfReference( - reference_tsconfig.path.clone(), - )); - } - Ok(reference_tsconfig) - }, - ) - .await?; - tsconfig - .file_dependencies - .extend(reference_tsconfig.file_dependencies.iter().cloned()); - reference.tsconfig.replace(reference_tsconfig); - } - } + self.load_references(&mut tsconfig).await?; Ok(tsconfig) }) @@ -1554,6 +1530,53 @@ impl ResolverGeneric { Box::pin(fut) } + // Walks `tsconfig.references` and loads each referenced tsconfig, recursing + // into nested references so that transitive `paths` (e.g. A → B → C) are + // honored — matching `tsc`'s "nearest tsconfig wins" behavior and + // `enhanced-resolve`'s recursive `references: "auto"` walk. + // + // The caller is expected to provide a DAG; cycle detection is intentionally + // not implemented here. + fn load_references<'a>( + &'a self, + tsconfig: &'a mut TsConfig, + ) -> BoxFuture<'a, Result<(), ResolveError>> { + Box::pin(async move { + if tsconfig.references.is_empty() { + return Ok(()); + } + let directory = tsconfig.directory().to_path_buf(); + let current_path = tsconfig.path.clone(); + for reference in &mut tsconfig.references { + let reference_tsconfig_path = directory.normalize_with(&reference.path); + let reference_tsconfig = self + .cache + .tsconfig( + /* root */ true, + &reference_tsconfig_path, + |mut reference_tsconfig| { + let current_path = current_path.clone(); + async move { + if reference_tsconfig.path == current_path { + return Err(ResolveError::TsconfigSelfReference( + reference_tsconfig.path.clone(), + )); + } + self.load_references(&mut reference_tsconfig).await?; + Ok(reference_tsconfig) + } + }, + ) + .await?; + tsconfig + .file_dependencies + .extend(reference_tsconfig.file_dependencies.iter().cloned()); + reference.tsconfig.replace(reference_tsconfig); + } + Ok(()) + }) + } + async fn get_extended_tsconfig_path( &self, directory: &CachedPath, diff --git a/src/tests/tsconfig_project_references.rs b/src/tests/tsconfig_project_references.rs index 44455729..bda0d116 100644 --- a/src/tests/tsconfig_project_references.rs +++ b/src/tests/tsconfig_project_references.rs @@ -184,3 +184,47 @@ async fn self_reference() { ); } } + +// Transitive project references: A → B → C. +// When the entry tsconfig (A) declares `references: [B]` and B declares +// `references: [C]`, a file inside C must resolve via C's own `paths` +// (matching tsc's "nearest tsconfig wins" behavior and webpack's +// recursive `references: "auto"` walk). +#[tokio::test] +async fn transitive_references() { + let f = super::fixture_root().join("tsconfig/cases/references-transitive"); + + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigOptions { + config_file: f.join("app"), + references: TsconfigReferences::Auto, + }), + ..ResolveOptions::default() + }); + + let cases = [ + // Direct: file in app uses app's paths. + (f.join("app"), "@/index.ts", f.join("app/aliased/index.ts")), + // One level: file in project_b uses project_b's paths (baseUrl ./src). + ( + f.join("project_b/src"), + "@/index.ts", + f.join("project_b/src/aliased/index.ts"), + ), + // Two levels: file in project_c (referenced by project_b which is + // referenced by app) uses project_c's paths. + ( + f.join("project_c"), + "@/index.ts", + f.join("project_c/aliased/index.ts"), + ), + ]; + + for (path, request, expected) in cases { + let resolved_path = resolver + .resolve(&path, request) + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(expected), "{request} from {path:?}"); + } +} diff --git a/src/tsconfig.rs b/src/tsconfig.rs index 7286790f..a803a5af 100644 --- a/src/tsconfig.rs +++ b/src/tsconfig.rs @@ -170,17 +170,31 @@ impl TsConfig { } pub fn resolve(&self, path: &Path, specifier: &str) -> Vec { + if let Some(matched) = self.find_reference_paths(path, specifier) { + return matched; + } + self.resolve_path_alias(specifier) + } + + // Walks `references` recursively, returning the nearest reference whose + // `base_path` contains `path`. Used to honor transitive project references + // (A → B → C): a file inside C should resolve via C's own `paths` even + // when the entry tsconfig is A and only B is listed directly in A's + // references. Matches `tsc`'s "nearest tsconfig wins" semantics. + fn find_reference_paths(&self, path: &Path, specifier: &str) -> Option> { for tsconfig in self .references .iter() .filter_map(|reference| reference.tsconfig.as_ref()) { + if let Some(nested) = tsconfig.find_reference_paths(path, specifier) { + return Some(nested); + } if path.starts_with(tsconfig.base_path()) { - return tsconfig.resolve_path_alias(specifier); + return Some(tsconfig.resolve_path_alias(specifier)); } } - - self.resolve_path_alias(specifier) + None } // Copied from parcel From 415504a1198a5ac5880db3574481de4d2a2a5b63 Mon Sep 17 00:00:00 2001 From: pshu Date: Wed, 13 May 2026 15:34:14 +0800 Subject: [PATCH 2/5] style: format fixture tsconfig.json with prettier --- .../tsconfig/cases/references-transitive/app/tsconfig.json | 4 +--- .../cases/references-transitive/project_b/tsconfig.json | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json index 06a87050..5f3ab415 100644 --- a/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json +++ b/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json @@ -5,7 +5,5 @@ "@/*": ["./aliased/*"] } }, - "references": [ - { "path": "../project_b" } - ] + "references": [{ "path": "../project_b" }] } diff --git a/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json index db197ddd..4a964b85 100644 --- a/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json +++ b/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json @@ -6,7 +6,5 @@ "@/*": ["./aliased/*"] } }, - "references": [ - { "path": "../project_c" } - ] + "references": [{ "path": "../project_c" }] } From 7cfc5ac97a213f7bef4a55c9864b6c24450bf8f6 Mon Sep 17 00:00:00 2001 From: pshu Date: Wed, 13 May 2026 16:04:53 +0800 Subject: [PATCH 3/5] fix(tsconfig): apply `extends` to project references before recursing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a project reference uses `extends` to inherit its `baseUrl`/`paths` from a shared base config, those fields must be merged before the reference is consulted for resolution. The previous flow loaded each reference through `cache.tsconfig` and only ran the extends-merging logic on the entry tsconfig, so a transitively-referenced project whose aliases live in an extended base file would resolve as if it had no aliases at all. This was a pre-existing latent bug at depth 1 — direct references with `extends` did not inherit their aliases either — that the new transitive-references support made easier to hit. Changes: - Extract `merge_tsconfig_extends` from `load_tsconfig` and call it from `load_references`' inner callback after the self-reference check, before recursing into nested references. - Update the `references-transitive` fixture: `project_c` now reads its `paths` from a sibling `tsconfig.base.json` via `extends`. The existing `transitive_references` test fails before this fix (NotFound) and passes after. Detected via Codex review of #213. --- .../project_c/tsconfig.json | 6 +- .../references-transitive/tsconfig.base.json | 7 ++ src/lib.rs | 76 ++++++++++++------- 3 files changed, 56 insertions(+), 33 deletions(-) create mode 100644 fixtures/tsconfig/cases/references-transitive/tsconfig.base.json diff --git a/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json index 85b91efa..d5c79cfc 100644 --- a/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json +++ b/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json @@ -1,9 +1,7 @@ { + "extends": "../tsconfig.base.json", "compilerOptions": { "composite": true, - "baseUrl": "./", - "paths": { - "@/*": ["./aliased/*"] - } + "baseUrl": "./" } } diff --git a/fixtures/tsconfig/cases/references-transitive/tsconfig.base.json b/fixtures/tsconfig/cases/references-transitive/tsconfig.base.json new file mode 100644 index 00000000..7426947c --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/tsconfig.base.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./aliased/*"] + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 50184b80..3af074b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1475,35 +1475,9 @@ impl ResolverGeneric { tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig"); // Extend tsconfig - if let Some(extends) = &tsconfig.extends { - let extended_tsconfig_paths = match extends { - ExtendsField::Single(s) => { - vec![ - self - .get_extended_tsconfig_path(&directory, &tsconfig, s) - .await?, - ] - } - ExtendsField::Multiple(specifiers) => { - try_join_all( - specifiers - .iter() - .map(|s| self.get_extended_tsconfig_path(&directory, &tsconfig, s)), - ) - .await? - } - }; - for extended_tsconfig_path in extended_tsconfig_paths { - let extended_tsconfig = self - .load_tsconfig( - /* root */ false, - &extended_tsconfig_path, - &TsconfigReferences::Disabled, - ) - .await?; - tsconfig.extend_tsconfig(&extended_tsconfig); - } - } + self + .merge_tsconfig_extends(&mut tsconfig, &directory) + .await?; // Load project references match references { @@ -1562,6 +1536,12 @@ impl ResolverGeneric { reference_tsconfig.path.clone(), )); } + // Apply `extends` so the reference inherits its base config's + // `baseUrl`/`paths` before its own references are walked. + let directory = self.cache.value(reference_tsconfig.directory()); + self + .merge_tsconfig_extends(&mut reference_tsconfig, &directory) + .await?; self.load_references(&mut reference_tsconfig).await?; Ok(reference_tsconfig) } @@ -1577,6 +1557,44 @@ impl ResolverGeneric { }) } + async fn merge_tsconfig_extends( + &self, + tsconfig: &mut TsConfig, + directory: &CachedPath, + ) -> Result<(), ResolveError> { + let Some(extends) = &tsconfig.extends else { + return Ok(()); + }; + let extended_tsconfig_paths = match extends { + ExtendsField::Single(s) => { + vec![ + self + .get_extended_tsconfig_path(directory, tsconfig, s) + .await?, + ] + } + ExtendsField::Multiple(specifiers) => { + try_join_all( + specifiers + .iter() + .map(|s| self.get_extended_tsconfig_path(directory, tsconfig, s)), + ) + .await? + } + }; + for extended_tsconfig_path in extended_tsconfig_paths { + let extended_tsconfig = self + .load_tsconfig( + /* root */ false, + &extended_tsconfig_path, + &TsconfigReferences::Disabled, + ) + .await?; + tsconfig.extend_tsconfig(&extended_tsconfig); + } + Ok(()) + } + async fn get_extended_tsconfig_path( &self, directory: &CachedPath, From e84cffb00e1767a6e3f7001ffe761e4e6e3defcc Mon Sep 17 00:00:00 2001 From: pshu Date: Wed, 13 May 2026 16:21:18 +0800 Subject: [PATCH 4/5] test(tsconfig): add focused test for references with extends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated test (`references_with_extends`) and fixture (`references-extends/`) that exercises the fix from the previous commit in isolation: a single-level reference whose `baseUrl`/`paths` are inherited via `extends "../tsconfig.base.json"`. Without `merge_tsconfig_extends` being applied to the reference, the test fails with `NotFound` — confirming the test would have caught the latent extends-on-reference bug. Also revert the `references-transitive` fixture so it focuses purely on multi-level walking; the extends-merging behavior is now covered by the new fixture and test. --- .../references-extends/app/aliased/index.ts | 1 + .../references-extends/app/tsconfig.json | 9 +++++ .../project_b/src/aliased/index.ts | 1 + .../project_b/tsconfig.json | 7 ++++ .../tsconfig.base.json | 0 .../project_c/tsconfig.json | 6 ++-- src/tests/tsconfig_project_references.rs | 33 +++++++++++++++++++ 7 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 fixtures/tsconfig/cases/references-extends/app/aliased/index.ts create mode 100644 fixtures/tsconfig/cases/references-extends/app/tsconfig.json create mode 100644 fixtures/tsconfig/cases/references-extends/project_b/src/aliased/index.ts create mode 100644 fixtures/tsconfig/cases/references-extends/project_b/tsconfig.json rename fixtures/tsconfig/cases/{references-transitive => references-extends}/tsconfig.base.json (100%) diff --git a/fixtures/tsconfig/cases/references-extends/app/aliased/index.ts b/fixtures/tsconfig/cases/references-extends/app/aliased/index.ts new file mode 100644 index 00000000..aec3f835 --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/app/aliased/index.ts @@ -0,0 +1 @@ +export const from = "app"; diff --git a/fixtures/tsconfig/cases/references-extends/app/tsconfig.json b/fixtures/tsconfig/cases/references-extends/app/tsconfig.json new file mode 100644 index 00000000..5f3ab415 --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/app/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["./aliased/*"] + } + }, + "references": [{ "path": "../project_b" }] +} diff --git a/fixtures/tsconfig/cases/references-extends/project_b/src/aliased/index.ts b/fixtures/tsconfig/cases/references-extends/project_b/src/aliased/index.ts new file mode 100644 index 00000000..9bd27886 --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/project_b/src/aliased/index.ts @@ -0,0 +1 @@ +export const from = "project_b via extends"; diff --git a/fixtures/tsconfig/cases/references-extends/project_b/tsconfig.json b/fixtures/tsconfig/cases/references-extends/project_b/tsconfig.json new file mode 100644 index 00000000..15e488c9 --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/project_b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "baseUrl": "./src" + } +} diff --git a/fixtures/tsconfig/cases/references-transitive/tsconfig.base.json b/fixtures/tsconfig/cases/references-extends/tsconfig.base.json similarity index 100% rename from fixtures/tsconfig/cases/references-transitive/tsconfig.base.json rename to fixtures/tsconfig/cases/references-extends/tsconfig.base.json diff --git a/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json index d5c79cfc..85b91efa 100644 --- a/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json +++ b/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json @@ -1,7 +1,9 @@ { - "extends": "../tsconfig.base.json", "compilerOptions": { "composite": true, - "baseUrl": "./" + "baseUrl": "./", + "paths": { + "@/*": ["./aliased/*"] + } } } diff --git a/src/tests/tsconfig_project_references.rs b/src/tests/tsconfig_project_references.rs index bda0d116..892a1b68 100644 --- a/src/tests/tsconfig_project_references.rs +++ b/src/tests/tsconfig_project_references.rs @@ -228,3 +228,36 @@ async fn transitive_references() { assert_eq!(resolved_path, Ok(expected), "{request} from {path:?}"); } } + +// When a project reference uses `extends` to inherit its `baseUrl`/`paths` +// from a shared base config, those fields must be merged before the +// reference is consulted for resolution. Without merging `extends` on +// referenced configs, a request from inside `project_b` would see no +// alias candidates and fail to resolve. +#[tokio::test] +async fn references_with_extends() { + let f = super::fixture_root().join("tsconfig/cases/references-extends"); + + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigOptions { + config_file: f.join("app"), + references: TsconfigReferences::Auto, + }), + ..ResolveOptions::default() + }); + + // From project_b's directory, the inherited `paths` from + // ../tsconfig.base.json must apply (baseUrl ./src). + let resolved_path = resolver + .resolve(&f.join("project_b/src"), "@/index.ts") + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(f.join("project_b/src/aliased/index.ts"))); + + // The entry tsconfig still uses its own `paths`. + let resolved_path = resolver + .resolve(&f.join("app"), "@/index.ts") + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(f.join("app/aliased/index.ts"))); +} From 23b89541d4ee139ac9e998f999ea96a0ab0cdb5a Mon Sep 17 00:00:00 2001 From: pshu Date: Wed, 13 May 2026 16:41:32 +0800 Subject: [PATCH 5/5] fix(tsconfig): detect cycles when walking project references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pair of project references that point at each other (a → b → a) previously caused infinite recursion when `references: "auto"` recursively loaded the graph, blowing the test thread's stack. Add a `visited` set threaded through `load_references`. Each tsconfig inserts its own path before walking its references. When a candidate reference's loaded path is already in the chain, the cycle edge is cut: the reference is still attached so its own `paths` are honored, but its nested references are not walked. Includes a `references-cycle` fixture (a ↔ b) and a `cyclic_references` test that asserts resolution succeeds from both sides without stack overflow. Direct self-reference (A → A) detection via the existing equality check inside the cache callback is preserved. --- .../cases/references-cycle/a/src/index.ts | 1 + .../cases/references-cycle/a/tsconfig.json | 10 +++++++ .../cases/references-cycle/b/src/index.ts | 1 + .../cases/references-cycle/b/tsconfig.json | 10 +++++++ src/lib.rs | 25 +++++++++++++--- src/tests/tsconfig_project_references.rs | 30 +++++++++++++++++++ 6 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 fixtures/tsconfig/cases/references-cycle/a/src/index.ts create mode 100644 fixtures/tsconfig/cases/references-cycle/a/tsconfig.json create mode 100644 fixtures/tsconfig/cases/references-cycle/b/src/index.ts create mode 100644 fixtures/tsconfig/cases/references-cycle/b/tsconfig.json diff --git a/fixtures/tsconfig/cases/references-cycle/a/src/index.ts b/fixtures/tsconfig/cases/references-cycle/a/src/index.ts new file mode 100644 index 00000000..bd571ae0 --- /dev/null +++ b/fixtures/tsconfig/cases/references-cycle/a/src/index.ts @@ -0,0 +1 @@ +export const from = "a"; diff --git a/fixtures/tsconfig/cases/references-cycle/a/tsconfig.json b/fixtures/tsconfig/cases/references-cycle/a/tsconfig.json new file mode 100644 index 00000000..2cab053f --- /dev/null +++ b/fixtures/tsconfig/cases/references-cycle/a/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./", + "paths": { + "@a/*": ["src/*"] + } + }, + "references": [{ "path": "../b" }] +} diff --git a/fixtures/tsconfig/cases/references-cycle/b/src/index.ts b/fixtures/tsconfig/cases/references-cycle/b/src/index.ts new file mode 100644 index 00000000..fc37097d --- /dev/null +++ b/fixtures/tsconfig/cases/references-cycle/b/src/index.ts @@ -0,0 +1 @@ +export const from = "b"; diff --git a/fixtures/tsconfig/cases/references-cycle/b/tsconfig.json b/fixtures/tsconfig/cases/references-cycle/b/tsconfig.json new file mode 100644 index 00000000..26cc84a1 --- /dev/null +++ b/fixtures/tsconfig/cases/references-cycle/b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./", + "paths": { + "@b/*": ["src/*"] + } + }, + "references": [{ "path": "../a" }] +} diff --git a/src/lib.rs b/src/lib.rs index 3af074b1..f5296379 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1495,7 +1495,8 @@ impl ResolverGeneric { .collect(); } } - self.load_references(&mut tsconfig).await?; + let mut visited = FxHashSet::default(); + self.load_references(&mut tsconfig, &mut visited).await?; Ok(tsconfig) }) @@ -1509,11 +1510,14 @@ impl ResolverGeneric { // honored — matching `tsc`'s "nearest tsconfig wins" behavior and // `enhanced-resolve`'s recursive `references: "auto"` walk. // - // The caller is expected to provide a DAG; cycle detection is intentionally - // not implemented here. + // `visited` carries the canonical paths of tsconfigs already being loaded + // along the current chain. When a reference points back into the chain, + // the cycle edge is cut: the reference is still attached with its own + // `paths` honored, but its nested references are not walked. fn load_references<'a>( &'a self, tsconfig: &'a mut TsConfig, + visited: &'a mut FxHashSet, ) -> BoxFuture<'a, Result<(), ResolveError>> { Box::pin(async move { if tsconfig.references.is_empty() { @@ -1521,8 +1525,13 @@ impl ResolverGeneric { } let directory = tsconfig.directory().to_path_buf(); let current_path = tsconfig.path.clone(); + visited.insert(current_path.clone()); for reference in &mut tsconfig.references { let reference_tsconfig_path = directory.normalize_with(&reference.path); + // Reborrow so the closure below can capture `&mut FxHashSet` across + // multiple iterations of this loop. Without the reborrow, the first + // iteration would consume the original `&mut visited` binding. + let visited: &mut FxHashSet = &mut *visited; let reference_tsconfig = self .cache .tsconfig( @@ -1536,13 +1545,21 @@ impl ResolverGeneric { reference_tsconfig.path.clone(), )); } + // Cut the cycle: if this reference is already part of the + // ongoing load chain, return it without walking its own + // references. Its `paths` are still attached to the parent. + if visited.contains(&reference_tsconfig.path) { + return Ok(reference_tsconfig); + } // Apply `extends` so the reference inherits its base config's // `baseUrl`/`paths` before its own references are walked. let directory = self.cache.value(reference_tsconfig.directory()); self .merge_tsconfig_extends(&mut reference_tsconfig, &directory) .await?; - self.load_references(&mut reference_tsconfig).await?; + self + .load_references(&mut reference_tsconfig, visited) + .await?; Ok(reference_tsconfig) } }, diff --git a/src/tests/tsconfig_project_references.rs b/src/tests/tsconfig_project_references.rs index 892a1b68..9571b892 100644 --- a/src/tests/tsconfig_project_references.rs +++ b/src/tests/tsconfig_project_references.rs @@ -261,3 +261,33 @@ async fn references_with_extends() { .map(|p| p.full_path()); assert_eq!(resolved_path, Ok(f.join("app/aliased/index.ts"))); } + +// A pair of project references that form a cycle (a → b → a) must not +// cause infinite recursion / stack overflow when `references: "auto"` +// recursively walks the graph. Each project's own `paths` should still +// be honored from within its own directory. +#[tokio::test] +async fn cyclic_references() { + let f = super::fixture_root().join("tsconfig/cases/references-cycle"); + + let resolver = Resolver::new(ResolveOptions { + extensions: vec![".ts".into()], + tsconfig: Some(TsconfigOptions { + config_file: f.join("a"), + references: TsconfigReferences::Auto, + }), + ..ResolveOptions::default() + }); + + let resolved_path = resolver + .resolve(&f.join("a"), "@a/index") + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(f.join("a/src/index.ts"))); + + let resolved_path = resolver + .resolve(&f.join("b"), "@b/index") + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(f.join("b/src/index.ts"))); +}