From 7dc497d854ae32d7964c2986693a71ec6040d68a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 3 Sep 2026 16:55:15 +0200 Subject: [PATCH 1/2] feat(tsconfig): `test-d.json`, the preset for a type-level test project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type-level test asserts with bindings nothing reads, so `noUnusedLocals` and `noUnusedParameters` have to be off for that project and on everywhere else. Every workspace wrote both flags out by hand, usually without the reason. It carries no `include`, and cannot: TypeScript resolves a base config's globs relative to the BASE file's directory, so a shipped one would point inside node_modules — measured, TS18003. `validate.mjs` refuses a `test-d.json` that grows one, so the limit is held rather than remembered. Claude-Session: https://claude.ai/code/session_01GGixjxi5AQ2cNK62bBymfF --- .changeset/tsconfig-test-d.md | 28 ++++++++++++++++++++++++++++ packages/tsconfig/README.md | 25 +++++++++++++++++++++++++ packages/tsconfig/package.json | 6 ++++-- packages/tsconfig/test-d.json | 7 +++++++ scripts/validate.mjs | 18 +++++++++++++++++- 5 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 .changeset/tsconfig-test-d.md create mode 100644 packages/tsconfig/test-d.json diff --git a/.changeset/tsconfig-test-d.md b/.changeset/tsconfig-test-d.md new file mode 100644 index 0000000..faeeff7 --- /dev/null +++ b/.changeset/tsconfig-test-d.md @@ -0,0 +1,28 @@ +--- +"@btravstack/tsconfig": minor +--- + +`test-d.json`: the preset for a type-level test project. + +Type-level tests assert with bindings nothing reads — `type _x = Expect>` +— so `noUnusedLocals` and `noUnusedParameters` must be off for that project and +on everywhere else. Every workspace with a `tsconfig.test-d.json` was writing +those two flags out by hand, along with the reason, or more often without it. + +```json +{ + "extends": ["./tsconfig.json", "@btravstack/tsconfig/test-d.json"], + "include": ["src/**/*.test-d.ts"] +} +``` + +The preset comes **last** in the array so its relaxations win over the strict +config beneath. + +It carries no `include`, and cannot: TypeScript resolves a base config's +`include` / `exclude` / `files` relative to the **base file's** own directory, +so a shipped glob would point inside `node_modules` and match nothing. Measured +— an `include` of `src/**/*.test-d.ts` in a base one directory up resolves to +`../base/src/**/*.test-d.ts` and reports `TS18003`. `scripts/validate.mjs` now +refuses a `test-d.json` that grows one, so the limit is held rather than +remembered. diff --git a/packages/tsconfig/README.md b/packages/tsconfig/README.md index c91c9a3..088e63c 100644 --- a/packages/tsconfig/README.md +++ b/packages/tsconfig/README.md @@ -47,6 +47,31 @@ symbols came first, and the sentence naming the missing port came third. With A library keeps `base.json`: there the declaration check is the guarantee that its consumers can build. +## `test-d.json`, for a type-level test project + +Type-level tests assert with bindings nothing reads — `type _x = Expect>` +— so `noUnusedLocals` and `noUnusedParameters` have to be off for that project +and on everywhere else. `test-d.json` is those two flags and nothing else, +composed with the workspace's own config through an `extends` array: + +```json +{ + "extends": ["./tsconfig.json", "@btravstack/tsconfig/test-d.json"], + "include": ["src/**/*.test-d.ts"] +} +``` + +Order matters: the preset comes **last**, so its relaxations win over the +strict config it is layered on. + +It carries no `include`, and cannot. TypeScript resolves a base config's +`include` / `exclude` / `files` relative to **the base file's own directory** +(measured: an `include` of `src/**/*.test-d.ts` in a base one directory up +resolves to `../base/src/**/*.test-d.ts` and reports `TS18003`), so a shipped +preset's globs would point inside `node_modules`. Each workspace states its +own — which is also where the answer belongs, since a workspace that keeps its +type tests somewhere else needs a different glob, not a different preset. + It does **not** set `types` — TypeScript auto-includes every reachable `@types/*` package (Node included). This avoids forcing each consuming package to declare a direct `@types/node` just to satisfy a `types: ["node"]` list. diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 18e43dd..d7f6418 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -20,12 +20,14 @@ }, "files": [ "app.json", - "base.json" + "base.json", + "test-d.json" ], "type": "module", "exports": { "./app.json": "./app.json", - "./base.json": "./base.json" + "./base.json": "./base.json", + "./test-d.json": "./test-d.json" }, "publishConfig": { "access": "public" diff --git a/packages/tsconfig/test-d.json b/packages/tsconfig/test-d.json new file mode 100644 index 0000000..ba44a29 --- /dev/null +++ b/packages/tsconfig/test-d.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false + } +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 0af74be..8c77d98 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -22,7 +22,7 @@ const check = (name, fn) => { // Every package ships exactly the files it lists, and those files load. const shipped = { - tsconfig: ["app.json", "base.json"], + tsconfig: ["app.json", "base.json", "test-d.json"], typedoc: ["base.json"], oxlint: ["base.json"], oxfmt: ["base.json"], @@ -58,6 +58,22 @@ check("tsconfig/app.json extends base and emits no declarations", () => { } }); +check("tsconfig/test-d.json relaxes the unused checks and carries no paths", () => { + const testD = json("packages/tsconfig/test-d.json"); + // An assertion binding is never read, so both checks must be off — that is + // the whole reason this file exists. + for (const flag of ["noUnusedLocals", "noUnusedParameters"]) { + if (testD.compilerOptions?.[flag] !== false) throw new Error(`${flag} must be false`); + } + // Measured, not assumed: TypeScript resolves a base config's `include` / + // `exclude` / `files` relative to the BASE file's own directory, so an + // `include` here would resolve inside node_modules and match nothing. Each + // workspace keeps its own, and this preset must never grow one. + for (const key of ["include", "exclude", "files", "extends"]) { + if (key in testD) throw new Error(`test-d.json must not carry "${key}"`); + } +}); + check("typedoc/base.json loads the markdown plugin", () => { const td = json("packages/typedoc/base.json"); if (!td.plugin?.includes("typedoc-plugin-markdown")) { From 88a34ba17ca5f36ce106f8a86f53005a02d2a1bd Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 3 Sep 2026 17:02:33 +0200 Subject: [PATCH 2/2] fix(tsconfig): hold `test-d.json`'s shape with an allow-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deny-list of four keys lets the fifth through, and this preset is layered LAST in a consumer's `extends` array — anything it grows silently overrides the workspace's own config. `validate.mjs` now names what may appear: `$schema` plus exactly the two unused checks. Also: the README said "those two flags and nothing else" of a file that also ships `$schema`. Claude-Session: https://claude.ai/code/session_01GGixjxi5AQ2cNK62bBymfF --- .changeset/tsconfig-test-d.md | 9 ++++++--- packages/tsconfig/README.md | 5 +++-- scripts/validate.mjs | 34 +++++++++++++++++++++++----------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/.changeset/tsconfig-test-d.md b/.changeset/tsconfig-test-d.md index faeeff7..cf41f1a 100644 --- a/.changeset/tsconfig-test-d.md +++ b/.changeset/tsconfig-test-d.md @@ -23,6 +23,9 @@ It carries no `include`, and cannot: TypeScript resolves a base config's `include` / `exclude` / `files` relative to the **base file's** own directory, so a shipped glob would point inside `node_modules` and match nothing. Measured — an `include` of `src/**/*.test-d.ts` in a base one directory up resolves to -`../base/src/**/*.test-d.ts` and reports `TS18003`. `scripts/validate.mjs` now -refuses a `test-d.json` that grows one, so the limit is held rather than -remembered. +`../base/src/**/*.test-d.ts` and reports `TS18003`. `scripts/validate.mjs` holds the shape as an +**allow-list** — `$schema` plus exactly those two `compilerOptions` — rather +than forbidding the four keys a reader would reach for. The preset is layered +last in a consumer's `extends` array, so anything it grows silently overrides +the workspace's own config, and only naming what may appear catches a key +nobody thought to forbid. diff --git a/packages/tsconfig/README.md b/packages/tsconfig/README.md index 088e63c..0511453 100644 --- a/packages/tsconfig/README.md +++ b/packages/tsconfig/README.md @@ -51,8 +51,9 @@ its consumers can build. Type-level tests assert with bindings nothing reads — `type _x = Expect>` — so `noUnusedLocals` and `noUnusedParameters` have to be off for that project -and on everywhere else. `test-d.json` is those two flags and nothing else, -composed with the workspace's own config through an `extends` array: +and on everywhere else. `test-d.json` sets exactly those two flags — nothing +else, which `scripts/validate.mjs` enforces as an allow-list — and composes +with the workspace's own config through an `extends` array: ```json { diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 8c77d98..c684724 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -58,19 +58,31 @@ check("tsconfig/app.json extends base and emits no declarations", () => { } }); -check("tsconfig/test-d.json relaxes the unused checks and carries no paths", () => { +check("tsconfig/test-d.json is exactly the two unused-check relaxations", () => { const testD = json("packages/tsconfig/test-d.json"); - // An assertion binding is never read, so both checks must be off — that is - // the whole reason this file exists. - for (const flag of ["noUnusedLocals", "noUnusedParameters"]) { - if (testD.compilerOptions?.[flag] !== false) throw new Error(`${flag} must be false`); + // An ALLOW-list, not a deny-list: this preset is layered LAST in a consumer's + // `extends` array, so anything it grows silently overrides the workspace's + // own config. Naming what may appear is the only shape that catches a key + // nobody thought to forbid. + // + // `include` / `exclude` / `files` are the ones a reader will reach for and + // they cannot work here: TypeScript resolves a base config's globs relative + // to the BASE file's own directory, so a shipped glob points inside + // node_modules and matches nothing (measured: TS18003). + const top = Object.keys(testD).sort(); + if (top.join() !== "$schema,compilerOptions") { + throw new Error(`top-level keys must be $schema + compilerOptions, got: ${top.join(", ")}`); } - // Measured, not assumed: TypeScript resolves a base config's `include` / - // `exclude` / `files` relative to the BASE file's own directory, so an - // `include` here would resolve inside node_modules and match nothing. Each - // workspace keeps its own, and this preset must never grow one. - for (const key of ["include", "exclude", "files", "extends"]) { - if (key in testD) throw new Error(`test-d.json must not carry "${key}"`); + const options = Object.keys(testD.compilerOptions ?? {}).sort(); + if (options.join() !== "noUnusedLocals,noUnusedParameters") { + throw new Error( + `compilerOptions must be exactly the two unused checks, got: ${options.join(", ")}`, + ); + } + // An assertion binding is never read, so both must be OFF — the whole reason + // this file exists. + for (const flag of options) { + if (testD.compilerOptions[flag] !== false) throw new Error(`${flag} must be false`); } });