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