Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/tsconfig-test-d.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@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<Equal<A, B>>`
— 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` 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.
26 changes: 26 additions & 0 deletions packages/tsconfig/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,32 @@ 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<Equal<A, B>>`
— so `noUnusedLocals` and `noUnusedParameters` have to be off for that project
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
{
"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.
Expand Down
6 changes: 4 additions & 2 deletions packages/tsconfig/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions packages/tsconfig/test-d.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"noUnusedLocals": false,
"noUnusedParameters": false
}
}
30 changes: 29 additions & 1 deletion scripts/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -58,6 +58,34 @@ check("tsconfig/app.json extends base and emits no declarations", () => {
}
});

check("tsconfig/test-d.json is exactly the two unused-check relaxations", () => {
const testD = json("packages/tsconfig/test-d.json");
// 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(", ")}`);
}
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`);
}
});

check("typedoc/base.json loads the markdown plugin", () => {
const td = json("packages/typedoc/base.json");
if (!td.plugin?.includes("typedoc-plugin-markdown")) {
Expand Down
Loading