@williamthorsen/nmr 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,17 +3,47 @@
3
3
  Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
4
4
 
5
5
  <!-- section:release-notes -->
6
- ## Release notes — v0.22.0 (2026-07-31)
6
+ ## Release notes — v0.24.0 (2026-08-03)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - 🚨 **Breaking:** Ship shell and Dockerfile formatting from a shared nmr Prettier config (#540)
10
+ - 🚨 **Breaking:** Retire integration label in favor of an isolation-tier test ladder (#552)
11
11
 
12
- Adds the ability to format shell scripts and Dockerfiles to `nmr fmt`. `@williamthorsen/nmr` now provides a common config for consistency across projects. Shell fences inside Markdown are now formatted as well. These features are absorbed into `nmr fmt` and `nmr fmt:check`; the `fmt:sh` and `fmt:all` commands are removed.
12
+ Test groups are now named for what a test reaches rather than for how much of the codebase it covers: `integration` and `app` are replaced by `unit`, `tool`, `localhost`, and `remote`.
13
13
 
14
- - 🚨 **Breaking:** Require the monorepo root in the shared root Vitest config (#541)
14
+ `nmr test` now runs the `tool` group alongside `unit`, so the first run after upgrading may fail on tests that nothing was running before. Upgrading means renaming `*.int.test.ts` files onto the new groups and replacing `nmr test:integration` with `nmr test:tool`.
15
15
 
16
- Modifies the testing configuration so that root-level test runs always cover the repository containing the configuration. The shared root Vitest config now requires a `monorepoRoot` option specifying an absolute path to the repository's own root, in place of the optional search origin. A repository nested inside a parent monorepo fails instead of resolving silently to its parent.
16
+ - Let Vitest config files compose shared options and target one tier (#556)
17
+
18
+ Vitest settings can now be shared across config files: A repo declares its common options once, and a workspace can use its own config file to supplement or override them. Shared setup files run first. An option can now target a single test tier instead of all tiers, allowing per-tier timeouts to be set.
19
+
20
+ - 🚨 **Breaking:** Redefine ci as the code-quality gate and add prepush (#559)
21
+
22
+ Repurposes the `nmr ci` command to align with its name; it now runs the code-quality checks that run in CI: `nmr build && nmr check:strict`. A new `nmr prepush` command, combining code-quality checks and the security audit, takes the place of `nmr ci`. nmr's readyup kit is adjusted to account for the changes.
23
+
24
+ - Exclude test scaffolding from builds and make the ignore set configurable (#564)
25
+
26
+ The `build` command now ignores the directories typically used for test scaffolding, such as fixtures and mocks. The exclusion includes `test-utils/`, which is treated as the conventional name for a directory containing test utilities. A package can declare additional ignore patterns in its config file.
27
+
28
+ - Skip checks that already passed on the current working tree (#568)
29
+
30
+ `nmr` commands now skip any pure check (types, lint, tests) that has already passed on an unchanged working tree; instead, success is reported immediately. `nmr --no-cache` causes the cache check to be skipped, and `NMR_NO_CACHE=1` does the same for a whole shell. Cached results are cleared by `nmr clean`. Which commands can skip is configurable at the monorepo root, and `NMR_DEBUG=1` reports why a run didn't skip.
31
+
32
+ ### 🪦 Removed
33
+
34
+ - 🚨 **Breaking:** Remove the package root entry in favor of /config (#569)
35
+
36
+ `defineConfig` is now imported from `@williamthorsen/nmr/config` instead of the bare package.
37
+
38
+ ### 🐛 Bug fixes
39
+
40
+ - Stop excluding *.types.ts and mock* files from coverage
41
+
42
+ Coverage now measures modules matching `*.types.ts` and `mock*`, which the shared Vitest config previously excluded. A type module that grows a type guard, or a mock helper placed in `test-utils/` where helpers are meant to stay measured, was left out of the report with nothing saying so, and because exclusions concatenate, no consuming repo could add it back.
43
+
44
+ What remains excluded names something that cannot hold runtime code by construction: a directory of test scaffolding, a barrel, and a declaration file. A pattern resting on a filename alone suppresses nothing it could claim to, since a file with no executable statements is absent from the report either way.
45
+
46
+ A repo enforcing its own coverage thresholds may see a lower figure once these files enter the denominator.
17
47
  <!-- /section:release-notes -->
18
48
 
19
49
  ## Installation
@@ -48,9 +78,9 @@ nmr check # Typecheck, format check, lint check, and tests
48
78
  From the monorepo root:
49
79
 
50
80
  ```bash
51
- nmr test # Run root tests + recursive workspace tests
52
- nmr build # Build all packages
53
- nmr ci # Run build && check:strict && audit (full CI pipeline)
81
+ nmr test # Run root tests + recursive workspace tests
82
+ nmr build # Build all packages
83
+ nmr prepush # Run everything the remote runs, before you push
54
84
  ```
55
85
 
56
86
  nmr detects where you are and selects the right scripts automatically — see [context-aware resolution](#context-aware-resolution) below.
@@ -59,11 +89,11 @@ nmr detects where you are and selects the right scripts automatically — see [c
59
89
 
60
90
  nmr's key feature is that the same command runs different scripts depending on where you invoke it. It walks up from your current directory to find `pnpm-workspace.yaml`, then checks whether your CWD is inside a workspace package directory.
61
91
 
62
- | Where you run `nmr` | Registry used | Working directory | `nmr test` runs |
63
- | --------------------------------- | ----------------- | ----------------- | --------------------------------------------------------------- |
64
- | Monorepo root | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
65
- | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest --project '!integration'` (that package only) |
66
- | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
92
+ | Where you run `nmr` | Registry used | Working directory | `nmr test` runs |
93
+ | --------------------------------- | ----------------- | ----------------- | -------------------------------------------------------------------- |
94
+ | Monorepo root | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
95
+ | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest --project unit --project tool` (that package only) |
96
+ | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
67
97
 
68
98
  Relative paths in a script resolve against that working directory, not the invocation directory.
69
99
 
@@ -114,10 +144,10 @@ Passthrough arguments attach to the final step alone, because the expansion is a
114
144
 
115
145
  ## Configuration
116
146
 
117
- Create `.config/nmr.config.ts` in the monorepo root to add or override scripts:
147
+ Create `.config/nmr.config.ts` in the monorepo root to add or override scripts. A package may carry one too, for [build settings of its own](#package-level-configuration):
118
148
 
119
149
  ```ts
120
- import { defineConfig } from '@williamthorsen/nmr';
150
+ import { defineConfig } from '@williamthorsen/nmr/config';
121
151
 
122
152
  export default defineConfig({
123
153
  workspaceScripts: {
@@ -131,14 +161,35 @@ export default defineConfig({
131
161
 
132
162
  ### `defineConfig` fields
133
163
 
134
- | Field | Type | Description |
135
- | ------------------ | ------------------------------------ | -------------------------------------------------------------- |
136
- | `workspaceScripts` | `Record<string, string \| string[]>` | Scripts added or overridden in the workspace registry (tier 2) |
137
- | `rootScripts` | `Record<string, string \| string[]>` | Scripts added or overridden in the root registry (tier 2) |
138
- | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
164
+ | Field | Type | Description |
165
+ | ------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------------- |
166
+ | `build` | `{ extraIgnorePatterns?: string[] }` | Patterns added to the build's ignore set (package config only) |
167
+ | `checkCache` | `{ enabled?: boolean; extraCommands?: string[]; excludeCommands?: string[] }` | Which commands the [check-result cache](#check-result-cache) may skip |
168
+ | `workspaceScripts` | `Record<string, string \| string[]>` | Scripts added or overridden in the workspace registry (tier 2) |
169
+ | `rootScripts` | `Record<string, string \| string[]>` | Scripts added or overridden in the root registry (tier 2) |
170
+ | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
139
171
 
140
172
  All fields are optional. `workspaceScripts` and `rootScripts` values follow the same `string | string[]` convention described in [script values](#script-values).
141
173
 
174
+ Each field belongs to exactly one tier, and a config loads only the fields its own tier honors: `build` in a package config, the other four in the monorepo-root config. Declaring a field at the wrong tier fails with a message naming it and where it goes, as does a key nmr does not recognize at all — a typo cannot degrade into a setting that silently applies nowhere.
175
+
176
+ ### Package-level configuration
177
+
178
+ A package may carry its own `.config/nmr.config.ts`, which `nmr-compile` reads from the package it is compiling. This tier honors `build` alone; declaring `workspaceScripts`, `rootScripts`, or `devBin` there fails with a message naming them, rather than being quietly ignored — those belong in the monorepo-root config, and a package that appeared to set them would otherwise build on settings nothing applied. The monorepo-root config carries the mirror restriction: `build` there fails the same way, since only a package's own config reaches the compile.
179
+
180
+ ```ts
181
+ // packages/my-package/.config/nmr.config.ts
182
+ import { defineConfig } from '@williamthorsen/nmr/config';
183
+
184
+ export default defineConfig({
185
+ build: { extraIgnorePatterns: ['**/fixtures/**'] },
186
+ });
187
+ ```
188
+
189
+ `extraIgnorePatterns` adds to the build's [default ignore set](#nmr-compile) rather than replacing it, so declaring a pattern cannot start a package shipping its own tests. The programmatic `buildPackage` option of the same name behaves identically; its bare `ignorePatterns` is the one that replaces.
190
+
191
+ > **Building nmr from source:** a config file that has to load while nmr itself is being built — before its `dist` exists — must use the type-only form, `import type { NmrConfig } from '@williamthorsen/nmr/config'` with `satisfies NmrConfig`, which carries no runtime import. `defineConfig` resolves through nmr's build output and so is unavailable at that moment. Every other config, including every consumer's, installs nmr from a tarball that ships `dist` and can use `defineConfig` freely.
192
+
142
193
  ### `devBin` — source-repo binary substitution
143
194
 
144
195
  When developing a CLI tool inside the monorepo, the published binary may not reflect your latest source changes. `devBin` lets you map a binary name to a replacement command that runs from source:
@@ -173,7 +224,7 @@ Extend `nmr build` with a pre-build step for every workspace package:
173
224
 
174
225
  ```ts
175
226
  // .config/nmr.config.ts
176
- import { defineConfig } from '@williamthorsen/nmr';
227
+ import { defineConfig } from '@williamthorsen/nmr/config';
177
228
 
178
229
  export default defineConfig({
179
230
  workspaceScripts: {
@@ -195,6 +246,114 @@ Attach a post-build step to one package:
195
246
 
196
247
  The second example calls the bin directly, which sidesteps the workspace-versus-root registry distinction.
197
248
 
249
+ ## Check-result cache
250
+
251
+ A check that passed on a working tree passes again on the same working tree. nmr records that, and the next run of the same command on an unchanged tree exits 0 without doing the work:
252
+
253
+ ```console
254
+ $ nmr ci
255
+ # ... four minutes of build, typecheck, lint, and coverage ...
256
+
257
+ $ nmr ci
258
+ ⏭️ ci: passed 2m ago on this tree (saved ~4m). Re-run with --no-cache.
259
+ ```
260
+
261
+ Composite commands expand into child `nmr` processes, so one green `nmr ci` records a pass for every cacheable command in the chain, at every scope it ran at. A later `nmr check`, `nmr typecheck`, or `nmr -F core test` on the same tree skips too. The skip line is suppressed by `-q`.
262
+
263
+ The cache is a working-tree cache, not a build cache: it answers "has this exact tree already passed this check?" and nothing else. It is also local to one checkout (the install fingerprint in its key is machine-specific), so it saves repeated work in a working copy rather than sharing results across machines or with CI.
264
+
265
+ ### What is cached
266
+
267
+ Cacheable by default: `check`, `check:agent-files`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
268
+
269
+ Everything else runs every time:
270
+
271
+ | Command | Why it is never skipped |
272
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
273
+ | `audit`, `prepush` | Consult a vulnerability database that moves without the tree. `prepush`'s `ci` constituent still skips while its `audit` runs every time. |
274
+ | `build`, `compile` | Carry a cache of their own. |
275
+ | `fix`, `fmt`, `lint`, `upgrade` | Rewrite the tree they were asked about, so a recorded pass would describe a tree that no longer exists. |
276
+ | `test:all` | Reaches the `localhost` and `remote` tiers, which the environment supplies rather than the tree. |
277
+
278
+ ### The exit-status-only contract
279
+
280
+ **A cacheable name promises that its whole contribution is an exit status**, through its entire chain, `:pre` and `:post` hooks included. A hit skips that chain wholesale, so any file a cacheable command was relied on to produce is a file that will not appear.
281
+
282
+ `test:coverage` is cacheable on exactly this reading: what it contributes is the pass, not the `coverage/` directory, which a skipped run leaves as whatever the last real run wrote. Read `coverage/` after a `test:coverage` that may have skipped, and it may be stale; `--no-cache` is how you insist on a fresh one.
283
+
284
+ Overriding a cacheable name, in `package.json` or in `workspaceScripts`, inherits its cacheability. An override that writes something anyone depends on belongs in `excludeCommands`.
285
+
286
+ ### What the key is made of
287
+
288
+ A hit requires all of these to match the run that recorded the pass:
289
+
290
+ - **The working tree's content**, hashed from the commit's tree object folded with the current content of every path git reports as changed or untracked. Timestamps are not content, so a `touch` that rewrites no bytes leaves the hash alone.
291
+ - **The command string that would run**, hooks and all, and the scope it would run in.
292
+ - **nmr's version**, the **Node version**, and the **platform and architecture**.
293
+ - **What pnpm has installed**, so an install, a prune, or a lockfile change forces a re-run.
294
+ - **`TZ`, `LANG`, `LC_ALL`, and `NODE_OPTIONS`.** This set is fixed, so two machines that differ only in shell decoration still agree.
295
+
296
+ A hit additionally requires the build output of every package nmr's own build covers to be present, and to have been built from this tree. Output is git-ignored, so neither its removal nor its replacement moves the hash: restoring a tree with `git stash` or `git checkout` restores none of the output that tree was built with. nmr compares each covered package's recorded build digest against the one on disk, so a `dist` compiled from another tree is a miss the following run repairs. A package that overrides `build` or `compile` emits on terms nmr does not know and is exempt.
297
+
298
+ A pass is recorded only when the command exits 0, only when the tree still matches the one the run started against, only when every covered package's output is present, and never for a run that executed nothing (a `""`/`":"` skip override, or an `NMR_RUN_IF_PRESENT` miss).
299
+
300
+ ### Out of contract
301
+
302
+ These change what a check concludes without moving the hash. Where one applies, exclude the affected command:
303
+
304
+ - **Content filters.** `core.autocrlf` and `.gitattributes` filters mean the bytes on disk differ from the bytes git records; the hash follows what is on disk, but a checkout on another platform may not reproduce it.
305
+ - **Symlinked inputs.** A symlink is hashed by the path it names, not by the content at the far end.
306
+ - **Gitignored inputs.** Anything git does not report is invisible to the hash. The build-output probe is the one exception, and covers only nmr's own build.
307
+ - **`passWithNoTests`.** A suite that collects no files exits 0, and that green is recorded like any other.
308
+
309
+ Committing an already-checked tree also moves the hash, because the commit's tree object is the base of the fold; the next run does the work again. The reverse holds and is useful: a rebase or an amended message that preserves content leaves the hash alone, as does checking out a branch whose tree is identical.
310
+
311
+ ### When the gate stands aside
312
+
313
+ The gate never wrongly skips. Where it cannot be sure, it does nothing and the command runs:
314
+
315
+ - Outside a git repository, when git fails, or when there is no commit at `HEAD`.
316
+ - When the repository declares or contains submodules, whose content the hash does not cover.
317
+ - When the monorepo root is not the git toplevel.
318
+ - When a changed path cannot be read, or is neither a file nor a symlink (an untracked nested repository, for instance).
319
+ - When there is no pnpm install to fingerprint.
320
+ - Under a `devBin` substitution, which runs a binary built from somewhere the hash does not describe.
321
+ - For any invocation carrying arguments after the command name.
322
+
323
+ `NMR_DEBUG=1` reports why a run did not skip and why the gate stood aside.
324
+
325
+ ### Bypassing and clearing
326
+
327
+ Reach for these in order; the first is almost always the right one.
328
+
329
+ | Step | Effect |
330
+ | -------------------------------------- | ------------------------------------------------------------------------- |
331
+ | `nmr --no-cache <command>` | Runs the command, then records the fresh result. Reaches the whole chain. |
332
+ | `NMR_NO_CACHE=1` | The same, for every nmr invocation in the shell. |
333
+ | `rm -rf node_modules/.cache/nmr-check` | Forgets every recorded pass, leaving build output alone. |
334
+ | `nmr clean` | Forgets every recorded pass and removes build output, at any scope. |
335
+
336
+ `--no-cache` belongs before the command name. After it, it is an argument to the command rather than a flag to nmr; nmr says so rather than letting the bypass silently not happen.
337
+
338
+ ### Reserved environment variables
339
+
340
+ `NMR_TREE_SNAPSHOT` is nmr's own: it carries one observation of the tree from a top-level invocation down to the processes it spawns, so a chain hashes the tree once rather than at every link. nmr trusts an inherited value only while `HEAD` still stands where it did when the observation was taken, which bounds a process that outlives the run that spawned it. That bound does not extend to a tree edited without committing, so **a process that survives its run and later invokes nmr should clear `NMR_TREE_SNAPSHOT`** — a test suite that shells out to `nmr` is the case worth checking.
341
+
342
+ ### Configuring
343
+
344
+ ```ts
345
+ export default defineConfig({
346
+ checkCache: {
347
+ // Names promising exit-status-only semantics through their whole chain.
348
+ extraCommands: ['verify:contracts'],
349
+ // Names that turned out to do more than report an exit status.
350
+ excludeCommands: ['test:coverage'],
351
+ },
352
+ });
353
+ ```
354
+
355
+ `extraCommands` extends the default set rather than replacing it, so naming one command cannot silently drop the rest; `excludeCommands` is applied afterwards, so a name in both is excluded. `enabled: false` turns the gate off entirely. The key is `checkCache`, in the monorepo-root config.
356
+
198
357
  ## Dependency upgrades
199
358
 
200
359
  `nmr upgrade` reports the dependency upgrades available to your repo. What it covers depends on where you run it:
@@ -243,58 +402,65 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
243
402
 
244
403
  ### Workspace scripts
245
404
 
246
- | Command | Runs |
247
- | ------------------ | -------------------------------------------------------- |
248
- | `build` | `compile` |
249
- | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
250
- | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
251
- | `clean` | `nmr-clean` |
252
- | `compile` | `nmr-compile` |
253
- | `fix` | `lint`, `fmt` |
254
- | `fix:check` | `fmt:check`, `lint:check` |
255
- | `fmt` | `nmr-fmt --write` |
256
- | `fmt:check` | `nmr-fmt --check` |
257
- | `lint` | `eslint --fix .` |
258
- | `lint:check` | `eslint .` |
259
- | `lint:strict` | `strict-lint` |
260
- | `test` | `pnpm exec vitest --project '!integration'` |
261
- | `test:all` | `pnpm exec vitest` |
262
- | `test:coverage` | `pnpm exec vitest --project '!integration' --coverage` |
263
- | `test:integration` | `pnpm exec vitest --project integration` |
264
- | `test:watch` | `pnpm exec vitest --project '!integration' --watch` |
265
- | `typecheck` | `tsgo --noEmit` |
266
- | `upgrade` | `nmr-taze --include-locked` |
267
- | `view-coverage` | `open coverage/index.html` |
405
+ | Command | Runs |
406
+ | --------------- | ----------------------------------------------------------- |
407
+ | `build` | `compile` |
408
+ | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
409
+ | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
410
+ | `clean` | `nmr-clean` |
411
+ | `compile` | `nmr-compile` |
412
+ | `fix` | `lint`, `fmt` |
413
+ | `fix:check` | `fmt:check`, `lint:check` |
414
+ | `fmt` | `nmr-fmt --write` |
415
+ | `fmt:check` | `nmr-fmt --check` |
416
+ | `lint` | `eslint --fix .` |
417
+ | `lint:check` | `eslint .` |
418
+ | `lint:strict` | `strict-lint` |
419
+ | `test` | `pnpm exec vitest --project unit --project tool` |
420
+ | `test:all` | `pnpm exec vitest` |
421
+ | `test:coverage` | `pnpm exec vitest --project unit --project tool --coverage` |
422
+ | `test:tool` | `pnpm exec vitest --project tool` |
423
+ | `test:unit` | `pnpm exec vitest --project unit` |
424
+ | `test:watch` | `pnpm exec vitest --project unit --project tool --watch` |
425
+ | `typecheck` | `tsgo --noEmit` |
426
+ | `upgrade` | `nmr-taze --include-locked` |
427
+ | `view-coverage` | `open coverage/index.html` |
268
428
 
269
429
  `fmt` and `fmt:check` select their files through git rather than by walking the directory, which is what makes a package-level run and a root-level run apply the same ignore rules — see [file selection](#file-selection).
270
430
 
271
431
  #### Test selections
272
432
 
273
- Every package resolves the same five test commands. Nothing is detected on disk: the commands select [Vitest projects](#shared-vitest-config), so a package separates its integration tests by naming them `*.int.test.ts`, not by carrying extra config files.
433
+ Every package resolves the same six test commands. Nothing is detected on disk: the commands select [Vitest projects](#shared-vitest-config), so a package separates its tool-tier tests by naming them `*.tool.test.ts`, not by carrying extra config files.
274
434
 
275
- | To run | Command |
276
- | ----------------------------------- | ------------------ |
277
- | Everything except integration tests | `test` |
278
- | Integration tests alone | `test:integration` |
279
- | Every project | `test:all` |
435
+ | To run | Command |
436
+ | --------------------------------------------- | ----------- |
437
+ | Everything that runs on a bare install | `test` |
438
+ | The fastest tier alone | `test:unit` |
439
+ | Tests reaching a program alone | `test:tool` |
440
+ | Every tier, including those needing a service | `test:all` |
280
441
 
281
- `test` negates `integration` rather than naming the code-only projects, so a category added later joins the default run instead of being dropped from it.
442
+ `test` names the tiers it runs rather than negating the ones it skips, so a tier added in a later release is opt-in rather than joining the default run on arrival. It covers `unit` and `tool`, the two that need nothing beyond a checkout and an install; `localhost` and `remote` are reached only through `test:all` or an explicit `--project`.
282
443
 
283
- `test:coverage` and `test:watch` are the same selection as `test` in a different mode. There are no `test:integration --coverage` equivalents because none are needed: everything after a command name is forwarded untouched, so `nmr test:integration --coverage` already works.
444
+ `test:coverage` and `test:watch` are the same selection as `test` in a different mode. There are no `test:tool --coverage` equivalents because none are needed: everything after a command name is forwarded untouched, so `nmr test:tool --coverage` already works.
284
445
 
285
- To narrow to a single project, go through `test:all`: `nmr test:all --project app`. Narrowing through `test` does not work, because a second `--project` widens the filter rather than restricting it.
446
+ To narrow to a single project, go through `test:all`: `nmr test:all --project remote`. Narrowing through `test` does not work, because a second `--project` widens the selection rather than restricting it.
286
447
 
287
- A run that collects no test files passes. That is what lets `nmr test:integration` fan out across a monorepo in which most packages have no integration tests, and it means a package with no tests at all needs no `"test": ""` override.
448
+ A run that collects no test files passes. That is what lets `nmr test:tool` fan out across a monorepo in which most packages hold no tool-tier tests, and it means a package with no tests at all needs no `"test": ""` override.
288
449
 
289
450
  ### Root scripts
290
451
 
291
452
  #### Build and CI
292
453
 
293
- | Command | Runs |
294
- | ------- | --------------------------------- |
295
- | `build` | `pnpm --recursive exec nmr build` |
296
- | `ci` | `build`, `check:strict`, `audit` |
297
- | `clean` | `nmr-clean` |
454
+ | Command | Runs |
455
+ | --------- | --------------------------------- |
456
+ | `build` | `pnpm --recursive exec nmr build` |
457
+ | `ci` | `build`, `check:strict` |
458
+ | `clean` | `nmr-clean` |
459
+ | `prepush` | `ci`, `audit` |
460
+
461
+ `ci` is what a code-quality workflow runs; it leaves out the audit, which belongs in a workflow of its own. `prepush` is what a developer runs before pushing: both gates. It composes `ci` rather than restating its stages, so a stage added to `ci` joins the pre-push run too.
462
+
463
+ Neither is bound to a git hook. `prepush` is named for when you run it, not for a hook nmr installs.
298
464
 
299
465
  #### Check and quality
300
466
 
@@ -312,15 +478,16 @@ A run that collects no test files passes. That is what lets `nmr test:integratio
312
478
 
313
479
  #### Test
314
480
 
315
- The same five names the workspace registry carries, so a command means the same thing from the monorepo root as from inside a package. Each fans out to root-level files and every workspace package.
481
+ The same six names the workspace registry carries, so a command means the same thing from the monorepo root as from inside a package. Each fans out to root-level files and every workspace package.
316
482
 
317
- | Command | Runs |
318
- | ------------------ | ------------------------------------------------------------------------- |
319
- | `test` | `nmr root:test && pnpm --recursive exec nmr test` |
320
- | `test:all` | `nmr root:test:all && pnpm --recursive exec nmr test:all` |
321
- | `test:coverage` | `nmr root:test && pnpm --recursive exec nmr test:coverage` |
322
- | `test:integration` | `nmr root:test:integration && pnpm --recursive exec nmr test:integration` |
323
- | `test:watch` | `vitest --project '!integration' --watch` |
483
+ | Command | Runs |
484
+ | --------------- | ----------------------------------------------------------- |
485
+ | `test` | `nmr root:test && pnpm --recursive exec nmr test` |
486
+ | `test:all` | `nmr root:test:all && pnpm --recursive exec nmr test:all` |
487
+ | `test:coverage` | `nmr root:test && pnpm --recursive exec nmr test:coverage` |
488
+ | `test:tool` | `nmr root:test:tool && pnpm --recursive exec nmr test:tool` |
489
+ | `test:unit` | `nmr root:test:unit && pnpm --recursive exec nmr test:unit` |
490
+ | `test:watch` | `vitest --project unit --project tool --watch` |
324
491
 
325
492
  `test:coverage` chains `root:test` rather than a `root:test:coverage`, because the root config reports no coverage of its own; packages cover their own sources.
326
493
 
@@ -373,17 +540,18 @@ See [dependency upgrades](#dependency-upgrades) for the workflow and its configu
373
540
 
374
541
  These scripts operate on root-level code only (not workspace packages):
375
542
 
376
- | Command | Runs |
377
- | ----------------------- | ------------------------------------------------------------------ |
378
- | `root:check` | `root:typecheck`, `fmt:check`, `root:lint:check`, `root:test` |
379
- | `root:lint` | `eslint --fix --ignore-pattern 'packages/**' .` |
380
- | `root:lint:check` | `eslint --ignore-pattern 'packages/**' .` |
381
- | `root:lint:strict` | `strict-lint --ignore-pattern 'packages/**' .` |
382
- | `root:test` | `vitest --config ./vitest.root.config.ts --project '!integration'` |
383
- | `root:test:all` | `vitest --config ./vitest.root.config.ts` |
384
- | `root:test:integration` | `vitest --config ./vitest.root.config.ts --project integration` |
385
- | `root:typecheck` | `tsgo --noEmit` |
386
- | `root:upgrade` | `nmr-taze --include-locked` |
543
+ | Command | Runs |
544
+ | ------------------ | ----------------------------------------------------------------------- |
545
+ | `root:check` | `root:typecheck`, `fmt:check`, `root:lint:check`, `root:test` |
546
+ | `root:lint` | `eslint --fix --ignore-pattern 'packages/**' .` |
547
+ | `root:lint:check` | `eslint --ignore-pattern 'packages/**' .` |
548
+ | `root:lint:strict` | `strict-lint --ignore-pattern 'packages/**' .` |
549
+ | `root:test` | `vitest --config ./vitest.root.config.ts --project unit --project tool` |
550
+ | `root:test:all` | `vitest --config ./vitest.root.config.ts` |
551
+ | `root:test:tool` | `vitest --config ./vitest.root.config.ts --project tool` |
552
+ | `root:test:unit` | `vitest --config ./vitest.root.config.ts --project unit` |
553
+ | `root:typecheck` | `tsgo --noEmit` |
554
+ | `root:upgrade` | `nmr-taze --include-locked` |
387
555
 
388
556
  #### Utilities
389
557
 
@@ -402,14 +570,15 @@ nmr [flags] <command> [args...]
402
570
 
403
571
  Position determines ownership: flags before the command name are nmr's own, and everything after the command name is forwarded untouched to the resolved command.
404
572
 
405
- | Flag | Description | Default |
406
- | ------------------------ | --------------------------------------------------- | ------- |
407
- | `-F, --filter <pattern>` | Run command in matching packages | — |
408
- | `-R, --recursive` | Run command in all packages | — |
409
- | `-w, --workspace-root` | Use root scripts, running at the monorepo root | — |
410
- | `-q, --quiet` | Suppress info messages; show full output on failure | — |
411
- | `-?, --help` | Show available commands | — |
412
- | `-V, --version` | Show version number | — |
573
+ | Flag | Description | Default |
574
+ | ------------------------ | ------------------------------------------------------- | ------- |
575
+ | `-F, --filter <pattern>` | Run command in matching packages | — |
576
+ | `-R, --recursive` | Run command in all packages | — |
577
+ | `-w, --workspace-root` | Use root scripts, running at the monorepo root | — |
578
+ | `-q, --quiet` | Suppress info messages; show full output on failure | — |
579
+ | `--no-cache` | Run even if this tree already passed; record the result | — |
580
+ | `-?, --help` | Show available commands | — |
581
+ | `-V, --version` | Show version number | — |
413
582
 
414
583
  ### Examples
415
584
 
@@ -419,8 +588,9 @@ nmr test # Run workspace test script
419
588
  nmr build # Compile to .js and .d.ts in one pass
420
589
 
421
590
  # From the monorepo root
422
- nmr test # Root tests + recursive workspace tests
423
- nmr ci # build + check:strict + audit
591
+ nmr test # Root tests + recursive workspace tests
592
+ nmr ci # build + check:strict
593
+ nmr prepush # ci + audit
424
594
 
425
595
  # Target specific packages
426
596
  nmr --filter core test # Test only the core package
@@ -486,6 +656,10 @@ nmr-clean
486
656
 
487
657
  Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (a content-and-path hash is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means — `nmr clean`, `rm -rf dist`, `git clean` — therefore forces a rebuild rather than a skip. This is the default `compile` script — run it from a package directory.
488
658
 
659
+ **What it compiles.** Entry points are `src/**/*.ts` less the directories that hold test scaffolding rather than shipped code: `__fixtures__/`, `__mocks__/`, `__tests__/`, and `test-utils/`. Add to that list per package with [`build.extraIgnorePatterns`](#package-level-configuration). Ignoring a directory drops it as an _entry point_, not from the emit: the compiler still emits whatever the surviving entry points import, so a helper that production code uses is still compiled and its importer never emits a dangling specifier. This list is deliberately not the Vitest [coverage exclusions](#what-the-config-excludes) — helpers live in `test-utils/` precisely so they stay covered.
660
+
661
+ **The build owns its output directory.** Every rebuild wipes `dist/esm` before emitting, so the directory holds exactly the current emit: a source that was deleted, or that a widened ignore set now covers, leaves nothing behind. Assets therefore belong outside the output directory, or in a `build:post` hook, which runs after the wipe and so composes correctly. Note that a bare `nmr compile` runs no hook, so assets a `build:post` step copies in are absent until a full `nmr build` restores them. An `outdir` that does not resolve inside the package is rejected rather than wiped.
662
+
489
663
  `typescript` is a peer dependency (`>=5.7.0 <7`); the consuming repo provides it. The floor is what `rewriteRelativeImportExtensions` requires; the ceiling is because TypeScript 7 ships no compiler API — its root export is a version constant, so `nmr-compile` cannot run on it. Relative imports in source must carry explicit `.ts` extensions for them to be rewritten.
490
664
 
491
665
  ```bash
@@ -587,6 +761,8 @@ Beyond everything Prettier already handles, this config formats `.sh`, `.bash`,
587
761
 
588
762
  Shell output matches `shfmt` run with no flags: `binaryNextLine`, `spaceRedirects`, and `switchCaseIndent` are pinned to shfmt's CLI defaults, which the underlying plugin inverts. A repo whose scripts shfmt already formatted sees no diff on adoption.
589
763
 
764
+ Code fences inside Markdown are left alone, in every language rather than shell alone. Registering the shell plugin also routes a fence tagged `bash` to shfmt, where a documented command's angle-bracket placeholders are valid redirections: `cmd --type <type> --harness claude` is reprinted as `cmd --type claude <type >--harness`, which still runs but no longer does what it documents. It cannot be narrowed back to shell, because Prettier matches a fence tag against the same `extensions` its file inference reads, so the `.bash` routing a script also claims the tag `bash`.
765
+
590
766
  ### Language scoping
591
767
 
592
768
  `prettier-plugin-sh` routes 20 further file types to the same shell parser, among them `.gitignore`, `.env`, `.csh`, `.nu`, `.properties`, `.ics`, `.vcf`, `CODEOWNERS`, and `hosts`. Since `nmr fmt` hands git's whole file list to Prettier, registering the plugin as shipped would make every one of them formattable — and the shell parser either fails on them or silently rewrites them. A `.gitignore` pattern such as `a(b)c` fails to parse, and an unquoted `&` in a `.env` or `.properties` value is split across two lines.
@@ -618,7 +794,7 @@ export default definePrettierConfig({
618
794
  ### Adoption caveats
619
795
 
620
796
  - **Indentation follows `.editorconfig`**, as it did under shfmt: Prettier maps `indent_style` and `indent_size` to `useTabs` and `tabWidth`, and the plugin honours both. With no `.editorconfig`, the two disagree — shfmt indents shell with tabs, Prettier with two spaces. Add `indent_style = tab` under `[*.sh]` to keep tabs.
621
- - **Shell fences in Markdown are formatted**, which collapses hand-aligned comment columns in documentation. Prettier keeps the original text when an embedded snippet fails to parse, so illustrative or truncated fences are safe. Use `<!-- prettier-ignore -->` before a fence whose alignment matters.
797
+ - **Markdown fences stop being tidied.** The carve-out that protects documented shell commands costs the `ts`, `json`, and `yaml` fences their formatting too. Fences already formatted stay as they are, so nothing churns on adoption; new ones are simply left as written. A repo whose docs hold no shell can take them back for the Markdown paths it actually uses: `additionalOverrides: [{ files: ['*.md'], options: { embeddedLanguageFormatting: 'auto' } }]` restores `.md` alone, and the carve-out still covers every other path Prettier reads as Markdown.
622
798
  - **Adopting the house options reformats the repo.** `singleQuote`, `trailingComma`, and the rest apply on the first run; review that diff separately from the shell one.
623
799
 
624
800
  ## Shared Vitest config
@@ -634,29 +810,40 @@ export default defineVitestConfig();
634
810
 
635
811
  `vitest` is a peer dependency (`>=4.0.0 <5`), declared optional — the consuming repo provides it, and repos that never import this subpath are unaffected.
636
812
 
637
- ### Test categories
813
+ ### Test tiers
638
814
 
639
- The config declares three projects, named for what the tests cover:
815
+ The config declares four projects, an ordered ladder named for the furthest thing a test reaches:
640
816
 
641
- | Project | Matches | Covers |
642
- | ------------- | -------------------------------------- | ---------------------------------------- |
643
- | `unit` | every test file the others don't claim | source code |
644
- | `integration` | `*.int.test.{ts,tsx}` | behavior beyond source code |
645
- | `app` | `*.app.test.{ts,tsx}` | the app's own tooling, e.g. drift checks |
817
+ | Project | Matches | Reaches |
818
+ | ----------- | -------------------------------------- | ---------------------------------------------------- |
819
+ | `unit` | every test file the others don't claim | nothing outside the package's own dependency closure |
820
+ | `tool` | `*.tool.test.{ts,tsx}` | a program the environment must supply |
821
+ | `localhost` | `*.localhost.test.{ts,tsx}` | a service running on this machine |
822
+ | `remote` | `*.remote.test.{ts,tsx}` | a machine that isn't this one |
646
823
 
647
- All three match only under a `__tests__` directory. Select them at run time with `--project`, which accepts negation:
824
+ All four match only under a `__tests__` directory. Select them at run time with `--project`, which unions when repeated and accepts negation:
648
825
 
649
826
  ```bash
650
- vitest --project '!integration' # everything except integration tests
651
- vitest --project integration # integration tests alone
652
- vitest # every project
827
+ vitest --project unit --project tool # everything that runs on a bare install
828
+ vitest --project tool # tests reaching a program, alone
829
+ vitest # every project
653
830
  ```
654
831
 
655
- `unit` is defined by subtracting the other categories rather than by an allow-list of suffixes, so a file such as `parser.smoke.test.ts` runs under `unit` instead of being silently dropped.
832
+ **A tier names what a test reaches, never how it invokes it.** A test driving a compiler through its JavaScript API is `tool`, exactly as one spawning the compiler binary is; whether a program ships as a library or an executable is an accident of packaging, not a property of the test. A package's `peerDependencies`, plus whatever binaries it spawns, are a good statement of where its `tool` boundary sits: a bundled dependency ships inside the package's own closure and is always present, so reaching one is `unit`.
833
+
834
+ **A tier is not a statement about preconditions.** A `unit` test may still need something set up before it runs: a build, a generated fixture, a seeded file. What makes it `unit` is that it reaches nothing beyond the test process _while running_. The filesystem sits in `unit` for the same reason the tiers exist: they gate on what must be available, and the filesystem always is.
835
+
836
+ `unit` is defined by subtracting the tiers rather than by an allow-list of infixes, so a file such as `parser.smoke.test.ts` runs under `unit` instead of being silently dropped. That is also what makes an unrecognized infix safe to use as documentation: `resolveConfig.packaged.test.ts` runs under `unit` and reads as a companion to `resolveConfig.unit.test.ts`.
837
+
838
+ `localhost` and `remote` have no test script of their own. They are declared anyway, so that a `*.remote.test.ts` file cannot fall into `unit` and run in the default gate unnoticed.
839
+
840
+ Every tier above `unit` carries a 30-second `testTimeout` and `hookTimeout`, where `unit` keeps Vitest's defaults of 5 and 10 seconds. A tier test waits on something it doesn't control, and coverage instrumentation multiplies that wait, so the defaults turn a green suite flaky the moment `nmr test:coverage` collects it. Both budgets move together because a tier that scaffolds in `beforeAll` moves that wait out from under `testTimeout` entirely, where raising the test budget alone never reaches it. `unit` keeps the tight budgets, which is what makes a hung unit test fail fast.
841
+
842
+ To raise a budget for one tier and no other, use the `tiers` seam below. The `project` seam merges over both budgets as well, but like every option passed through it the value reaches all four projects at once -- raising a tier's budget raises `unit`'s with it. To lift the ceiling for a single file rather than a whole tier, pass a timeout to the individual test or hook, which stays the narrower tool.
656
843
 
657
844
  ### Customizing by scope
658
845
 
659
- Vitest applies some options at the root of a `projects` config and others per project, and placing one at the wrong level is silent rather than loud. The factory therefore takes two separate override surfaces instead of one merged config:
846
+ Vitest applies some options at the root of a `projects` config and others per project, and placing one at the wrong level is silent rather than loud. The factory therefore takes separate override surfaces instead of one merged config:
660
847
 
661
848
  ```ts
662
849
  export default defineVitestConfig({
@@ -664,12 +851,69 @@ export default defineVitestConfig({
664
851
  root: { resolve: { conditions: ['development'] } },
665
852
  // Applied to every project.
666
853
  project: { setupFiles: ['./vitest.setup.ts'] },
854
+ // Applied to one tier, after the `project` block above.
855
+ tiers: { tool: { testTimeout: 120_000 } },
667
856
  });
668
857
  ```
669
858
 
670
- `root` is typed to accept only the options that work at the root, so writing a per-project option there is a compile error rather than a setting that never runs. Both surfaces merge into the generated config rather than replacing it, so overriding one coverage field leaves the rest intact.
859
+ `root` is typed to accept only the options that work at the root, so writing a per-project option there is a compile error rather than a setting that never runs. Every surface merges into the generated config rather than replacing it, so overriding one coverage field leaves the rest intact.
860
+
861
+ `tiers` is keyed by tier name and reaches all four, `unit` included. A key naming no tier throws and names the valid ones: ignoring it would leave the suite green on the budget the key failed to change, which nothing in the run reports. A tier target sets whichever keys it names and no others, so raising `testTimeout` alone leaves that tier's `hookTimeout` at 30 seconds.
862
+
863
+ Arrays concatenate rather than replace. `exclude` and `setupFiles` therefore add to what the config already declares, and no surface can narrow `include` or drop a default exclusion. Adding an `include` pattern through `project` widens all four projects at once, so a file matching it is collected by each and runs four times.
864
+
865
+ `resolve.conditions` concatenates too, but layer order carries no meaning there: Vite consumes conditions as a set, and which one wins is decided by the key order of the consumed package's own `exports`. A later layer can add a condition and can never remove or outrank one an earlier layer contributed. `resolve.alias` is the one key that merges override-first, so a later alias takes precedence over an earlier one.
866
+
867
+ ### Sharing options across config files
868
+
869
+ Vitest resolves one config per run, so a package that adds its own `vitest.config.ts` stops seeing the repo's root config entirely -- not the one setting it meant to change, all of them. Pass the shared settings as a layer instead:
870
+
871
+ ```ts
872
+ // vitest.shared.ts
873
+ import { fileURLToPath } from 'node:url';
874
+
875
+ import type { VitestConfigOptions } from '@williamthorsen/nmr/vitest';
876
+
877
+ export const shared: VitestConfigOptions = {
878
+ root: { resolve: { conditions: ['source'] } },
879
+ project: { setupFiles: [fileURLToPath(new URL('./vitest.setup.ts', import.meta.url))] },
880
+ };
881
+ ```
882
+
883
+ ```ts
884
+ // packages/web/vitest.config.ts
885
+ import { defineVitestConfig } from '@williamthorsen/nmr/vitest';
886
+
887
+ import { shared } from '../../vitest.shared.ts';
888
+
889
+ export default defineVitestConfig(shared, { project: { environment: 'jsdom' } });
890
+ ```
891
+
892
+ Both factories fold any number of layers left to right: a later layer wins on a scalar, arrays concatenate in layer order, and an `undefined` layer is skipped, so `defineVitestConfig(shared, isCI ? ciLayer : undefined)` composes without a spread. `defineRootVitestConfig` takes the same layers, with `monorepoRoot` on the last one -- the config file's own, the only place `import.meta.dirname` names this repo.
893
+
894
+ Order is the point where `setupFiles` is concerned, since a shared setup file establishes the environment the package's own then runs in. A later layer's `project` block likewise wins over an earlier layer's `tiers` target: the nearer config is the more deliberate.
895
+
896
+ **A path in a shared layer must be absolute.** Vitest resolves `setupFiles` against each project's own root, not against the module that declared the path, so a bare `'./vitest.setup.ts'` in a shared module names a different file in every package that consumes it -- and a package that happens to own a file by that name loads the wrong one instead of failing. The co-located form stays correct in a config file that declares the path directly.
897
+
898
+ **Do not merge two returned configs.** `mergeConfig(defineVitestConfig(), defineVitestConfig(mine))` looks like the idiomatic recovery and fails at startup: both sides declare the same four project names, which Vitest rejects with `Project name "unit" ... is not unique`. Layers merge the factory's inputs instead, which is why they yield four projects however many fold.
671
899
 
672
- Arrays concatenate rather than replace. `exclude` and `setupFiles` therefore add to what the config already declares, and neither seam can narrow `include` or drop a default exclusion. Adding an `include` pattern through `project` widens all three projects at once, so a file matching it is collected by each and runs three times.
900
+ A config file that omits the shared layer still loses those settings, silently -- Vitest's own resolution contract, not something the factory can intercept. Guard it with a test that fails when a package's suite goes missing, which also catches a shared `exclude` pattern swallowing one package's tests.
901
+
902
+ ### What the config excludes
903
+
904
+ Collection skips `**/node_modules/**`, `**/.git/**`, and `**/dist/**`. Coverage skips `**/__{fixtures,mocks,tests}__/**`, `**/index.ts`, and `**/*.d.ts`.
905
+
906
+ Because both seams concatenate, a consumer can add an exclusion but never remove one. A pattern therefore earns its place in these lists only by preventing a _silent_ failure, one a consumer cannot self-diagnose. A visible failure, such as a stray file sitting at 0% in the coverage report, is left to the consumer's own `project` seam.
907
+
908
+ Excluding build output from collection is the clearest case. A build that copies `.ts` sources rather than compiling them puts a second copy of the suite under `dist/`, where it is collected and passes green against stale code, and nothing in the run says so. (`nmr build` emits only `.js` and `.d.ts`, neither of which the include matches, so an nmr-built package was never exposed.) `dist/` is deliberately absent from the coverage list, because build output reaching a coverage report shows up as a diagnosable 0% entry.
909
+
910
+ #### Where to put fixtures
911
+
912
+ A `__fixtures__/` directory is excluded from coverage at any depth, so fixture data stops counting against a package's numbers whether or not a test imports it.
913
+
914
+ Placing it outside `__tests__/` settles a second problem: anything named `*.test.{ts,tsx}` under `__tests__/` is collected and run, whatever it holds, so a fixture carrying that name becomes a failing test. `src/__fixtures__/` resolves both halves; `src/__tests__/__fixtures__/` resolves the coverage half alone.
915
+
916
+ No collection pattern names fixtures, and that asymmetry is intentional. A coverage exclusion cannot hide a real test, whereas a collection exclusion can; since it could not be removed, a consumer who legitimately keeps a test under `__fixtures__/` would have no recourse.
673
917
 
674
918
  ### Root-scoped tests
675
919
 
@@ -682,24 +926,31 @@ import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest';
682
926
  export default defineRootVitestConfig({ monorepoRoot: import.meta.dirname });
683
927
  ```
684
928
 
685
- This variant reads `pnpm-workspace.yaml` and excludes every workspace package from all three projects, so a root run covers only root-level files. It reports no coverage of its own — packages cover their own sources.
929
+ This variant reads `pnpm-workspace.yaml` and excludes every workspace package from all four projects, so a root run covers only root-level files. It reports no coverage of its own — packages cover their own sources.
686
930
 
687
931
  `monorepoRoot` is required, and because the config sits at the monorepo root, it is always `import.meta.dirname`. Stating the root rather than searching for it is what makes the exclusions describe this repo: a search from the working directory would resolve whichever monorepo the run started in, and every project is then pinned to that one. A directory holding no `pnpm-workspace.yaml` throws, naming the directory.
688
932
 
933
+ ### Migrating to the isolation tiers
934
+
935
+ The projects were once named `unit`, `integration`, and `app`, for what a test _covered_ rather than what it _reached_. `integration` and `app` are gone.
936
+
937
+ 1. Rename every `*.int.test.ts`. A test that reaches a program the environment supplies becomes `*.tool.test.ts`; one that reaches nothing beyond the test process drops the infix. Re-read each file rather than renaming in bulk -- the axis changed, so the old bucket does not map onto one new tier.
938
+ 2. Leave `*.app.test.ts` alone, or rename at your leisure. `app` was never a tier, and its files now land in `unit`.
939
+ 3. Replace `nmr test:integration` with `nmr test:tool`, and `nmr root:test:integration` with `nmr root:test:tool`, wherever a script or workflow names them.
940
+
941
+ **An unrenamed `*.int.test.ts` file is a silent failure.** `.int.` no longer matches a project, so the residual `unit` claims those files and they run in the default gate -- green, with nothing reporting that the separation was lost. `rdy run nmr` reports them; a passing test run does not. Run it before treating the migration as done.
942
+
943
+ `nmr test` also runs the `tool` tier, which the old `test` excluded. Those tests previously ran in no default selection at all: `check`, `check:strict`, and `ci` all inherited the exclusion, so a green pipeline said nothing about them. Expect `nmr test` to take longer and to surface failures that were never gated.
944
+
689
945
  ### Migrating from the config-file variants
690
946
 
691
- nmr once selected a package's test scripts by looking for a `vitest.integration.config.ts` on disk, which meant three config files per package that separated its integration tests. Those files are no longer consulted. **Migrate the config and the scripts together**: one without the other leaves the repo in one of the two states below.
947
+ nmr once selected a package's test scripts by looking for a `vitest.integration.config.ts` on disk, which meant three config files per package that separated its integration tests. Those files are no longer consulted.
692
948
 
693
949
  1. Replace the repo's root `vitest.config.ts` with `defineVitestConfig()`, and its root-scoped config with `defineRootVitestConfig({ monorepoRoot: import.meta.dirname })`.
694
950
  2. Delete every `vitest.integration.config.ts` and `vitest.standalone.config.ts`, plus any per-package `vitest.config.ts` that only re-exports an ancestor. Vitest resolves config by walking up from the run root, so those are redundant.
695
- 3. Rename integration tests to `*.int.test.ts` and tooling or drift tests to `*.app.test.ts`.
951
+ 3. Rename the tests onto the tiers, per the section above.
696
952
  4. Drop any hand-copied `test*` entries from `package.json`, which now shadow the defaults.
697
953
 
698
- Until step 1 is done, an upgraded nmr behaves as follows against a config that declares no projects:
699
-
700
- | Command | Result |
701
- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
702
- | `nmr test` / `test:coverage` | **Succeeds, and runs integration tests too.** The separation is lost with no error, because `--project '!integration'` matches nothing to exclude. |
703
- | `nmr test:integration` | **Fails** with `No projects matched the filter "integration"`. |
954
+ Until step 1 is done, every test command that names a tier fails against a config declaring no projects, with `Error: No projects matched the filter "unit", "tool".` at startup. `test:all` is the exception: it names no project, so it runs everything the config collects.
704
955
 
705
- The second is the reliable signal that migration is incomplete. The first is silent, so do not treat a green `nmr test` as evidence that the move succeeded.
956
+ That is a change for the better. While `test` selected by negation, an unmigrated config produced a _green_ run, because `--project '!integration'` had nothing to exclude, so the separation was lost with no error anywhere. Positive selection turns the same state into a startup failure that names the missing projects.