Skip to content

tsconfig paths do not apply to files outside the root config's include, unlike tsx #835

Closed

Description

@fengmk2

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 lib

The 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.ts resolves @lib/x.ts on Node.js 22.18, 24.12, and 26.5, and src/ files resolve as before.
  • A decorator in scripts/ still fails with the experimentalDecorators error, because for_path is unchanged and compiler options keep the ownership rule.
  • cargo test passes. pnpm test passes 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 file exclude names must not resolve the config's aliases.

That test is the open question:

  1. Follow tsx: also fall back for excluded files, and update the test. This is the smallest change, and it matches what tsx users expect.
  2. Keep exclude meaningful: fall back only for files outside include, and keep refusing files exclude names. oxc_resolver keeps that matching private (is_file_included_in_tsconfig, is_glob_matches), so this needs it to expose something like TsConfig::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

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions