From 4a5551d4b61268224fa52b1f4c582cc67cbe8ac5 Mon Sep 17 00:00:00 2001 From: sleitor Date: Wed, 25 Feb 2026 18:36:56 +0000 Subject: [PATCH 1/6] fix(boundaries): resolve package-name-shaped tsconfig path aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a tsconfig `paths` entry maps a bare specifier that looks like a package name (e.g. `features/feature-a` → `./src/features/feature-a`) `turbo boundaries` incorrectly flagged the import as an undeclared dependency. The root cause was an early-return guard in `check_import_as_tsconfig_path_alias` that skipped any import matching `is_potential_package_name`. This prevented the resolver from even attempting to resolve such imports as tsconfig aliases, causing them to fall through to `check_package_import` which then raised a violation. Fix: remove the `is_potential_package_name` guard so that all non-relative imports are tried against the resolver. If the import resolves to a local file the function returns `true` (no further checks); if it cannot be resolved it returns `false` and the call site falls through to `check_package_import` as before. Adds a regression test (`tsconfig_alias_resolves_package_name_shaped_path_alias`) that reproduces the exact setup reported in the issue. Fixes #11906 --- crates/turborepo-boundaries/src/imports.rs | 94 +++++++++++++++++++--- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/crates/turborepo-boundaries/src/imports.rs b/crates/turborepo-boundaries/src/imports.rs index a3636c98a8bf3..ed401457aba60 100644 --- a/crates/turborepo-boundaries/src/imports.rs +++ b/crates/turborepo-boundaries/src/imports.rs @@ -82,12 +82,16 @@ impl<'a> DependencyLocations<'a> { } /// Checks if the given import can be resolved as a tsconfig path alias via the -/// resolver, e.g. `@/types/foo` -> `./src/foo`, and if so, checks the resolved -/// path against package boundaries. +/// resolver, e.g. `@/types/foo` -> `./src/foo` or `features/foo` -> `./src/features/foo`, +/// and if so, checks the resolved path against package boundaries. /// -/// Only attempts resolution for imports that are not relative paths and not -/// bare package names, so that bare specifiers like `react` still go through -/// `check_package_import` for dependency declaration validation. +/// Attempts resolution for all non-relative imports, including those that look +/// like bare package names (e.g. `features/feature-a`). This allows tsconfig +/// `paths` entries that shadow package-name-shaped specifiers to be recognised as +/// local imports instead of being incorrectly flagged as undeclared dependencies. +/// If the resolver cannot resolve the import (no matching tsconfig alias and no +/// file on disk), `Ok(false)` is returned and the caller falls through to +/// `check_package_import`. /// /// Returns true if the import was resolved as a tsconfig path alias. #[allow(clippy::too_many_arguments)] @@ -101,9 +105,11 @@ fn check_import_as_tsconfig_path_alias( import: &str, result: &mut BoundariesResult, ) -> Result { - // Skip relative imports and bare package names — those are handled elsewhere. - // We only want to resolve tsconfig path aliases here. - if import.starts_with('.') || BoundariesChecker::is_potential_package_name(import) { + // Skip relative imports — those are always resolved as file imports elsewhere. + // Package-name-shaped imports are intentionally tried here so that tsconfig + // `paths` aliases (e.g. `features/*` → `./src/features/*`) are resolved before + // we check them against declared package dependencies. + if import.starts_with('.') { return Ok(false); } @@ -372,6 +378,9 @@ mod test { (resolver, package_name, span, file_content, result) } + // Package-name-shaped imports that have no matching tsconfig alias and no + // corresponding file on disk should still return `false` so that the caller + // can fall through to `check_package_import`. #[test_case("react" ; "bare package name")] #[test_case("lodash" ; "bare package name lodash")] #[test_case("@scope/package" ; "scoped package name")] @@ -379,7 +388,7 @@ mod test { #[test_case("lodash/fp" ; "subpath import")] #[test_case("@scope/package/sub" ; "scoped subpath import")] #[test_case("@scope/package/deeply/nested" ; "scoped deeply nested subpath import")] - fn tsconfig_alias_check_skips_bare_package_names(import: &str) { + fn tsconfig_alias_check_returns_false_for_unresolvable_package_imports(import: &str) { let (resolver, package_name, span, file_content, mut result) = make_tsconfig_alias_test_args(import); let tmp = tempfile::tempdir().unwrap(); @@ -401,7 +410,7 @@ mod test { assert!( !resolved, - "bare package name {import:?} should not be resolved as tsconfig alias" + "package import {import:?} with no tsconfig alias should not be resolved" ); assert!(result.diagnostics.is_empty()); } @@ -483,4 +492,69 @@ mod test { "@/utils/helper should be resolved as a tsconfig path alias" ); } + + /// Regression test: an import whose specifier looks like a bare package + /// name (e.g. `features/feature-a`) but is actually a tsconfig `paths` + /// alias pointing to a local source file must be resolved as a tsconfig + /// alias (returning `true`) rather than being incorrectly forwarded to + /// `check_package_import` and flagged as an undeclared dependency. + /// + /// See: + #[test] + fn tsconfig_alias_resolves_package_name_shaped_path_alias() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Mimic a tsconfig that maps `*` to `./src/*`, turning bare specifiers + // like `features/feature-a` into local imports. + let tsconfig = root.join("tsconfig.json"); + std::fs::write( + &tsconfig, + r#"{ "compilerOptions": { "paths": { "*": ["./src/*"] } } }"#, + ) + .unwrap(); + + // Create the target file the alias should resolve to + std::fs::create_dir_all(root.join("src").join("features")).unwrap(); + std::fs::write( + root.join("src").join("features").join("feature-a.ts"), + "export const featureA = true;", + ) + .unwrap(); + + // Create the source file that imports via the alias + let file_content = "import { featureA } from \"features/feature-a\";"; + std::fs::write(root.join("index.ts"), file_content).unwrap(); + + let package_root = AbsoluteSystemPath::new(root.to_str().unwrap()).unwrap(); + let tsconfig_path = AbsoluteSystemPath::new(tsconfig.to_str().unwrap()).unwrap(); + let file_path = package_root.join_component("index.ts"); + let package_name = PackageName::from("test-pkg"); + let span = SourceSpan::new(0.into(), 0); + let mut result = BoundariesResult::default(); + + let resolver = Tracer::create_resolver(Some(tsconfig_path)); + + let resolved = check_import_as_tsconfig_path_alias( + &resolver, + &package_name, + package_root, + span, + &file_path, + file_content, + "features/feature-a", + &mut result, + ) + .unwrap(); + + assert!( + resolved, + "features/feature-a with a tsconfig `*` alias should be resolved as a local import" + ); + // The resolved path is inside the package root, so no boundary violation + assert!( + result.diagnostics.is_empty(), + "expected no boundary violations for a locally-aliased import" + ); + } } From 83be5c2d9fc728341c566792179eea6545e66df0 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Thu, 26 Feb 2026 14:08:36 +0000 Subject: [PATCH 2/6] fix: skip node_modules resolutions in tsconfig alias check When check_import_as_tsconfig_path_alias resolves an import to a path inside node_modules, return false so the caller falls through to check_package_import for proper dependency validation. --- crates/turborepo-boundaries/src/imports.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/turborepo-boundaries/src/imports.rs b/crates/turborepo-boundaries/src/imports.rs index ed401457aba60..e1f00beb2d01b 100644 --- a/crates/turborepo-boundaries/src/imports.rs +++ b/crates/turborepo-boundaries/src/imports.rs @@ -118,6 +118,12 @@ fn check_import_as_tsconfig_path_alias( match resolver.resolve(dir, import) { Ok(resolution) => { let path = resolution.path(); + // If the import resolved to a path inside node_modules, it is a + // real package import — not a tsconfig alias. Return false so the + // caller falls through to `check_package_import`. + if path.components().any(|c| c.as_os_str() == "node_modules") { + return Ok(false); + } let Some(utf8_path) = Utf8Path::from_path(path) else { result.diagnostics.push(BoundariesDiagnostic::InvalidPath { path: path.to_string_lossy().to_string(), From a94be964f423897b4d621d99f09fc90396685ab8 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Wed, 4 Mar 2026 17:23:38 +0000 Subject: [PATCH 3/6] fix(boundaries): use resolution.package_json() for package detection; guard tsconfig check with is_potential_package_name - Replace node_modules path component check with resolution.package_json().is_some() per anthonyshew's suggestion: if the resolver found a package.json the resolution went through node_modules (a real npm package), not a tsconfig path alias. More robust than string path component matching. - Move check_import_as_tsconfig_path_alias inside the is_potential_package_name guard to restore O(1) filtering for non-package-name-shaped imports (node:fs, bun:test, etc.). Previously the resolver was invoked for every bare import which regressed performance. Now the resolver is only called for package-name-shaped imports. --- crates/turborepo-boundaries/src/imports.rs | 57 ++++++++++++---------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/crates/turborepo-boundaries/src/imports.rs b/crates/turborepo-boundaries/src/imports.rs index 30d9a97a37a48..8dc84eb997509 100644 --- a/crates/turborepo-boundaries/src/imports.rs +++ b/crates/turborepo-boundaries/src/imports.rs @@ -83,15 +83,15 @@ impl<'a> DependencyLocations<'a> { /// resolver, e.g. `@/types/foo` -> `./src/foo` or `features/foo` -> `./src/features/foo`, /// and if so, checks the resolved path against package boundaries. /// -/// Attempts resolution for all non-relative imports, including those that look -/// like bare package names (e.g. `features/feature-a`). This allows tsconfig -/// `paths` entries that shadow package-name-shaped specifiers to be recognised as -/// local imports instead of being incorrectly flagged as undeclared dependencies. -/// If the resolver cannot resolve the import (no matching tsconfig alias and no -/// file on disk), `Ok(false)` is returned and the caller falls through to -/// `check_package_import`. +/// Only called for package-name-shaped imports (after `is_potential_package_name` guard +/// in `check_import`). This allows tsconfig `paths` entries that shadow package-name-shaped +/// specifiers to be recognised as local imports instead of being incorrectly flagged as +/// undeclared dependencies. /// -/// Returns true if the import was resolved as a tsconfig path alias. +/// Returns `Ok(true)` if the import was resolved as a local tsconfig path alias. +/// Returns `Ok(false)` if the resolver found a real npm package (has a package.json), +/// could not resolve the import, or the import is relative — the caller should then +/// fall through to `check_package_import`. #[allow(clippy::too_many_arguments)] fn check_import_as_tsconfig_path_alias( resolver: &Resolver, @@ -115,13 +115,14 @@ fn check_import_as_tsconfig_path_alias( match resolver.resolve(dir, import) { Ok(resolution) => { - let path = resolution.path(); - // If the import resolved to a path inside node_modules, it is a - // real package import — not a tsconfig alias. Return false so the - // caller falls through to `check_package_import`. - if path.components().any(|c| c.as_os_str() == "node_modules") { + // If the resolver found a package.json the resolution went through + // node_modules (a real npm package), not a tsconfig path alias pointing + // to a local file. Return false so the caller falls through to + // `check_package_import`. + if resolution.package_json().is_some() { return Ok(false); } + let path = resolution.path(); let Some(utf8_path) = Utf8Path::from_path(path) else { result.diagnostics.push(BoundariesDiagnostic::InvalidPath { path: path.to_string_lossy().to_string(), @@ -189,19 +190,6 @@ pub(crate) fn check_import( let span = SourceSpan::new(start.into(), end - start); - if check_import_as_tsconfig_path_alias( - resolver, - package_name, - package_root, - span, - file_path, - file_content, - import, - result, - )? { - return Ok(()); - } - // We have a file import let check_result = if import.starts_with(".") { let import_path = RelativeUnixPath::new(import)?; @@ -219,6 +207,23 @@ pub(crate) fn check_import( file_content, )? } else if BoundariesChecker::is_potential_package_name(import) { + // For package-name-shaped imports, first check whether the import resolves + // as a tsconfig path alias (e.g. `features/*` → `./src/features/*`). + // If so, validate the resolved path against package boundaries. + // Only if the resolver does not find a tsconfig alias do we fall through + // to the ordinary dependency-declaration check. + if check_import_as_tsconfig_path_alias( + resolver, + package_name, + package_root, + span, + file_path, + file_content, + import, + result, + )? { + return Ok(()); + } check_package_import( import, *import_type, From 500e7d9d91b5278fe3cf3aa991c46650af76842c Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Wed, 4 Mar 2026 17:35:27 +0000 Subject: [PATCH 4/6] fix(boundaries): detect node_modules by path instead of package_json() presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach used `resolution.package_json().is_some()` to distinguish npm packages from local tsconfig path alias targets. However, oxc_resolver may return a package.json for any file inside a package directory — including files reached via tsconfig `paths` aliases in a package that has its own package.json. Switch to checking whether the resolved path contains a `node_modules` component, which is the canonical way to distinguish npm packages from local source files. Also adds a regression test that verifies alias resolution still works when a package.json is present in the package root. --- crates/turborepo-boundaries/src/imports.rs | 80 ++++++++++++++++++++-- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/crates/turborepo-boundaries/src/imports.rs b/crates/turborepo-boundaries/src/imports.rs index 8dc84eb997509..a32da67d2c647 100644 --- a/crates/turborepo-boundaries/src/imports.rs +++ b/crates/turborepo-boundaries/src/imports.rs @@ -115,14 +115,19 @@ fn check_import_as_tsconfig_path_alias( match resolver.resolve(dir, import) { Ok(resolution) => { - // If the resolver found a package.json the resolution went through - // node_modules (a real npm package), not a tsconfig path alias pointing - // to a local file. Return false so the caller falls through to - // `check_package_import`. - if resolution.package_json().is_some() { + // If the resolved path goes through node_modules, the import resolved to + // a real npm package rather than a tsconfig path alias pointing to a local + // file. Return false so the caller falls through to `check_package_import`. + // + // We intentionally do NOT use `resolution.package_json().is_some()` here + // because oxc_resolver may return a package.json for any file inside a + // package directory — including files that are local tsconfig path alias + // targets. Checking for `node_modules` in the resolved path is the + // canonical way to distinguish npm packages from local source files. + let path = resolution.path(); + if path.components().any(|c| c.as_os_str() == "node_modules") { return Ok(false); } - let path = resolution.path(); let Some(utf8_path) = Utf8Path::from_path(path) else { result.diagnostics.push(BoundariesDiagnostic::InvalidPath { path: path.to_string_lossy().to_string(), @@ -562,4 +567,65 @@ mod test { "expected no boundary violations for a locally-aliased import" ); } -} + /// Regression test: a tsconfig path alias must still be resolved as a local + /// import even when the package root contains a `package.json` file. + /// + /// Previously, using `resolution.package_json().is_some()` caused the check + /// to incorrectly treat tsconfig aliases as npm packages in any real project + /// that has a `package.json` in its root directory. + #[test] + fn tsconfig_alias_resolves_with_package_json_present() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Create a package.json so oxc_resolver can find it during resolution + std::fs::write( + root.join("package.json"), + r#"{ "name": "test-pkg", "version": "1.0.0" }"#, + ) + .unwrap(); + + let tsconfig = root.join("tsconfig.json"); + std::fs::write( + &tsconfig, + r#"{ "compilerOptions": { "paths": { "@/*": ["./*"] } } }"#, + ) + .unwrap(); + + std::fs::create_dir_all(root.join("utils")).unwrap(); + std::fs::write(root.join("utils").join("helper.ts"), "export const x = 1;").unwrap(); + + let file_content = "import { x } from \"@/utils/helper\";"; + std::fs::write(root.join("index.ts"), file_content).unwrap(); + + let package_root = AbsoluteSystemPath::new(root.to_str().unwrap()).unwrap(); + let tsconfig_path = AbsoluteSystemPath::new(tsconfig.to_str().unwrap()).unwrap(); + let file_path = package_root.join_component("index.ts"); + let package_name = PackageName::from("test-pkg"); + let span = SourceSpan::new(0.into(), 0); + let mut result = BoundariesResult::default(); + + let resolver = Tracer::create_resolver(Some(tsconfig_path)); + + let resolved = check_import_as_tsconfig_path_alias( + &resolver, + &package_name, + package_root, + span, + &file_path, + file_content, + "@/utils/helper", + &mut result, + ) + .unwrap(); + + assert!( + resolved, + "@/utils/helper should be resolved as a tsconfig path alias even when package.json is present" + ); + assert!( + result.diagnostics.is_empty(), + "expected no boundary violations for a locally-aliased import" + ); + } +} \ No newline at end of file From 73157340da3816540787961b60e10c88dd4d40f4 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 11 Mar 2026 15:44:45 -0600 Subject: [PATCH 5/6] fix: Improve tsconfig alias resolution robustness and test coverage Structured resolver error handling to distinguish expected failures (NotFound, Builtin) from unexpected ones (I/O, broken tsconfig) with tracing::debug logging for the latter. Added test coverage for the node_modules bail-out path and out-of-package alias boundary violations. Fixed pre-existing macOS test failures caused by /tmp symlink canonicalization mismatch. Added doc comments to key functions. --- crates/turborepo-boundaries/src/imports.rs | 231 ++++++++++++++++++--- crates/turborepo-boundaries/src/lib.rs | 8 + 2 files changed, 208 insertions(+), 31 deletions(-) diff --git a/crates/turborepo-boundaries/src/imports.rs b/crates/turborepo-boundaries/src/imports.rs index a32da67d2c647..4155b67243e8b 100644 --- a/crates/turborepo-boundaries/src/imports.rs +++ b/crates/turborepo-boundaries/src/imports.rs @@ -5,6 +5,7 @@ use itertools::Itertools; use miette::{NamedSource, SourceSpan}; use oxc_ast::ast::Comment; use oxc_span::Span; +use tracing::debug; use turbo_trace::ImportType; use turbopath::{AbsoluteSystemPath, AnchoredSystemPathBuf, PathRelation, RelativeUnixPath}; use turborepo_errors::Spanned; @@ -80,18 +81,23 @@ impl<'a> DependencyLocations<'a> { } /// Checks if the given import can be resolved as a tsconfig path alias via the -/// resolver, e.g. `@/types/foo` -> `./src/foo` or `features/foo` -> `./src/features/foo`, -/// and if so, checks the resolved path against package boundaries. +/// resolver, e.g. `@/types/foo` -> `./src/foo` or `features/foo` -> +/// `./src/features/foo`, and if so, checks the resolved path against package +/// boundaries. /// -/// Only called for package-name-shaped imports (after `is_potential_package_name` guard -/// in `check_import`). This allows tsconfig `paths` entries that shadow package-name-shaped -/// specifiers to be recognised as local imports instead of being incorrectly flagged as -/// undeclared dependencies. +/// Only called for package-name-shaped imports (after +/// `is_potential_package_name` guard in `check_import`). This allows tsconfig +/// `paths` entries that shadow package-name-shaped specifiers to be recognised +/// as local imports instead of being incorrectly flagged as undeclared +/// dependencies. /// -/// Returns `Ok(true)` if the import was resolved as a local tsconfig path alias. -/// Returns `Ok(false)` if the resolver found a real npm package (has a package.json), -/// could not resolve the import, or the import is relative — the caller should then -/// fall through to `check_package_import`. +/// Returns `Ok(true)` if the import was resolved as a tsconfig path alias +/// (local or cross-package — the latter produces an `ImportLeavesPackage` +/// diagnostic via [`check_file_import`]). +/// +/// Returns `Ok(false)` if the resolved path goes through `node_modules` (a +/// real npm package) or if the resolver could not resolve the import. The +/// caller should then fall through to `check_package_import`. #[allow(clippy::too_many_arguments)] fn check_import_as_tsconfig_path_alias( resolver: &Resolver, @@ -103,10 +109,7 @@ fn check_import_as_tsconfig_path_alias( import: &str, result: &mut BoundariesResult, ) -> Result { - // Skip relative imports — those are always resolved as file imports elsewhere. - // Package-name-shaped imports are intentionally tried here so that tsconfig - // `paths` aliases (e.g. `features/*` → `./src/features/*`) are resolved before - // we check them against declared package dependencies. + // Safety guard — relative imports are resolved as file imports elsewhere. if import.starts_with('.') { return Ok(false); } @@ -115,15 +118,15 @@ fn check_import_as_tsconfig_path_alias( match resolver.resolve(dir, import) { Ok(resolution) => { - // If the resolved path goes through node_modules, the import resolved to - // a real npm package rather than a tsconfig path alias pointing to a local - // file. Return false so the caller falls through to `check_package_import`. + // If the resolved path goes through node_modules, the import + // resolved to a real npm package rather than a tsconfig path alias + // pointing to a local file. Return false so the caller falls + // through to `check_package_import`. // - // We intentionally do NOT use `resolution.package_json().is_some()` here - // because oxc_resolver may return a package.json for any file inside a - // package directory — including files that are local tsconfig path alias - // targets. Checking for `node_modules` in the resolved path is the - // canonical way to distinguish npm packages from local source files. + // We intentionally do NOT use `resolution.package_json().is_some()` + // because the resolver may return a package.json for any file + // inside a directory that has one — including local tsconfig alias + // targets. let path = resolution.path(); if path.components().any(|c| c.as_os_str() == "node_modules") { return Ok(false); @@ -146,10 +149,40 @@ fn check_import_as_tsconfig_path_alias( )?); Ok(true) } - Err(_) => Ok(false), + // Expected resolution failures — the import isn't a tsconfig alias. + Err( + ResolveError::NotFound(_) + | ResolveError::MatchedAliasNotFound(_, _) + | ResolveError::Builtin { .. } + | ResolveError::Ignored(_) + | ResolveError::Specifier(_), + ) => Ok(false), + // Unexpected errors (I/O, broken tsconfig, etc.) — log for debugging + // but still fall through to check_package_import. + Err(e) => { + debug!( + import = %import, + error = %e, + "tsconfig path alias resolution failed unexpectedly, \ + falling through to package import check" + ); + Ok(false) + } } } +/// Validates a single import statement against package boundaries. +/// +/// Dispatches to one of three paths: +/// 1. Relative imports (`./`, `../`) — validates the resolved path stays within +/// the package via [`check_file_import`]. +/// 2. Package-name-shaped imports — first tries +/// [`check_import_as_tsconfig_path_alias`] (resolves tsconfig `paths` +/// entries), then falls through to [`check_package_import`] (validates the +/// import is a declared dependency). +/// 3. Everything else — skipped (no diagnostic). +/// +/// Respects `@boundaries-ignore` comments placed above the import statement. #[allow(clippy::too_many_arguments)] pub(crate) fn check_import( comments: &[Comment], @@ -247,6 +280,10 @@ pub(crate) fn check_import( Ok(()) } +/// Checks whether a resolved file import stays within the package boundary. +/// +/// Returns `Some(BoundariesDiagnostic::ImportLeavesPackage)` if the resolved +/// path falls outside `package_path`, `None` otherwise. #[allow(clippy::too_many_arguments)] pub(crate) fn check_file_import( file_path: &AbsoluteSystemPath, @@ -286,6 +323,11 @@ pub(crate) fn check_file_import( } } +/// Extracts the npm package name from an import specifier. +/// +/// For scoped packages (`@scope/name/path`), returns `@scope/name`. +/// For unscoped packages (`name/path`), returns `name`. +/// For bare imports without subpaths, returns the import as-is. pub(crate) fn get_package_name(import: &str) -> String { if import.starts_with("@") { import.split('/').take(2).join("/") @@ -513,7 +555,9 @@ mod test { #[test] fn tsconfig_alias_resolves_package_name_shaped_path_alias() { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); + // Canonicalize to match the resolver's symlink-resolved paths + // (e.g. /tmp → /private/tmp on macOS). + let root = tmp.path().canonicalize().unwrap(); // Mimic a tsconfig that maps `*` to `./src/*`, turning bare specifiers // like `features/feature-a` into local imports. @@ -561,24 +605,27 @@ mod test { resolved, "features/feature-a with a tsconfig `*` alias should be resolved as a local import" ); - // The resolved path is inside the package root, so no boundary violation assert!( result.diagnostics.is_empty(), "expected no boundary violations for a locally-aliased import" ); + assert!(result.warnings.is_empty(), "expected no warnings"); } + /// Regression test: a tsconfig path alias must still be resolved as a local /// import even when the package root contains a `package.json` file. /// /// Previously, using `resolution.package_json().is_some()` caused the check - /// to incorrectly treat tsconfig aliases as npm packages in any real project - /// that has a `package.json` in its root directory. + /// to incorrectly treat tsconfig aliases as npm packages in any real + /// project that has a `package.json` in its root directory. #[test] fn tsconfig_alias_resolves_with_package_json_present() { let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); + // Canonicalize to match the resolver's symlink-resolved paths + // (e.g. /tmp → /private/tmp on macOS). + let root = tmp.path().canonicalize().unwrap(); - // Create a package.json so oxc_resolver can find it during resolution + // Create a package.json so unrs_resolver can find it during resolution std::fs::write( root.join("package.json"), r#"{ "name": "test-pkg", "version": "1.0.0" }"#, @@ -621,11 +668,133 @@ mod test { assert!( resolved, - "@/utils/helper should be resolved as a tsconfig path alias even when package.json is present" + "@/utils/helper should be resolved as a tsconfig path alias even when package.json is \ + present" ); assert!( result.diagnostics.is_empty(), "expected no boundary violations for a locally-aliased import" ); + assert!(result.warnings.is_empty(), "expected no warnings"); + } + + /// When the resolver resolves an import to a path inside `node_modules`, + /// the function must return `false` so the caller falls through to + /// `check_package_import` for dependency-declaration validation. + #[test] + fn tsconfig_alias_check_returns_false_for_node_modules_resolution() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Wildcard alias that could match anything + let tsconfig = root.join("tsconfig.json"); + std::fs::write( + &tsconfig, + r#"{ "compilerOptions": { "paths": { "*": ["./src/*"] } } }"#, + ) + .unwrap(); + + // Create a real node_modules package so the resolver can find it + std::fs::create_dir_all(root.join("node_modules").join("some-pkg")).unwrap(); + std::fs::write( + root.join("node_modules").join("some-pkg").join("index.js"), + "module.exports = {};", + ) + .unwrap(); + std::fs::write( + root.join("node_modules") + .join("some-pkg") + .join("package.json"), + r#"{ "name": "some-pkg", "main": "index.js" }"#, + ) + .unwrap(); + + let file_content = r#"import { x } from "some-pkg";"#; + std::fs::write(root.join("index.ts"), file_content).unwrap(); + + let package_root = AbsoluteSystemPath::new(root.to_str().unwrap()).unwrap(); + let tsconfig_path = AbsoluteSystemPath::new(tsconfig.to_str().unwrap()).unwrap(); + let file_path = package_root.join_component("index.ts"); + let package_name = PackageName::from("test-pkg"); + let span = SourceSpan::new(0.into(), 0); + let mut result = BoundariesResult::default(); + + let resolver = Tracer::create_resolver(Some(tsconfig_path)); + + let resolved = check_import_as_tsconfig_path_alias( + &resolver, + &package_name, + package_root, + span, + &file_path, + file_content, + "some-pkg", + &mut result, + ) + .unwrap(); + + assert!( + !resolved, + "import resolving to node_modules must not be treated as a tsconfig alias" + ); + assert!(result.diagnostics.is_empty()); } -} \ No newline at end of file + + /// A tsconfig alias that resolves to a file outside the package root should + /// still be treated as a resolved alias (returns `true`), but produce an + /// `ImportLeavesPackage` diagnostic. + #[test] + fn tsconfig_alias_flags_boundary_violation_for_out_of_package_resolution() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Shared file outside the package root + let shared_dir = root.join("shared"); + std::fs::create_dir_all(&shared_dir).unwrap(); + std::fs::write(shared_dir.join("utils.ts"), "export const x = 1;").unwrap(); + + // Package directory with a tsconfig alias pointing outside + let pkg_dir = root.join("packages").join("my-app"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + + let tsconfig = pkg_dir.join("tsconfig.json"); + std::fs::write( + &tsconfig, + r#"{ "compilerOptions": { "paths": { "@shared/*": ["../../shared/*"] } } }"#, + ) + .unwrap(); + + let file_content = r#"import { x } from "@shared/utils";"#; + std::fs::write(pkg_dir.join("index.ts"), file_content).unwrap(); + + let package_root = AbsoluteSystemPath::new(pkg_dir.to_str().unwrap()).unwrap(); + let tsconfig_path = AbsoluteSystemPath::new(tsconfig.to_str().unwrap()).unwrap(); + let file_path = package_root.join_component("index.ts"); + let package_name = PackageName::from("my-app"); + let span = SourceSpan::new(0.into(), 0); + let mut result = BoundariesResult::default(); + + let resolver = Tracer::create_resolver(Some(tsconfig_path)); + + let resolved = check_import_as_tsconfig_path_alias( + &resolver, + &package_name, + package_root, + span, + &file_path, + file_content, + "@shared/utils", + &mut result, + ) + .unwrap(); + + assert!( + resolved, + "@shared/utils should be resolved as a tsconfig path alias" + ); + assert!( + !result.diagnostics.is_empty(), + "expected an ImportLeavesPackage diagnostic for an out-of-package alias" + ); + } +} diff --git a/crates/turborepo-boundaries/src/lib.rs b/crates/turborepo-boundaries/src/lib.rs index f1582144715ae..0db355f8406be 100644 --- a/crates/turborepo-boundaries/src/lib.rs +++ b/crates/turborepo-boundaries/src/lib.rs @@ -330,6 +330,14 @@ impl BoundariesChecker { None } + /// Returns `true` if the import specifier looks like it could be an npm + /// package name (e.g. `react`, `@scope/pkg`, `lodash/fp`). + /// + /// Note: tsconfig `paths` aliases like `features/*` pass this check when + /// `features` is a valid npm package name. This is intentional — + /// [`imports::check_import_as_tsconfig_path_alias`] runs first for these + /// imports to resolve them locally before falling through to dependency + /// validation. fn is_potential_package_name(import: &str) -> bool { let base = imports::get_package_name(import); PACKAGE_NAME_REGEX.is_match(&base) From 6811f66266beedc7cdd0929eb29985b2338470a3 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 11 Mar 2026 16:38:51 -0600 Subject: [PATCH 6/6] fix: Handle symlinked workspace packages in tsconfig alias check Workspace packages symlinked in node_modules resolve to their real path (without node_modules in it), so the node_modules path check alone is insufficient. Added a package.json name check: if the resolution's package.json has a name matching the import's package name, it's an actual workspace/npm package, not a tsconfig alias. Also moved tsconfig alias resolution to cover all non-relative imports (not just package-name-shaped ones) so that aliases like `!` and `@/foo` are correctly resolved. Fixed Windows test failure by using dunce::canonicalize to avoid the \\?\ prefix that breaks path comparison. --- Cargo.lock | 1 + crates/turborepo-boundaries/Cargo.toml | 1 + crates/turborepo-boundaries/src/imports.rs | 85 ++++++++++++---------- crates/turborepo-boundaries/src/lib.rs | 8 +- 4 files changed, 53 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0198b3ca9df7b..4b9036206fbbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7265,6 +7265,7 @@ dependencies = [ "biome_deserialize_macros 0.6.0", "biome_diagnostics", "camino", + "dunce", "globwalk", "indicatif", "itertools 0.14.0", diff --git a/crates/turborepo-boundaries/Cargo.toml b/crates/turborepo-boundaries/Cargo.toml index 99d13a998ffe9..d9316c65e29ff 100644 --- a/crates/turborepo-boundaries/Cargo.toml +++ b/crates/turborepo-boundaries/Cargo.toml @@ -38,5 +38,6 @@ turborepo-ui = { workspace = true } unrs_resolver = { workspace = true } [dev-dependencies] +dunce = { workspace = true } tempfile = { workspace = true } test-case = { workspace = true } diff --git a/crates/turborepo-boundaries/src/imports.rs b/crates/turborepo-boundaries/src/imports.rs index 27a977a643acc..4bcfa99b5079c 100644 --- a/crates/turborepo-boundaries/src/imports.rs +++ b/crates/turborepo-boundaries/src/imports.rs @@ -85,11 +85,10 @@ impl<'a> DependencyLocations<'a> { /// `./src/features/foo`, and if so, checks the resolved path against package /// boundaries. /// -/// Only called for package-name-shaped imports (after -/// `is_potential_package_name` guard in `check_import`). This allows tsconfig -/// `paths` entries that shadow package-name-shaped specifiers to be recognised -/// as local imports instead of being incorrectly flagged as undeclared -/// dependencies. +/// Called for all non-relative imports in `check_import`. This allows tsconfig +/// `paths` entries — whether they shadow package-name-shaped specifiers or use +/// non-package-name patterns like `!` or `@/foo` — to be recognised as local +/// imports instead of being incorrectly flagged as undeclared dependencies. /// /// Returns `Ok(true)` if the import was resolved as a tsconfig path alias /// (local or cross-package — the latter produces an `ImportLeavesPackage` @@ -122,15 +121,23 @@ fn check_import_as_tsconfig_path_alias( // resolved to a real npm package rather than a tsconfig path alias // pointing to a local file. Return false so the caller falls // through to `check_package_import`. - // - // We intentionally do NOT use `resolution.package_json().is_some()` - // because the resolver may return a package.json for any file - // inside a directory that has one — including local tsconfig alias - // targets. let path = resolution.path(); if path.components().any(|c| c.as_os_str() == "node_modules") { return Ok(false); } + // Workspace packages are symlinked in node_modules, so the + // resolved path won't contain `node_modules` after symlink + // resolution. Detect these by checking if the resolution's + // package.json name matches the import's package name — if so, + // the resolver found the actual package, not a tsconfig alias. + if BoundariesChecker::is_potential_package_name(import) { + let import_pkg_name = get_package_name(import); + if let Some(pkg_json) = resolution.package_json() + && pkg_json.name() == Some(import_pkg_name.as_str()) + { + return Ok(false); + } + } let Some(utf8_path) = Utf8Path::from_path(path) else { result.diagnostics.push(BoundariesDiagnostic::InvalidPath { path: path.to_string_lossy().to_string(), @@ -176,11 +183,12 @@ fn check_import_as_tsconfig_path_alias( /// Dispatches to one of three paths: /// 1. Relative imports (`./`, `../`) — validates the resolved path stays within /// the package via [`check_file_import`]. -/// 2. Package-name-shaped imports — first tries -/// [`check_import_as_tsconfig_path_alias`] (resolves tsconfig `paths` -/// entries), then falls through to [`check_package_import`] (validates the -/// import is a declared dependency). -/// 3. Everything else — skipped (no diagnostic). +/// 2. Non-relative imports — first tries +/// [`check_import_as_tsconfig_path_alias`] to resolve tsconfig `paths` +/// entries, then for package-name-shaped imports falls through to +/// [`check_package_import`] (validates the import is a declared dependency). +/// 3. Non-relative, non-package-name imports that don't resolve as tsconfig +/// aliases — skipped (no diagnostic). /// /// Respects `@boundaries-ignore` comments placed above the import statement. #[allow(clippy::too_many_arguments)] @@ -228,8 +236,8 @@ pub(crate) fn check_import( let span = SourceSpan::new(start.into(), end - start); - // We have a file import let check_result = if import.starts_with(".") { + // Relative file import let import_path = RelativeUnixPath::new(import)?; let dir_path = file_path .parent() @@ -244,12 +252,11 @@ pub(crate) fn check_import( span, file_content, )? - } else if BoundariesChecker::is_potential_package_name(import) { - // For package-name-shaped imports, first check whether the import resolves - // as a tsconfig path alias (e.g. `features/*` → `./src/features/*`). - // If so, validate the resolved path against package boundaries. - // Only if the resolver does not find a tsconfig alias do we fall through - // to the ordinary dependency-declaration check. + } else { + // Non-relative import: try tsconfig alias resolution first. This + // handles both package-name-shaped imports (where the alias may + // shadow a package name) and non-package-name imports (like `!` or + // `@/foo`) that can only be tsconfig aliases. if check_import_as_tsconfig_path_alias( resolver, package_name, @@ -262,17 +269,19 @@ pub(crate) fn check_import( )? { return Ok(()); } - check_package_import( - import, - *import_type, - span, - file_path, - file_content, - dependency_locations, - resolver, - ) - } else { - None + if BoundariesChecker::is_potential_package_name(import) { + check_package_import( + import, + *import_type, + span, + file_path, + file_content, + dependency_locations, + resolver, + ) + } else { + None + } }; result.diagnostics.extend(check_result); @@ -682,8 +691,9 @@ mod test { fn tsconfig_alias_resolves_package_name_shaped_path_alias() { let tmp = tempfile::tempdir().unwrap(); // Canonicalize to match the resolver's symlink-resolved paths - // (e.g. /tmp → /private/tmp on macOS). - let root = tmp.path().canonicalize().unwrap(); + // (e.g. /tmp → /private/tmp on macOS). Uses dunce to avoid + // \\?\ prefix on Windows which breaks path comparison. + let root = dunce::canonicalize(tmp.path()).unwrap(); // Mimic a tsconfig that maps `*` to `./src/*`, turning bare specifiers // like `features/feature-a` into local imports. @@ -748,8 +758,9 @@ mod test { fn tsconfig_alias_resolves_with_package_json_present() { let tmp = tempfile::tempdir().unwrap(); // Canonicalize to match the resolver's symlink-resolved paths - // (e.g. /tmp → /private/tmp on macOS). - let root = tmp.path().canonicalize().unwrap(); + // (e.g. /tmp → /private/tmp on macOS). Uses dunce to avoid + // \\?\ prefix on Windows which breaks path comparison. + let root = dunce::canonicalize(tmp.path()).unwrap(); // Create a package.json so unrs_resolver can find it during resolution std::fs::write( diff --git a/crates/turborepo-boundaries/src/lib.rs b/crates/turborepo-boundaries/src/lib.rs index 0db355f8406be..0c9b06c93d6a5 100644 --- a/crates/turborepo-boundaries/src/lib.rs +++ b/crates/turborepo-boundaries/src/lib.rs @@ -333,11 +333,9 @@ impl BoundariesChecker { /// Returns `true` if the import specifier looks like it could be an npm /// package name (e.g. `react`, `@scope/pkg`, `lodash/fp`). /// - /// Note: tsconfig `paths` aliases like `features/*` pass this check when - /// `features` is a valid npm package name. This is intentional — - /// [`imports::check_import_as_tsconfig_path_alias`] runs first for these - /// imports to resolve them locally before falling through to dependency - /// validation. + /// Used in [`imports::check_import`] to decide whether a non-relative + /// import that didn't resolve as a tsconfig alias should be checked + /// against declared dependencies. fn is_potential_package_name(import: &str) -> bool { let base = imports::get_package_name(import); PACKAGE_NAME_REGEX.is_match(&base)