From 9b109c6e7a56e93a10fd0f42c153b602c63296fd Mon Sep 17 00:00:00 2001 From: Lars Kappert Date: Sat, 6 Jun 2026 09:04:32 +0200 Subject: [PATCH] fix(tsconfig): treat a missing `extends` target as non-fatal (#1185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsconfig: 'auto'` (or an explicit config) returned no result for every specifier under a tsconfig whose `extends` target can't be resolved — a package that isn't installed, or a generated config that doesn't exist yet (e.g. Nuxt's `.nuxt/tsconfig*.json` before `nuxt prepare`). tsc / typescript-go report TS6053 and keep resolving with the options that parsed. Skip a missing `extends` base — both a bare-package `TsconfigNotFound` and a missing relative target — and carry on. Unreadable (`IOError`) and malformed (`Json`) bases stay fatal; circular `extends` and missing `references` are unaffected. Also flips the two tests that asserted the old fatal behavior, and excludes the `extends-not-found` case from the tsconfck `parse/invalid` conformance set (tsconfck classifies a missing extends as invalid; tsc/tsgo don't). --- .../tsconfig/cases/extends-not-found/a.ts | 2 + .../tsconfig/cases/extends-not-found/b.ts | 1 + .../cases/extends-package-not-found/a.ts | 2 + .../cases/extends-package-not-found/b.ts | 1 + .../extends-package-not-found/tsconfig.json | 1 + src/tests/tsconfck.rs | 5 ++ src/tests/tsconfig_extends.rs | 51 +++++++++++++++---- src/tests/tsconfig_paths.rs | 6 +-- src/tsconfig_resolver.rs | 27 +++++++--- 9 files changed, 76 insertions(+), 20 deletions(-) create mode 100644 fixtures/tsconfig/cases/extends-not-found/a.ts create mode 100644 fixtures/tsconfig/cases/extends-not-found/b.ts create mode 100644 fixtures/tsconfig/cases/extends-package-not-found/a.ts create mode 100644 fixtures/tsconfig/cases/extends-package-not-found/b.ts create mode 100644 fixtures/tsconfig/cases/extends-package-not-found/tsconfig.json diff --git a/fixtures/tsconfig/cases/extends-not-found/a.ts b/fixtures/tsconfig/cases/extends-not-found/a.ts new file mode 100644 index 000000000..483942308 --- /dev/null +++ b/fixtures/tsconfig/cases/extends-not-found/a.ts @@ -0,0 +1,2 @@ +import { b } from './b'; +export const a = b; diff --git a/fixtures/tsconfig/cases/extends-not-found/b.ts b/fixtures/tsconfig/cases/extends-not-found/b.ts new file mode 100644 index 000000000..b85663f20 --- /dev/null +++ b/fixtures/tsconfig/cases/extends-not-found/b.ts @@ -0,0 +1 @@ +export const b = 1; diff --git a/fixtures/tsconfig/cases/extends-package-not-found/a.ts b/fixtures/tsconfig/cases/extends-package-not-found/a.ts new file mode 100644 index 000000000..483942308 --- /dev/null +++ b/fixtures/tsconfig/cases/extends-package-not-found/a.ts @@ -0,0 +1,2 @@ +import { b } from './b'; +export const a = b; diff --git a/fixtures/tsconfig/cases/extends-package-not-found/b.ts b/fixtures/tsconfig/cases/extends-package-not-found/b.ts new file mode 100644 index 000000000..b85663f20 --- /dev/null +++ b/fixtures/tsconfig/cases/extends-package-not-found/b.ts @@ -0,0 +1 @@ +export const b = 1; diff --git a/fixtures/tsconfig/cases/extends-package-not-found/tsconfig.json b/fixtures/tsconfig/cases/extends-package-not-found/tsconfig.json new file mode 100644 index 000000000..7379485e7 --- /dev/null +++ b/fixtures/tsconfig/cases/extends-package-not-found/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "@acme/not-installed/tsconfig.json", "compilerOptions": { "baseUrl": ".", "paths": { "@app/*": ["./src/*"] } } } diff --git a/src/tests/tsconfck.rs b/src/tests/tsconfck.rs index 1b6dda791..ff4b53807 100644 --- a/src/tests/tsconfck.rs +++ b/src/tests/tsconfck.rs @@ -28,6 +28,11 @@ fn parse_invalid() { let dir = super::fixture_root().join("tsconfck").join("parse").join("invalid"); let resolver = Resolver::default(); for path in walk(&dir).into_iter().filter(|path| path.file_name().unwrap() == "tsconfig.json") { + // A missing `extends` target is non-fatal in oxc (TS6053), matching + // tsc/tsgo — unlike tsconfck, which classifies it as invalid. + if path.parent().is_some_and(|p| p.ends_with("extends-not-found")) { + continue; + } let tsconfig = resolver.resolve_tsconfig(&path); assert!(tsconfig.is_err(), "{} {tsconfig:?}", path.display()); } diff --git a/src/tests/tsconfig_extends.rs b/src/tests/tsconfig_extends.rs index 240c8a752..7383a7f4b 100644 --- a/src/tests/tsconfig_extends.rs +++ b/src/tests/tsconfig_extends.rs @@ -198,12 +198,11 @@ fn test_extend_tsconfig_no_override_existing() { assert!(compiler_options.base_url.is_some()); } -/// When a tsconfig's `extends` target does not exist, -/// `resolve_tsconfig` should return `TsconfigNotFound`. +/// A missing `extends` target is non-fatal: `tsc` reports TS6053 and keeps the +/// options that parsed, so `resolve_tsconfig` succeeds rather than failing every +/// resolution under the config. #[test] fn test_extend_tsconfig_not_found() { - use crate::ResolveError; - let f = super::fixture_root().join("tsconfig/cases/extends-not-found"); let resolver = Resolver::new(ResolveOptions { @@ -215,10 +214,7 @@ fn test_extend_tsconfig_not_found() { }); let result = resolver.resolve_tsconfig(&f); - assert!( - matches!(&result, Err(ResolveError::TsconfigNotFound(_))), - "expected TsconfigNotFound for missing extends target, got {result:?}", - ); + assert!(result.is_ok(), "a missing `extends` target must be non-fatal, got {result:?}"); } /// When a tsconfig's `references` target does not exist, @@ -385,10 +381,11 @@ fn test_extend_imports() { let resolution = resolver.resolve_tsconfig(&f).expect("resolved"); assert_eq!(resolution.compiler_options.target, Some("ES2015".to_string())); + // An undefined `#` import in `extends` is non-fatal — the config still loads. let result = resolver.resolve_tsconfig(f.join("tsconfig-missing.json")); assert!( - matches!(&result, Err(crate::ResolveError::TsconfigNotFound(_))), - "expected TsconfigNotFound for an undefined `#` import, got {result:?}", + result.is_ok(), + "an undefined `#` import in `extends` must be non-fatal, got {result:?}" ); } @@ -423,3 +420,37 @@ fn test_extend_tsconfig_via_symlink_relative() { let f = super::fixture_root().join("tsconfig/cases/extends-symlink"); assert_extends_symlink_resolves_to_canonical(&f.join("project/tsconfig.relative.json")); } + +/// A plain relative import must still resolve when the discovered tsconfig +/// `extends` a **package** that isn't installed (common in monorepos / before +/// dependencies are installed). `tsc`/`tsgo` report TS6053 and keep resolving; +/// previously oxc returned no result for every specifier under the config. +#[test] +fn test_extend_package_not_found_still_resolves() { + let f = super::fixture_root().join("tsconfig/cases/extends-package-not-found"); + + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigDiscovery::Auto), + extensions: vec![".ts".into()], + ..ResolveOptions::default() + }); + + let resolved = resolver.resolve_file(f.join("a.ts"), "./b").map(|r| r.full_path()); + assert_eq!(resolved, Ok(f.join("b.ts"))); +} + +/// Same for a missing **relative** `extends` target (e.g. a generated +/// `./.nuxt/tsconfig.json` before `nuxt prepare`). +#[test] +fn test_extend_relative_not_found_still_resolves() { + let f = super::fixture_root().join("tsconfig/cases/extends-not-found"); + + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigDiscovery::Auto), + extensions: vec![".ts".into()], + ..ResolveOptions::default() + }); + + let resolved = resolver.resolve_file(f.join("a.ts"), "./b").map(|r| r.full_path()); + assert_eq!(resolved, Ok(f.join("b.ts"))); +} diff --git a/src/tests/tsconfig_paths.rs b/src/tests/tsconfig_paths.rs index 783551f73..433b00052 100644 --- a/src/tests/tsconfig_paths.rs +++ b/src/tests/tsconfig_paths.rs @@ -56,12 +56,12 @@ pub fn tsconfig_resolve_impl(tsconfig_discovery: bool) { Err(ResolveError::NotFound("ts-path".to_string())), ), ( + // A missing `extends` target is non-fatal: the config loads, so the + // alias it would have provided is just a normal `NotFound`. f.join("cases/extends-not-found"), "ts-path", f.join("cases").join("extends-not-found").join("tsconfig.json"), - Err(ResolveError::TsconfigNotFound( - f.join("cases").join("extends-not-found").join("not-found"), - )), + Err(ResolveError::NotFound("ts-path".to_string())), ), // no `base_url` ( diff --git a/src/tsconfig_resolver.rs b/src/tsconfig_resolver.rs index c4016372f..35e2a5921 100644 --- a/src/tsconfig_resolver.rs +++ b/src/tsconfig_resolver.rs @@ -207,20 +207,33 @@ impl ResolverGeneric { // `extend_tsconfig` only fills `None` fields, we iterate in reverse // so that the last base sets fields first and earlier bases can no // longer override them — net effect: later wins. - let extended_tsconfig_paths = tsconfig - .extends() - .map(|specifier| self.get_extended_tsconfig_path(&directory, tsconfig, specifier)) - .collect::, _>>()?; + // A missing `extends` target is non-fatal: `tsc` reports TS6053 and + // keeps the options that did parse, so skip the missing base rather + // than failing every resolution under this config. + let mut extended_tsconfig_paths = Vec::new(); + for specifier in tsconfig.extends() { + match self.get_extended_tsconfig_path(&directory, tsconfig, specifier) { + Ok(path) => extended_tsconfig_paths.push(path), + Err(ResolveError::TsconfigNotFound(_)) => {} + Err(err) => return Err(err), + } + } if !extended_tsconfig_paths.is_empty() { ctx.with_extended_file(tsconfig.path().to_owned(), |ctx| { for extended_tsconfig_path in extended_tsconfig_paths.into_iter().rev() { - let extended_tsconfig = self.load_tsconfig( + match self.load_tsconfig( /* root */ false, &extended_tsconfig_path, TsconfigReferences::Disabled, ctx, - )?; - tsconfig.extend_tsconfig(&extended_tsconfig); + ) { + Ok(extended_tsconfig) => tsconfig.extend_tsconfig(&extended_tsconfig), + // A relative `extends` target that doesn't exist is + // non-fatal too (TS6053); an existing-but-unreadable or + // malformed base still errors (`IOError` / `Json`). + Err(ResolveError::TsconfigNotFound(_)) => {} + Err(err) => return Err(err), + } } Result::Ok::<(), ResolveError>(()) })?;