@williamthorsen/nmr 0.19.0 → 0.21.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.
Files changed (44) hide show
  1. package/AGENTS.md +5 -96
  2. package/README.md +303 -99
  3. package/bin/nmr-fmt.js +11 -0
  4. package/dist/esm/cli-ensure-prepublish-hooks.js +1 -1
  5. package/dist/esm/cli-fmt.js +3 -0
  6. package/dist/esm/cli-report-overrides.js +1 -1
  7. package/dist/esm/cli-sync-agent-files.js +1 -1
  8. package/dist/esm/commands/clean.js +4 -3
  9. package/dist/esm/commands/ensure-prepublish-hooks.js +1 -1
  10. package/dist/esm/commands/fmt.d.ts +23 -0
  11. package/dist/esm/commands/fmt.js +172 -0
  12. package/dist/esm/context.d.ts +0 -2
  13. package/dist/esm/context.js +7 -60
  14. package/dist/esm/default-scripts.d.ts +1 -3
  15. package/dist/esm/default-scripts.js +17 -21
  16. package/dist/esm/help.js +3 -2
  17. package/dist/esm/helpers/workspace-patterns.d.ts +1 -0
  18. package/dist/esm/helpers/workspace-patterns.js +29 -0
  19. package/dist/esm/index.d.ts +0 -1
  20. package/dist/esm/resolve-scripts.d.ts +1 -1
  21. package/dist/esm/resolve-scripts.js +3 -6
  22. package/dist/esm/resolver.d.ts +1 -2
  23. package/dist/esm/resolver.js +3 -6
  24. package/dist/esm/runCli.js +17 -19
  25. package/dist/esm/vitest.d.ts +15 -0
  26. package/dist/esm/vitest.js +62 -0
  27. package/dist/esm/workspace.d.ts +2 -0
  28. package/dist/esm/workspace.js +38 -0
  29. package/package.json +27 -12
  30. package/bin/nmr-sync-pnpm-version.js +0 -11
  31. package/dist/esm/cli-sync-pnpm-version.js +0 -11
  32. package/dist/esm/commands/sync-pnpm-version.d.ts +0 -3
  33. package/dist/esm/commands/sync-pnpm-version.js +0 -40
  34. package/dist/esm/helpers/code-quality-pnpm-action.d.ts +0 -13
  35. package/dist/esm/helpers/code-quality-pnpm-action.js +0 -22
  36. package/dist/esm/tests/consistency.d.ts +0 -7
  37. package/dist/esm/tests/consistency.js +0 -57
  38. package/dist/esm/tests/helpers/get-runtime-version-from-asdf.d.ts +0 -1
  39. package/dist/esm/tests/helpers/get-runtime-version-from-asdf.js +0 -12
  40. package/dist/esm/tests/helpers/get-string-from-yaml-file.d.ts +0 -1
  41. package/dist/esm/tests/helpers/get-string-from-yaml-file.js +0 -12
  42. package/dist/esm/tests/helpers/get-value-at-path.d.ts +0 -1
  43. package/dist/esm/tests/helpers/get-value-at-path.js +0 -34
  44. /package/dist/esm/{cli-sync-pnpm-version.d.ts → cli-fmt.d.ts} +0 -0
package/README.md CHANGED
@@ -1,27 +1,44 @@
1
1
  # @williamthorsen/nmr
