From 4881e9c132e3d602257c0066f5a797d9cc469152 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 24 Apr 2026 23:25:51 +0800 Subject: [PATCH 1/3] feat: walk up to discover parent tsconfig.json When oxnode is run from a sub-project of a monorepo that has no local tsconfig.json, previously the root workspace's tsconfig.json was silently ignored. This made settings like experimentalDecorators configured at the root inapplicable to sub-projects. init_resolver now walks up cwd.ancestors() to find a tsconfig.json when: - neither TS_NODE_PROJECT nor OXC_TSCONFIG_PATH is set, and - cwd/tsconfig.json does not exist. Explicit env var settings are still honored as-is, so a non-existent path in TS_NODE_PROJECT/OXC_TSCONFIG_PATH disables discovery entirely (escape hatch). Matches the behavior of tsc, ts-node, and tsx. --- README.md | 15 ++++ .../fixtures/monorepo-root/package.json | 5 ++ .../sub-project/decorator-entry.ts | 13 ++++ .../fixtures/monorepo-root/tsconfig.json | 10 +++ .../__tests__/tsconfig-discovery.spec.ts | 69 +++++++++++++++++++ src/lib.rs | 33 +++++++-- 6 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 packages/integrate-ava/__tests__/fixtures/monorepo-root/package.json create mode 100644 packages/integrate-ava/__tests__/fixtures/monorepo-root/sub-project/decorator-entry.ts create mode 100644 packages/integrate-ava/__tests__/fixtures/monorepo-root/tsconfig.json create mode 100644 packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts diff --git a/README.md b/README.md index eaf8c960..83e317a4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,21 @@ Transformer and register for Node.js projects. node --import @oxc-node/core/register ./path/to/entry.ts ``` +### `tsconfig.json` discovery + +On startup, oxc-node resolves a `tsconfig.json` using this precedence: + +1. `TS_NODE_PROJECT` — used as-is if set. +2. `OXC_TSCONFIG_PATH` — used as-is if set. +3. `tsconfig.json` in the current working directory. +4. If none of the above exist, walk up parent directories and use the first + `tsconfig.json` found. This makes a root-workspace `tsconfig.json` (e.g. one + with `experimentalDecorators: true`) apply to sub-projects that don't have + their own config. + +To opt out of the walk-up, point `TS_NODE_PROJECT` or `OXC_TSCONFIG_PATH` at an +explicit path (a non-existent path disables discovery entirely). + ## [Sponsored By](https://github.com/sponsors/Boshen)

