Repository navigation
tsconfig paths do not apply to files outside the root config's include, unlike tsx #835
Description
A script outside a root tsconfig.json's include cannot use that config's paths, while tsx resolves them. This is the most common layout for repository scripts: the root config covers src, and scripts/*.ts imports the same aliases.
Reproduction
$ cat tsconfig.json
{
"compilerOptions": { "baseUrl": ".", "paths": { "@lib/*": ["src/lib/*"] } },
"include": ["src"]
}
$ cat src/lib/x.ts
export const x = 'from lib';
$ cat scripts/seed.ts
import { x } from '@lib/x.ts';
console.log('scripts/seed.ts:', x);
$ node --import @oxc-node/core/register scripts/seed.ts
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@lib/x.ts' imported from /…/scripts/seed.ts
$ tsx scripts/seed.ts
scripts/seed.ts: from libThe same import in src/main.ts resolves with both. I reproduced this with @oxc-node/core@0.1.7 from npm on Node.js 22.18, 24.12, and 26.5. node --import @oxc-node/core/register with OXC_TSCONFIG_PATH=tsconfig.json works, but that also applies the config's compiler options to every file.
Cause
Resolution goes through TsconfigSource::for_importer (src/lib.rs#L203-L216). It asks find_tsconfig for the config that claims the importer, and oxc_resolver's find_tsconfig_auto returns None when no ancestor config claims the file through files / include / exclude. scripts/seed.ts is not in include, so it resolves with no config and loses paths.
for_importer already separates resolution from compiler options: it retries a JavaScript file as if it were TypeScript, for resolution only. Its comment says the question for resolution is "which project does this file belong to, for the purpose of module resolution", which is the case here too.
What tsx does
tsx applies the paths of the config found from the working directory to every importer outside node_modules, including files outside include and files that exclude names. It applies compiler options only to the files the config includes: in the reproduction, an experimentalDecorators: true in the root config does not reach scripts/, and a decorator there is compiled as a standard decorator.
Suggested fix
When no config claims the importer, fall back to the nearest ancestor tsconfig.json for resolution only, and keep compiler options on the ownership rule:
Self::Auto => Self::discover(resolver, path)
.or_else(|| { /* the existing TypeScript probe */ })
.or_else(|| {
Self::nearest(resolver, path).inspect(
|_| tracing::debug!(path = ?path, "tsconfig found as the nearest ancestor"),
)
}),/// The nearest ancestor `tsconfig.json`, whether or not it claims `path`.
fn nearest(resolver: &Resolver, path: &Path) -> Option<Arc<TsConfig>> {
if path.components().any(|c| c.as_os_str() == "node_modules") {
return None;
}
path.ancestors()
.skip(1)
.map(|dir| dir.join("tsconfig.json"))
.find(|candidate| candidate.is_file())
.and_then(|candidate| resolver.resolve_tsconfig(candidate).ok())
}With this on top of v0.1.7:
scripts/seed.tsresolves@lib/x.tson Node.js 22.18, 24.12, and 26.5, andsrc/files resolve as before.- A decorator in
scripts/still fails with theexperimentalDecoratorserror, becausefor_pathis unchanged and compiler options keep the ownership rule. cargo testpasses.pnpm testpasses except one test in__tests__/tsconfig-discovery.spec.ts, "the JavaScript probe does not resurrect a config that excludes the directory", which asserts that a fileexcludenames must not resolve the config's aliases.
That test is the open question:
- Follow tsx: also fall back for excluded files, and update the test. This is the smallest change, and it matches what tsx users expect.
- Keep
excludemeaningful: fall back only for files outsideinclude, and keep refusing filesexcludenames. oxc_resolver keeps that matching private (is_file_included_in_tsconfig,is_glob_matches), so this needs it to expose something likeTsConfig::excludes(path)first.
Either fixes the common case. We'd lean toward option 2 if exposing the matcher is acceptable, because it keeps the existing test's intent.
Vite+ (voidzero-dev/vite-plus#2936) vendors oxc-node for vpx <file>, which is meant to replace tsx for repository scripts, so this is the first thing users moving from tsx will hit. We'd rather fix it here and sync than diverge.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
docs(vpx): correct how tsx applies tsconfig paths
feat(vpx): run TypeScript scripts with the vendored oxc-node (#2936)