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..679aab87 --- /dev/null +++ b/packages/integrate-ava/__tests__/tsconfig-discovery.spec.ts @@ -0,0 +1,75 @@ +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, + }; +} + +// 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( + stdout, + /_decorate\s*\(/, + "legacy decorator helper should be emitted when experimentalDecorators is read from parent tsconfig", + ); +}); + +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", + }); + 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..0dba6499 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -778,28 +778,52 @@ 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 Path::new(&*tsconfig).is_absolute() { + PathBuf::from(&*tsconfig) + } else { cwd.join(PathBuf::from(&*tsconfig)) + }; + 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, true) + } + None => (initial_tsconfig_path, false), + } } else { - PathBuf::from(&*tsconfig) + (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,