diff --git a/packages/integrate-ava/__tests__/fixtures/monorepo-root/package.json b/packages/integrate-ava/__tests__/fixtures/monorepo-root/package.json new file mode 100644 index 00000000..f1a96660 --- /dev/null +++ b/packages/integrate-ava/__tests__/fixtures/monorepo-root/package.json @@ -0,0 +1,5 @@ +{ + "name": "monorepo-root-fixture", + "private": true, + "type": "module" +} diff --git a/packages/integrate-ava/__tests__/fixtures/monorepo-root/sub-project/decorator-entry.ts b/packages/integrate-ava/__tests__/fixtures/monorepo-root/sub-project/decorator-entry.ts new file mode 100644 index 00000000..a8def45c --- /dev/null +++ b/packages/integrate-ava/__tests__/fixtures/monorepo-root/sub-project/decorator-entry.ts @@ -0,0 +1,13 @@ +let argCount = -1; + +function Count(...args: unknown[]): unknown { + argCount = args.length; + return args[0]; +} + +@Count +class Foo {} + +void Foo; + +console.log(`ARG_COUNT:${argCount}`); diff --git a/packages/integrate-ava/__tests__/fixtures/monorepo-root/tsconfig.json b/packages/integrate-ava/__tests__/fixtures/monorepo-root/tsconfig.json new file mode 100644 index 00000000..8d418c73 --- /dev/null +++ b/packages/integrate-ava/__tests__/fixtures/monorepo-root/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "experimentalDecorators": true, + "useDefineForClassFields": false + }, + "include": ["sub-project"] +} diff --git a/packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts b/packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts new file mode 100644 index 00000000..04025ece --- /dev/null +++ b/packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts @@ -0,0 +1,69 @@ +import test from "ava"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const SUB_PROJECT_DIR = fileURLToPath( + new URL("./fixtures/monorepo-root/sub-project/", import.meta.url), +); + +const DECORATOR_SOURCE = ` +function Count(...args) { + globalThis.__argCount = args.length; +} +@Count +class Foo {} +void Foo; +`; + +function transformInSubprocess( + cwd: string, + env: NodeJS.ProcessEnv = {}, +): { stdout: string; stderr: string; status: number | null } { + const script = ` + const { OxcTransformer } = await import("@oxc-node/core"); + const transformer = new OxcTransformer(process.cwd()); + const result = await transformer.transformAsync( + "decorator.ts", + ${JSON.stringify(DECORATOR_SOURCE)}, + ); + process.stdout.write(result.source()); + `; + const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], { + cwd, + encoding: "utf8", + env: { + ...process.env, + NODE_OPTIONS: undefined, + TS_NODE_PROJECT: undefined, + OXC_TSCONFIG_PATH: undefined, + ...env, + }, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + status: result.status, + }; +} + +test("walks up to parent tsconfig.json when sub-project has none", (t) => { + const { stdout, stderr, status } = transformInSubprocess(SUB_PROJECT_DIR); + t.is(status, 0, `subprocess failed: ${stderr}`); + t.regex( + stdout, + /_decorate\s*\(/, + "legacy decorator helper should be emitted when experimentalDecorators is read from parent tsconfig", + ); +}); + +test("explicit TS_NODE_PROJECT wins over walk-up", (t) => { + const { stdout, stderr, status } = transformInSubprocess(SUB_PROJECT_DIR, { + TS_NODE_PROJECT: "/this/path/does/not/exist.json", + }); + t.is(status, 0, `subprocess failed: ${stderr}`); + t.notRegex( + stdout, + /_decorate\s*\(/, + "walk-up must not run when TS_NODE_PROJECT is explicitly set, even if the file is missing", + ); +}); diff --git a/src/lib.rs b/src/lib.rs index 20a028b1..1a213b46 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -778,20 +778,43 @@ impl TryAsStr for Either4 { } } +fn find_tsconfig_upwards(start: &Path) -> Option { + for ancestor in start.ancestors() { + let candidate = ancestor.join("tsconfig.json"); + if fs::exists(&candidate).unwrap_or(false) { + return Some(candidate); + } + } + None +} + fn init_resolver( cwd: PathBuf, conditions: Vec, ) -> (Resolver, Option>, Option<&'static str>) { - let tsconfig = env::var("TS_NODE_PROJECT") + let (tsconfig, used_default_name) = env::var("TS_NODE_PROJECT") .or_else(|_| env::var("OXC_TSCONFIG_PATH")) - .map(Cow::Owned) - .unwrap_or(Cow::Borrowed("tsconfig.json")); - tracing::debug!(tsconfig = ?tsconfig); - let tsconfig_full_path = if !tsconfig.starts_with('/') { + .map(|v| (Cow::Owned(v), false)) + .unwrap_or((Cow::Borrowed("tsconfig.json"), true)); + tracing::debug!(tsconfig = ?tsconfig, used_default_name); + let initial_tsconfig_path = if !tsconfig.starts_with('/') { cwd.join(PathBuf::from(&*tsconfig)) } else { PathBuf::from(&*tsconfig) }; + let tsconfig_full_path = if fs::exists(&initial_tsconfig_path).unwrap_or(false) { + initial_tsconfig_path + } else if used_default_name { + match find_tsconfig_upwards(&cwd) { + Some(found) => { + tracing::debug!(discovered_tsconfig = ?found, "found tsconfig.json by walking up parents"); + found + } + None => initial_tsconfig_path, + } + } else { + initial_tsconfig_path + }; tracing::debug!(tsconfig_full_path = ?tsconfig_full_path); let tsconfig = fs::exists(&tsconfig_full_path) From 89128cc70f560faf7329943fb05d6dde468c4b60 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 24 Apr 2026 23:31:53 +0800 Subject: [PATCH 2/3] refactor: drop redundant exists() stat and fix Windows absolute path check - Track tsconfig_exists inline with path resolution so the manual TsconfigDiscovery gate no longer re-stats the same path. - Use Path::is_absolute() so an absolute TS_NODE_PROJECT/OXC_TSCONFIG_PATH on Windows (e.g. C:\\proj\\tsconfig.json) is not joined with cwd. --- src/lib.rs | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 1a213b46..0dba6499 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -797,32 +797,33 @@ fn init_resolver( .map(|v| (Cow::Owned(v), false)) .unwrap_or((Cow::Borrowed("tsconfig.json"), true)); tracing::debug!(tsconfig = ?tsconfig, used_default_name); - let initial_tsconfig_path = if !tsconfig.starts_with('/') { - cwd.join(PathBuf::from(&*tsconfig)) - } else { + let initial_tsconfig_path = if Path::new(&*tsconfig).is_absolute() { PathBuf::from(&*tsconfig) + } else { + cwd.join(PathBuf::from(&*tsconfig)) }; - let tsconfig_full_path = if fs::exists(&initial_tsconfig_path).unwrap_or(false) { - initial_tsconfig_path + let (tsconfig_full_path, tsconfig_exists) = if fs::exists(&initial_tsconfig_path) + .unwrap_or(false) + { + (initial_tsconfig_path, true) } else if used_default_name { match find_tsconfig_upwards(&cwd) { Some(found) => { tracing::debug!(discovered_tsconfig = ?found, "found tsconfig.json by walking up parents"); - found + (found, true) } - None => initial_tsconfig_path, + None => (initial_tsconfig_path, false), } } else { - initial_tsconfig_path + (initial_tsconfig_path, false) }; - tracing::debug!(tsconfig_full_path = ?tsconfig_full_path); - let tsconfig = - fs::exists(&tsconfig_full_path) - .unwrap_or(false) - .then_some(TsconfigDiscovery::Manual(TsconfigOptions { - config_file: tsconfig_full_path.clone(), - references: TsconfigReferences::Auto, - })); + tracing::debug!(tsconfig_full_path = ?tsconfig_full_path, tsconfig_exists); + let tsconfig = tsconfig_exists.then(|| { + TsconfigDiscovery::Manual(TsconfigOptions { + config_file: tsconfig_full_path.clone(), + references: TsconfigReferences::Auto, + }) + }); let resolver = Resolver::new(ResolveOptions { tsconfig, condition_names: conditions, From 9ebcf80a9bb70a0491fa63bfe45c08e866e19b2a Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 24 Apr 2026 23:47:31 +0800 Subject: [PATCH 3/3] test: skip tsconfig-discovery spec when forced to WASI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subprocess pattern used by this spec (`node --input-type=module -e` with an inline `await import("@oxc-node/core")`) crashes when NAPI_RS_FORCE_WASI=1 is inherited into the child — the forced-WASI worker init fails inside a nested inline ESM script. The walk-up logic itself is platform-agnostic and already exercised on every native target in CI. --- .../integrate-ava/__tests__/tsconfig-discovery.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts b/packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts index 04025ece..679aab87 100644 --- a/packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts +++ b/packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts @@ -46,7 +46,13 @@ function transformInSubprocess( }; } -test("walks up to parent tsconfig.json when sub-project has none", (t) => { +// Nested `--input-type=module -e` subprocesses that load @oxc-node/core via +// `await import()` crash on WASI (the forced-WASI worker init fails inside an +// inline ESM script). The walk-up logic is platform-agnostic, so we cover it +// on the native targets only. +const runTest = process.env.NAPI_RS_FORCE_WASI ? test.skip : test; + +runTest("walks up to parent tsconfig.json when sub-project has none", (t) => { const { stdout, stderr, status } = transformInSubprocess(SUB_PROJECT_DIR); t.is(status, 0, `subprocess failed: ${stderr}`); t.regex( @@ -56,7 +62,7 @@ test("walks up to parent tsconfig.json when sub-project has none", (t) => { ); }); -test("explicit TS_NODE_PROJECT wins over walk-up", (t) => { +runTest("explicit TS_NODE_PROJECT wins over walk-up", (t) => { const { stdout, stderr, status } = transformInSubprocess(SUB_PROJECT_DIR, { TS_NODE_PROJECT: "/this/path/does/not/exist.json", });