2
2
 
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.
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.19.0 (2026-07-22)
6
+ ## Release notes — v0.21.0 (2026-07-30)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - 🚨 **Breaking:** Replace the dependency commands with nmr upgrade (#491)
10
+ - Add a shared Vitest config export (#526)
11
11
 
12
- Adds `nmr upgrade` as an ergonomic wrapper for displaying and applying available dependency upgrades. Taze is used under the hood to set a soaking period (customizable, defaulting to 7 days) before recently published dependencies will be accepted.
12
+ Adds a shared Vitest configuration that can be used by repos that use `nmr`. Tests are now sorted into three named categories (unit, integration, and app), so that a run can target or skip a single category. A second variant excludes all tests in workspace packages, allowing the monorepo's own root-level tests to be run separately.
13
13
 
14
- Removes the four dependency commands it replaces: `outdated`, `outdated:latest`, `update`, and `update:latest`.
14
+ - 🚨 **Breaking:** Retire the Vitest config-file variants from nmr's defaults (#533)
15
+
16
+ Packages no longer need extra Vitest config files to keep integration tests out of the default test run. Integration tests are now recognized by an `*.int.test.ts` suffix, and every package gets the same five commands: `test`, `test:all`, `test:coverage`, `test:integration`, and `test:watch`. The same five commands work from the monorepo root. A Vitest run that collects no test files now passes instead of failing. Consumers should migrate the Vitest config when upgrading.
17
+
18
+ ### 🐛 Bug fixes
19
+
20
+ - Apply ignore rules from any working directory when formatting (#535)
21
+
22
+ Fixes an issue where `nmr fmt` and `nmr fmt:check` did not apply a pattern from a package's `.gitignore` or `.prettierignore` when the run started at the monorepo root, so files a package excluded could fail a root-level format gate while passing a package-scoped one. Untracked files that git does not ignore are now formatted alongside tracked ones, and a run started outside a git repository now fails instead of reporting a clean pass over nothing. Trailing arguments to `nmr fmt` are git pathspecs rather than Prettier flags; one that matches no formattable file now fails instead of exiting silently.
15
23
  <!-- /section:release-notes -->
16
24
 
17
25
  ## Installation
18
26
 
19
- Requires Node.js 24 or later.
27
+ Requires Node.js 24.16 or later.
20
28
 
21
29
  ```bash
22
30
  pnpm add -D @williamthorsen/nmr
23
31
  ```
24
32
 
33
+ ### Making `nmr` resolvable
34
+
35
+ nmr installs as a workspace bin, so the bare `nmr` command works only when your shell can find `<root>/node_modules/.bin/nmr`. Choose one:
36
+
37
+ - **[direnv](https://direnv.net/)** (recommended for contributors). Add `PATH_add node_modules/.bin` to the repo's `.envrc`, and bare `nmr` works from any subdirectory.
38
+ - **`pnpm exec nmr <command>`**. Works with no setup: pnpm resolves the bin from the workspace root.
39
+
40
+ > **Note:** Avoid `npx nmr`. Inside a git worktree, `npx` can resolve a different nmr binary from outside the working tree, so the command succeeds while running the wrong code.
41
+
25
42
  ## Quick start
26
43
 
27
44
  nmr works out of the box with no configuration. It ships with built-in scripts for common monorepo tasks.
@@ -39,7 +56,7 @@ From the monorepo root:
39
56
  ```bash
40
57
  nmr test # Run root tests + recursive workspace tests
41
58
  nmr build # Build all packages
42
- nmr ci # Run check:strict && build (full CI pipeline)
59
+ nmr ci # Run build && check:strict && audit (full CI pipeline)
43
60
  ```
44
61
 
45
62
  nmr detects where you are and selects the right scripts automatically — see [context-aware resolution](#context-aware-resolution) below.
@@ -48,19 +65,26 @@ nmr detects where you are and selects the right scripts automatically — see [c
48
65
 
49
66
  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.
50
67
 
51
- | Where you run `nmr` | Registry used | `nmr test` runs |
52
- | -------------------------- | ----------------- | --------------------------------------------- |
53
- | Monorepo root | Root scripts | Root tests + `pnpm --recursive exec nmr test` |
54
- | Inside a workspace package | Workspace scripts | `pnpm exec vitest` (for that package only) |
55
- | Anywhere, with `-w` flag | Root scripts | Forces root registry regardless of location |
68
+ | Where you run `nmr` | Registry used | Working directory | `nmr test` runs |
69
+ | --------------------------------- | ----------------- | ----------------- | --------------------------------------------------------------- |
70
+ | Monorepo root | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
71
+ | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest --project '!integration'` (that package only) |
72
+ | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
73
+
74
+ Relative paths in a script resolve against that working directory, not the invocation directory.
56
75
 
57
- Use `-w` to escape package context:
76
+ Use `--workspace-root` to escape package context:
58
77
 
59
78
  ```bash
60
79
  # From inside packages/nmr-core, run the root check suite
61
- nmr -w check
80
+ nmr --workspace-root check
62
81
  ```
63
82
 
83
+ Two consequences:
84
+
85
+ - `nmr --workspace-root clean` sweeps every workspace package, as `nmr clean` does from the root.
86
+ - Passthrough paths resolve against the working directory: from `packages/nmr/src/`, `nmr --workspace-root fmt pnpm-workspace.yaml` formats the file at the monorepo root.
87
+
64
88
  ## Three-tier override system
65
89
 
66
90
  Scripts resolve through three tiers. Higher tiers override lower ones:
@@ -92,6 +116,8 @@ Script values can be `string` or `string[]`. Arrays expand to chained `nmr` sub-
92
116
  // expands to: nmr lint && nmr fmt
93
117
  ```
94
118
 
119
+ Passthrough arguments attach to the final step alone, because the expansion is a single shell chain: `nmr fix --dry-run` runs `nmr lint && nmr fmt --dry-run`.
120
+
95
121
  ## Configuration
96
122
 
97
123
  Create `.config/nmr.config.ts` in the monorepo root to add or override scripts:
@@ -137,6 +163,44 @@ For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites
137
163
 
138
164
  > **Note:** Path resolution uses a heuristic: any non-flag token containing `/` is treated as a relative path. This works well for typical dev-tool commands but may incorrectly resolve URL-like values or glob patterns. Flags using `--flag=value` syntax are not resolved; use the spaced form `--flag value` for paths that need resolution.
139
165
 
166
+ ## Pre and post hooks
167
+
168
+ Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same three tiers as any other script (built-in defaults, then `.config/nmr.config.ts`, then per-package `package.json`). Wrapping is uniform: nested invocations from composite expansion get their own hook treatment. Hook failure short-circuits the chain via shell `&&` semantics, propagating the failing exit code.
169
+
170
+ Behaviors worth knowing:
171
+
172
+ - **Silent when absent**: Missing hooks produce no error and no output.
173
+ - **Skip overrides apply to hooks**: A hook value of `""` or `":"` is treated the same as not defining the hook, with no console message.
174
+ - **Skipping the main command skips its hooks**: When `X` is overridden to `""` or `":"`, neither `X:pre` nor `X:post` fires.
175
+ - **Recursion guard**: Direct invocation of a hook (e.g. `nmr build:pre`) is treated as a leaf operation. It does not itself attempt to resolve `build:pre:pre` or `build:pre:post`.
176
+ - **Passthrough args attach only to the main command**: `nmr X --flag value` runs hooks without `--flag value`.
177
+
178
+ Extend `nmr build` with a pre-build step for every workspace package:
179
+
180
+ ```ts
181
+ // .config/nmr.config.ts
182
+ import { defineConfig } from '@williamthorsen/nmr';
183
+
184
+ export default defineConfig({
185
+ workspaceScripts: {
186
+ 'build:pre': 'npx rdy compile',
187
+ },
188
+ });
189
+ ```
190
+
191
+ Attach a post-build step to one package:
192
+
193
+ ```jsonc
194
+ // packages/nmr/package.json
195
+ {
196
+ "scripts": {
197
+ "build:post": "nmr-sync-agent-files",
198
+ },
199
+ }
200
+ ```
201
+
202
+ The second example calls the bin directly, which sidesteps the workspace-versus-root registry distinction.
203
+
140
204
  ## Dependency upgrades
141
205
 
142
206
  `nmr upgrade` reports the dependency upgrades available to your repo. What it covers depends on where you run it:
@@ -148,6 +212,8 @@ For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites
148
212
  | `nmr -F <package> upgrade` | that package, from anywhere |
149
213
  | `nmr root:upgrade` | the root `package.json` alone |
150
214
 
215
+ `nmr upgrade` from the root precedes its report with any active `pnpm.overrides` declared in the root `package.json`; `nmr root:upgrade` does not. An override pins a transitive dependency, so an upgrade masked by one never appears in the report.
216
+
151
217
  The upgrade tool ([taze](https://github.com/antfu-collective/taze)) arrives with nmr, so your repo declares no dependency on it. Everything after the command name is passed through, including the range mode:
152
218
 
153
219
  ```bash
@@ -156,8 +222,6 @@ nmr upgrade major # major upgrades, still inside the ceilings
156
222
  nmr upgrade --write # apply the proposals to package.json
157
223
  ```
158
224
 
159
- > **Note:** `-w` means different things on either side of the command. `nmr upgrade -w` passes `--write` to the upgrade tool; `nmr -w upgrade` is nmr's own `--workspace-root` flag and applies no write. Prefer the long forms to keep them apart. Run the root-context commands from the monorepo root: like nmr's other `root:` scripts, they take the current directory as their starting point.
160
-
161
225
  ### Configuring upgrades
162
226
 
163
227
  Declare version ceilings in a `taze.config.ts` at the monorepo root:
@@ -185,38 +249,48 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
185
249
 
186
250
  ### Workspace scripts
187
251
 
188
- | Command | Runs |
189
- | --------------- | -------------------------------------------------------- |
190
- | `build` | `compile` |
191
- | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
192
- | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
193
- | `clean` | `nmr-clean` |
194
- | `compile` | `nmr-compile` |
195
- | `fix` | `lint`, `fmt` |
196
- | `fix:check` | `fmt:check`, `lint:check` |
197
- | `fmt` | `prettier --list-different --write .` |
198
- | `fmt:check` | `prettier --check .` |
199
- | `lint` | `eslint --fix .` |
200
- | `lint:check` | `eslint .` |
201
- | `lint:strict` | `strict-lint` |
202
- | `test` | `pnpm exec vitest` |
203
- | `test:coverage` | `pnpm exec vitest --coverage` |
204
- | `test:watch` | `pnpm exec vitest --watch` |
205
- | `typecheck` | `tsgo --noEmit` |
206
- | `upgrade` | `nmr-taze --include-locked` |
207
- | `view-coverage` | `open coverage/index.html` |
208
-
209
- #### Integration test variant
210
-
211
- A package gets this variant automatically when it contains a `vitest.integration.config.ts` file: `test` and `test:coverage` run the standalone (non-integration) suite, and integration tests run only when invoked explicitly. Activation is detected per package, so it applies under the recursive `nmr ci` fan-out. Opting in requires **both** `vitest.integration.config.ts` and `vitest.standalone.config.ts`, since the standalone scripts reference the latter.
212
-
213
- | Command | Runs |
214
- | ------------------ | ------------------------------------------------------------------ |
215
- | `test` | `pnpm exec vitest --config=vitest.standalone.config.ts` |
216
- | `test:all` | `pnpm exec vitest` |
217
- | `test:coverage` | `pnpm exec vitest --config=vitest.standalone.config.ts --coverage` |
218
- | `test:integration` | `pnpm exec vitest --config=vitest.integration.config.ts` |
219
- | `test:watch` | `pnpm exec vitest --config=vitest.standalone.config.ts --watch` |
252
+ | Command | Runs |
253
+ | ------------------ | -------------------------------------------------------- |
254
+ | `build` | `compile` |
255
+ | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
256
+ | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
257
+ | `clean` | `nmr-clean` |
258
+ | `compile` | `nmr-compile` |
259
+ | `fix` | `lint`, `fmt` |
260
+ | `fix:check` | `fmt:check`, `lint:check` |
261
+ | `fmt` | `nmr-fmt --write` |
262
+ | `fmt:check` | `nmr-fmt --check` |
263
+ | `lint` | `eslint --fix .` |
264
+ | `lint:check` | `eslint .` |
265
+ | `lint:strict` | `strict-lint` |
266
+ | `test` | `pnpm exec vitest --project '!integration'` |
267
+ | `test:all` | `pnpm exec vitest` |
268
+ | `test:coverage` | `pnpm exec vitest --project '!integration' --coverage` |
269
+ | `test:integration` | `pnpm exec vitest --project integration` |
270
+ | `test:watch` | `pnpm exec vitest --project '!integration' --watch` |
271
+ | `typecheck` | `tsgo --noEmit` |
272
+ | `upgrade` | `nmr-taze --include-locked` |
273
+ | `view-coverage` | `open coverage/index.html` |
274
+
275
+ `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).
276
+
277
+ #### Test selections
278
+
279
+ 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.
280
+
281
+ | To run | Command |
282
+ | ----------------------------------- | ------------------ |
283
+ | Everything except integration tests | `test` |
284
+ | Integration tests alone | `test:integration` |
285
+ | Every project | `test:all` |
286
+
287
+ `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.
288
+
289
+ `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.
290
+
291
+ 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.
292
+
293
+ 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.
220
294
 
221
295
  ### Root scripts
222
296
 
@@ -225,7 +299,7 @@ A package gets this variant automatically when it contains a `vitest.integration
225
299
  | Command | Runs |
226
300
  | ------- | --------------------------------- |
227
301
  | `build` | `pnpm --recursive exec nmr build` |
228
- | `ci` | `build`, `check:strict` |
302
+ | `ci` | `build`, `check:strict`, `audit` |
229
303
  | `clean` | `nmr-clean` |
230
304
 
231
305
  #### Check and quality
@@ -244,11 +318,19 @@ A package gets this variant automatically when it contains a `vitest.integration
244
318
 
245
319
  #### Test
246
320
 
247
- | Command | Runs |
248
- | --------------- | ---------------------------------------------------------- |
249
- | `test` | `nmr root:test && pnpm --recursive exec nmr test` |
250
- | `test:coverage` | `nmr root:test && pnpm --recursive exec nmr test:coverage` |
251
- | `test:watch` | `vitest --watch` |
321
+ 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.
322
+
323
+ | Command | Runs |
324
+ | ------------------ | ------------------------------------------------------------------------- |
325
+ | `test` | `nmr root:test && pnpm --recursive exec nmr test` |
326
+ | `test:all` | `nmr root:test:all && pnpm --recursive exec nmr test:all` |
327
+ | `test:coverage` | `nmr root:test && pnpm --recursive exec nmr test:coverage` |
328
+ | `test:integration` | `nmr root:test:integration && pnpm --recursive exec nmr test:integration` |
329
+ | `test:watch` | `vitest --project '!integration' --watch` |
330
+
331
+ `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.
332
+
333
+ `test:watch` is the exception to the fan-out shape, and deliberately omits `--config`: bare `vitest` at the monorepo root resolves the root `vitest.config.ts`, whose projects then cover the whole tree in one process. A chain like the others would never advance past its first watcher. To watch root-level files alone, run `nmr root:test --watch`.
252
334
 
253
335
  #### Typecheck
254
336
 
@@ -266,12 +348,18 @@ A package gets this variant automatically when it contains a `vitest.integration
266
348
 
267
349
  #### Format
268
350
 
269
- | Command | Runs |
270
- | ----------- | ------------------------------------- |
271
- | `fmt` | `prettier --list-different --write .` |
272
- | `fmt:all` | `fmt`, `fmt:sh` |
273
- | `fmt:check` | `prettier --check .` |
274
- | `fmt:sh` | `shfmt --write **/*.sh` |
351
+ | Command | Runs |
352
+ | ----------- | ----------------------- |
353
+ | `fmt` | `nmr-fmt --write` |
354
+ | `fmt:all` | `fmt`, `fmt:sh` |
355
+ | `fmt:check` | `nmr-fmt --check` |
356
+ | `fmt:sh` | `shfmt --write **/*.sh` |
357
+
358
+ #### File selection
359
+
360
+ `fmt` and `fmt:check` format the files git reports: tracked files, plus untracked files git does not ignore. Prettier reads `.gitignore` and `.prettierignore` from the working directory alone, so a pattern in `packages/<pkg>/.gitignore` never reaches a run started at the monorepo root. git knows the whole hierarchy, including `.git/info/exclude` and `core.excludesFile`, and every `.prettierignore` in the tree is discovered from the repository root, so package-level exclusions apply from any directory as well.
361
+
362
+ A file git ignores is never formatted, even when named directly. Trailing arguments are git pathspecs rather than Prettier flags, so `nmr fmt:check packages/nmr` narrows the run, while an unrecognized option and a pathspec matching no formattable file both fail it. Running outside a git repository fails rather than reporting a clean run.
275
363
 
276
364
  #### Audit
277
365
 
@@ -283,9 +371,9 @@ A package gets this variant automatically when it contains a `vitest.integration
283
371
 
284
372
  #### Dependencies
285
373
 
286
- | Command | Runs |
287
- | --------- | --------------------------------------- |
288
- | `upgrade` | `nmr-taze --include-locked --recursive` |
374
+ | Command | Runs |
375
+ | --------- | --------------------------------------------------------------- |
376
+ | `upgrade` | `nmr-report-overrides && nmr-taze --include-locked --recursive` |
289
377
 
290
378
  See [dependency upgrades](#dependency-upgrades) for the workflow and its configuration.
291
379
 
@@ -293,23 +381,24 @@ See [dependency upgrades](#dependency-upgrades) for the workflow and its configu
293
381
 
294
382
  These scripts operate on root-level code only (not workspace packages):
295
383
 
296
- | Command | Runs |
297
- | ------------------ | ------------------------------------------------------------- |
298
- | `root:check` | `root:typecheck`, `fmt:check`, `root:lint:check`, `root:test` |
299
- | `root:lint` | `eslint --fix --ignore-pattern 'packages/**' .` |
300
- | `root:lint:check` | `eslint --ignore-pattern 'packages/**' .` |
301
- | `root:lint:strict` | `strict-lint --ignore-pattern 'packages/**' .` |
302
- | `root:test` | `vitest --config ./vitest.root.config.ts` |
303
- | `root:typecheck` | `tsgo --noEmit` |
304
- | `root:upgrade` | `nmr-taze --include-locked` |
384
+ | Command | Runs |
385
+ | ----------------------- | ------------------------------------------------------------------ |
386
+ | `root:check` | `root:typecheck`, `fmt:check`, `root:lint:check`, `root:test` |
387
+ | `root:lint` | `eslint --fix --ignore-pattern 'packages/**' .` |
388
+ | `root:lint:check` | `eslint --ignore-pattern 'packages/**' .` |
389
+ | `root:lint:strict` | `strict-lint --ignore-pattern 'packages/**' .` |
390
+ | `root:test` | `vitest --config ./vitest.root.config.ts --project '!integration'` |
391
+ | `root:test:all` | `vitest --config ./vitest.root.config.ts` |
392
+ | `root:test:integration` | `vitest --config ./vitest.root.config.ts --project integration` |
393
+ | `root:typecheck` | `tsgo --noEmit` |
394
+ | `root:upgrade` | `nmr-taze --include-locked` |
305
395
 
306
396
  #### Utilities
307
397
 
308
- | Command | Runs |
309
- | ------------------- | ----------------------- |
310
- | `report-overrides` | `nmr-report-overrides` |
311
- | `sync-agent-files` | `nmr-sync-agent-files` |
312
- | `sync-pnpm-version` | `nmr-sync-pnpm-version` |
398
+ | Command | Runs |
399
+ | ------------------ | ---------------------- |
400
+ | `report-overrides` | `nmr-report-overrides` |
401
+ | `sync-agent-files` | `nmr-sync-agent-files` |
313
402
 
314
403
  ## CLI reference
315
404
 
@@ -319,11 +408,13 @@ These scripts operate on root-level code only (not workspace packages):
319
408
  nmr [flags] <command> [args...]
320
409
  ```
321
410
 
411
+ 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.
412
+
322
413
  | Flag | Description | Default |
323
414
  | ------------------------ | --------------------------------------------------- | ------- |
324
415
  | `-F, --filter <pattern>` | Run command in matching packages | — |
325
416
  | `-R, --recursive` | Run command in all packages | — |
326
- | `-w, --workspace-root` | Force root script registry | — |
417
+ | `-w, --workspace-root` | Use root scripts, running at the monorepo root | — |
327
418
  | `-q, --quiet` | Suppress info messages; show full output on failure | — |
328
419
  | `-?, --help` | Show available commands | — |
329
420
  | `-V, --version` | Show version number | — |
@@ -337,14 +428,14 @@ nmr build # Compile to .js and .d.ts in one pass
337
428
 
338
429
  # From the monorepo root
339
430
  nmr test # Root tests + recursive workspace tests
340
- nmr ci # check:strict + build
431
+ nmr ci # build + check:strict + audit
341
432
 
342
433
  # Target specific packages
343
- nmr -F core test # Test only the core package
344
- nmr -R lint # Lint all workspace packages
434
+ nmr --filter core test # Test only the core package
435
+ nmr --recursive lint # Lint all workspace packages
345
436
 
346
437
  # Force root context from anywhere
347
- nmr -w check # Run root check from a package dir
438
+ nmr --workspace-root check # Run root check from a package dir
348
439
  ```
349
440
 
350
441
  ## Additional subcommands
@@ -353,7 +444,7 @@ These commands are available as `nmr` subcommands and as standalone `nmr-`-prefi
353
444
 
354
445
  ### `report-overrides`
355
446
 
356
- Report any active `pnpm.overrides` in the root `package.json`. Useful as a `postinstall` hook to remind developers of active overrides that may need cleanup.
447
+ Report any active `pnpm.overrides` in the root `package.json`, reminding developers of overrides that may need cleanup. The root `upgrade` script runs it automatically, so no per-repo wiring is needed. Invoking it directly is useful for a one-off report.
357
448
 
358
449
  ```bash
359
450
  nmr report-overrides
@@ -378,13 +469,16 @@ To expose the synced guidance to Claude Code sessions, add this include to the c
378
469
  @nmr/AGENTS.md
379
470
  ```
380
471
 
381
- ### `sync-pnpm-version`
472
+ #### What belongs in the synced file
382
473
 
383
- Synchronize the pnpm version from the root `package.json` `packageManager` field into the GitHub `code-quality.yaml` workflow file.
474
+ The synced file is injected at launch in every consuming repo, so every line is paid for on every task whether or not it is relevant. It is a cheatsheet, not a manual.
384
475
 
385
- ```bash
386
- nmr sync-pnpm-version
387
- ```
476
+ A line earns a place in `AGENTS.md` only if both hold:
477
+
478
+ 1. It is absent from nmr's own output. Bare `nmr` already prints the flags, every command, and the shell command each resolves to; failing checks already name their own fix. Repeating any of that costs launch tokens to say something the agent will be told anyway, at the moment it matters.
479
+ 2. The obvious action goes wrong without it. Guidance that prevents a silent mistake earns its place; guidance that prevents a mistake the next error message would diagnose does not.
480
+
481
+ Everything else belongs in this README, reachable on demand at `node_modules/@williamthorsen/nmr/README.md`, with a pointer from `AGENTS.md` naming the topic so an agent knows to look. When adding a feature, document it here and extend that pointer rather than the cheatsheet.
388
482
 
389
483
  ## Standalone utilities
390
484
 
@@ -406,9 +500,26 @@ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler
406
500
  nmr-compile
407
501
  ```
408
502
 
503
+ ### `nmr-fmt`
504
+
505
+ Format, or check the formatting of, the files git reports for the working directory. Exactly one of `--check` and `--write` is required; a bare invocation prints usage and exits non-zero rather than defaulting to a mutation. `--write` also lists the files it rewrote. This is what `fmt` and `fmt:check` resolve to, in both registries.
506
+
507
+ The selection is tracked files plus untracked files git does not ignore, scoped to the working directory, with every `.prettierignore` in the tree discovered from the repository root and passed to Prettier explicitly — see [file selection](#file-selection) for what that buys. Paths the index names but the filesystem does not have (a file deleted but not yet staged) and submodule gitlinks are both dropped, so neither reaches Prettier.
508
+
509
+ Trailing arguments are git pathspecs, not Prettier flags: they narrow the selection, an unrecognized option is rejected rather than forwarded, and pathspecs that match no formattable file fail rather than passing quietly.
510
+
511
+ `prettier` is an optional peer dependency (`>=3.9.5 <4`); the consuming repo provides it, and it is resolved through the module graph rather than from `PATH`, so the copy that runs is the one the repo declares and not whichever `prettier` happens to come first. A repository's formatter has to be the one its editor and pre-commit hook also run, which is why nmr takes it from the consumer instead of bundling a copy. It is optional because a repo can use nmr purely as a script runner; one that formats without a resolvable Prettier gets a message naming the package and the range.
512
+
513
+ The floor is a currency policy, not a capability boundary. What the design requires is `--ignore-path` honouring every flag rather than only the last, so a repository-root ignore file passed alongside a package-level one is not silently dropped — Prettier has done that since 3.0.0, while 2.x honoured only the final flag. The floor sits at the current release because every consuming repo tracks it; lowering it to `>=3.0.0` would cost correctness nothing.
514
+
515
+ ```bash
516
+ nmr-fmt --check
517
+ nmr-fmt --write packages/nmr
518
+ ```
519
+
409
520
  ### `nmr-taze`
410
521
 
411
- Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched. This is what the `upgrade` and `root:upgrade` scripts resolve to — see [dependency upgrades](#dependency-upgrades) for the workflow.
522
+ Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched. This is what `root:upgrade` resolves to, and what the root `upgrade` script ends with — see [dependency upgrades](#dependency-upgrades) for the workflow.
412
523
 
413
524
  Under pnpm's isolated `node_modules`, a transitive package's binary is absent from the consuming repo's `node_modules/.bin`, so a repo that depends on nmr cannot run `taze` directly. `nmr-taze` can, because nmr is a direct dependency, and it resolves the tool from the tree nmr controls.
414
525
 
@@ -436,25 +547,118 @@ After installing, a consuming repo's root `package.json` scripts shrink to lifec
436
547
 
437
548
  ```json
438
549
  {
439
- "prepare": "lefthook install",
440
- "postinstall": "nmr report-overrides"
550
+ "prepare": "lefthook install"
441
551
  }
442
552
  ```
443
553
 
444
554
  Per-package `package.json` files no longer need script entries. Run `nmr <command>` directly.
445
555
 
446
- ## Consistency tests
556
+ ## Workspace introspection
447
557
 
448
- Export structural consistency checks for use in your test suite:
558
+ Repo-wide tests and scripts often need to know where the monorepo root is, or which directories its workspace packages live in. The `@williamthorsen/nmr/workspace` subpath publishes the two pnpm-workspace lookups nmr uses internally:
449
559
 
450
560
  ```ts
451
- // __tests__/consistency.test.ts
452
- import { runConsistencyChecks } from '@williamthorsen/nmr/tests';
561
+ // __tests__/packages.test.ts
562
+ import { findMonorepoRoot, getWorkspacePackageDirs } from '@williamthorsen/nmr/workspace';
563
+
564
+ const monorepoRoot = findMonorepoRoot();
565
+
566
+ for (const packageDir of getWorkspacePackageDirs(monorepoRoot)) {
567
+ // assert something about every workspace package
568
+ }
569
+ ```
570
+
571
+ `findMonorepoRoot(startDir?)` walks up from `startDir`, defaulting to `process.cwd()`, until it reaches a directory containing `pnpm-workspace.yaml`. It throws if it runs out of parent directories without finding one.
572
+
573
+ `getWorkspacePackageDirs(monorepoRoot)` reads the workspace patterns from that repo's `pnpm-workspace.yaml` and resolves them to absolute package directories, sorted and free of duplicates. Patterns carry pnpm's own semantics: `packages/*`, deeper globs such as `packages/**`, exact paths such as `tools/cli`, and `!`-prefixed exclusions such as `!packages/legacy` or `!**/test/**`, which filter every directory the positive patterns matched regardless of where they appear in the list. Nothing under `node_modules` is ever returned.
574
+
575
+ One divergence from pnpm: a directory counts as a package only if it holds a `package.json`, not a `package.yaml` or `package.json5`.
576
+
577
+ Quote exclusion patterns in the manifest — `- '!packages/legacy'`. An unquoted `!` opens a YAML tag rather than a string, so the entry never reaches nmr (or pnpm) as a pattern.
453
578
 
454
- runConsistencyChecks();
579
+ ## Shared Vitest config
580
+
581
+ Every repo consuming nmr otherwise writes and maintains its own Vitest config. The `@williamthorsen/nmr/vitest` subpath publishes that config as a factory, so a repo declares only what it customizes:
582
+
583
+ ```ts
584
+ // vitest.config.ts
585
+ import { defineVitestConfig } from '@williamthorsen/nmr/vitest';
586
+
587
+ export default defineVitestConfig();
588
+ ```
589
+
590
+ `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.
591
+
592
+ ### Test categories
593
+
594
+ The config declares three projects, named for what the tests cover:
595
+
596
+ | Project | Matches | Covers |
597
+ | ------------- | -------------------------------------- | ---------------------------------------- |
598
+ | `unit` | every test file the others don't claim | source code |
599
+ | `integration` | `*.int.test.{ts,tsx}` | behavior beyond source code |
600
+ | `app` | `*.app.test.{ts,tsx}` | the app's own tooling, e.g. drift checks |
601
+
602
+ All three match only under a `__tests__` directory. Select them at run time with `--project`, which accepts negation:
603
+
604
+ ```bash
605
+ vitest --project '!integration' # everything except integration tests
606
+ vitest --project integration # integration tests alone
607
+ vitest # every project
455
608
  ```
456
609
 
457
- This verifies:
610
+ `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.
611
+
612
+ ### Customizing by scope
613
+
614
+ 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:
615
+
616
+ ```ts
617
+ export default defineVitestConfig({
618
+ // Vite-level options, plus the test options Vitest honours only at the root.
619
+ root: { resolve: { conditions: ['development'] } },
620
+ // Applied to every project.
621
+ project: { setupFiles: ['./vitest.setup.ts'] },
622
+ });
623
+ ```
624
+
625
+ `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.
626
+
627
+ 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.
628
+
629
+ ### Root-scoped tests
630
+
631
+ A monorepo's own root-level tests need a second config, because the package config is found by walking up from a package directory:
632
+
633
+ ```ts
634
+ // vitest.root.config.ts
635
+ import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest';
636
+
637
+ export default defineRootVitestConfig();
638
+ ```
639
+
640
+ 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.
641
+
642
+ The monorepo root is located by walking up from the working directory, and every project is pinned to it, so the exclusions hold wherever the run is invoked from. Pass `startDir` to locate it from somewhere else:
643
+
644
+ ```ts
645
+ export default defineRootVitestConfig({ startDir: import.meta.dirname });
646
+ ```
647
+
648
+ ### Migrating from the config-file variants
649
+
650
+ 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.
651
+
652
+ 1. Replace the repo's root `vitest.config.ts` with `defineVitestConfig()`, and its root-scoped config with `defineRootVitestConfig()`.
653
+ 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.
654
+ 3. Rename integration tests to `*.int.test.ts` and tooling or drift tests to `*.app.test.ts`.
655
+ 4. Drop any hand-copied `test*` entries from `package.json`, which now shadow the defaults.
656
+
657
+ Until step 1 is done, an upgraded nmr behaves as follows against a config that declares no projects:
658
+
659
+ | Command | Result |
660
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
661
+ | `nmr test` / `test:coverage` | **Succeeds, and runs integration tests too.** The separation is lost with no error, because `--project '!integration'` matches nothing to exclude. |
662
+ | `nmr test:integration` | **Fails** with `No projects matched the filter "integration"`. |
458
663
 
459
- - pnpm version matches between `package.json` and GitHub workflow
460
- - Node.js version matches between `.tool-versions` and GitHub workflow
664
+ 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.
package/bin/nmr-fmt.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/cli-fmt.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('nmr-fmt: build output not found — run `pnpm run build` first\n');
7
+ } else {
8
+ process.stderr.write(`nmr-fmt: failed to load: ${error.message}\n`);
9
+ }
10
+ process.exit(1);
11
+ }
@@ -1,6 +1,6 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
2
  import { DEFAULT_HOOK, ensurePrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
3
- import { findMonorepoRoot } from "./context.js";
3
+ import { findMonorepoRoot } from "./workspace.js";
4
4
  const flagSchema = {
5
5
  fix: { long: '--fix', type: 'boolean' },
6
6
  dryRun: { long: '--dry-run', type: 'boolean' },
@@ -0,0 +1,3 @@
1
+ import process from 'node:process';
2
+ import { runFmt } from "./commands/fmt.js";
3
+ process.exitCode = runFmt(process.argv.slice(2), process.cwd());
@@ -1,6 +1,6 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
2
  import { reportOverrides } from "./commands/report-overrides.js";
3
- import { findMonorepoRoot } from "./context.js";
3
+ import { findMonorepoRoot } from "./workspace.js";
4
4
  try {
5
5
  const monorepoRoot = findMonorepoRoot();
6
6
  reportOverrides(monorepoRoot);
@@ -1,6 +1,6 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
2
  import { check, sync } from "./commands/sync-agent-files.js";
3
- import { findMonorepoRoot } from "./context.js";
3
+ import { findMonorepoRoot } from "./workspace.js";
4
4
  const flagSchema = {
5
5
  check: { long: '--check', type: 'boolean' },
6
6
  };