@williamthorsen/nmr 0.18.2 → 0.20.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 (41) hide show
  1. package/AGENTS.md +5 -84
  2. package/README.md +187 -46
  3. package/bin/nmr-taze.js +11 -0
  4. package/dist/esm/cli-ensure-prepublish-hooks.js +1 -1
  5. package/dist/esm/cli-report-overrides.js +1 -1
  6. package/dist/esm/cli-sync-agent-files.js +1 -1
  7. package/dist/esm/cli-taze.js +10 -0
  8. package/dist/esm/commands/build.d.ts +1 -1
  9. package/dist/esm/commands/build.js +8 -6
  10. package/dist/esm/commands/clean.js +2 -1
  11. package/dist/esm/commands/ensure-prepublish-hooks.js +1 -1
  12. package/dist/esm/commands/taze.d.ts +12 -0
  13. package/dist/esm/commands/taze.js +32 -0
  14. package/dist/esm/context.d.ts +0 -2
  15. package/dist/esm/context.js +8 -61
  16. package/dist/esm/default-scripts.js +3 -5
  17. package/dist/esm/help.js +9 -6
  18. package/dist/esm/helpers/workspace-patterns.d.ts +1 -0
  19. package/dist/esm/helpers/workspace-patterns.js +29 -0
  20. package/dist/esm/index.d.ts +0 -1
  21. package/dist/esm/runCli.js +16 -16
  22. package/dist/esm/taze.d.ts +5 -0
  23. package/dist/esm/taze.js +6 -0
  24. package/dist/esm/workspace.d.ts +2 -0
  25. package/dist/esm/workspace.js +38 -0
  26. package/package.json +16 -12
  27. package/bin/nmr-sync-pnpm-version.js +0 -11
  28. package/dist/esm/cli-sync-pnpm-version.js +0 -11
  29. package/dist/esm/commands/sync-pnpm-version.d.ts +0 -3
  30. package/dist/esm/commands/sync-pnpm-version.js +0 -40
  31. package/dist/esm/helpers/code-quality-pnpm-action.d.ts +0 -13
  32. package/dist/esm/helpers/code-quality-pnpm-action.js +0 -22
  33. package/dist/esm/tests/consistency.d.ts +0 -7
  34. package/dist/esm/tests/consistency.js +0 -57
  35. package/dist/esm/tests/helpers/get-runtime-version-from-asdf.d.ts +0 -1
  36. package/dist/esm/tests/helpers/get-runtime-version-from-asdf.js +0 -12
  37. package/dist/esm/tests/helpers/get-string-from-yaml-file.d.ts +0 -1
  38. package/dist/esm/tests/helpers/get-string-from-yaml-file.js +0 -12
  39. package/dist/esm/tests/helpers/get-value-at-path.d.ts +0 -1
  40. package/dist/esm/tests/helpers/get-value-at-path.js +0 -34
  41. /package/dist/esm/{cli-sync-pnpm-version.d.ts → cli-taze.d.ts} +0 -0
package/AGENTS.md CHANGED
@@ -2,88 +2,9 @@
2
2
  source: '@williamthorsen/nmr@0.0.0-source'
3
3
  ---
4
4
 
5
- # nmr: agent guidance
5
+ # nmr: agent guidance (generated; do not edit)
6
6
 
7
- This file is managed by `@williamthorsen/nmr`. Do not edit; run `nmr sync-agent-files` to refresh it after an nmr upgrade that changes this guidance.
8
-
9
- ## Discover scripts by running nmr
10
-
11
- Run `nmr` with no command (from the monorepo root or any workspace package) to list every available script, including composite expansions and resolved shell commands. Check this before guessing a script name from another repo: The registry is authoritative.
12
-
13
- ## Invocation rules
14
-
15
- - Use `nmr <command>` for anything nmr provides. Do not use `pnpm run <command>`.
16
- - You can invoke nmr from the monorepo root or any workspace package.
17
-
18
- ### How to make `nmr` resolvable
19
-
20
- `nmr` ships as a workspace bin. The bare `nmr` command works only when your shell can find `<root>/node_modules/.bin/nmr`. Choose one:
21
-
22
- - **direnv** (recommended for contributors). With [direnv](https://direnv.net/) installed, the repo's `.envrc` adds `node_modules/.bin` to your `PATH` automatically. From any subdirectory, bare `nmr` works.
23
- - **`pnpm exec nmr <command>`**: Works without setup. pnpm resolves the bin from the workspace root.
24
-
25
- Avoid `npx nmr`. Inside git worktrees, `npx` can resolve a different nmr binary from outside the working tree.
26
-
27
- ## Root vs. workspace context
28
-
29
- nmr walks up to find `pnpm-workspace.yaml`, then decides which registry to use based on whether your cwd is inside a workspace package. The same command name (e.g. `build`, `test`, `check:strict`) often exists in both registries with different behavior; the root version typically delegates across all workspaces. Use `-w` to force the root registry from inside a package dir, and `-F <pkg>` to run a single package's script from anywhere.
30
-
31
- ## Composite scripts
32
-
33
- A script value shown in `nmr` output as `[a, b, c]` is a composite: it runs `nmr a && nmr b && nmr c`, stopping at the first failure. Composites can reference other composites.
34
-
35
- ## `root:*` siblings
36
-
37
- Some root scripts (e.g. `lint`, `typecheck`, `test`) expand to `nmr root:X && pnpm --recursive exec nmr X`. The `root:X` variant runs only against root-level files; the plain name runs everywhere. Use `root:X` directly when you want to isolate a failure to the root code.
38
-
39
- ## Managed build
40
-
41
- The default `compile` script runs `nmr-compile`, a standalone bin that uses the TypeScript compiler API to emit a package's `src` to `dist/esm` as `.js` and `.d.ts` in one pass, rewriting relative `.ts`→`.js` specifiers and tsconfig `paths` aliases in both outputs, and skipping work when inputs are unchanged. `typescript` is a peer dependency (`>=5.7.0`). There is no repo-local build script to maintain, and no separate typings step; `nmr build` runs `compile` alone. To find or debug the build, look to `nmr-compile`, not a `config/build.ts` in the consuming repo.
42
-
43
- ## Override behaviors
44
-
45
- In `.config/nmr.config.ts` or a package's `package.json`, override values have special semantics:
46
-
47
- - `""` (empty string): Skip the script with a "Skipping" message; exit 0.
48
- - `":"`: No-op; exit 0. Prefer this over `""` if your repo enforces non-empty script values.
49
- - Any other string: Runs in place of the default.
50
-
51
- ## Pre and post hooks
52
-
53
- 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 3-tier registry (built-in defaults → `.config/nmr.config.ts` → 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.
54
-
55
- Behaviors worth knowing:
56
-
57
- - **Silent when absent**: Missing hooks produce no error and no output.
58
- - **Skip overrides apply to hooks**: A hook value of `""` or `":"` is treated the same as not defining the hook. No console message.
59
- - **Skipping the main command skips its hooks**: When `X` is overridden to `""` or `":"`, neither `X:pre` nor `X:post` fires.
60
- - **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`.
61
- - **Passthrough args attach only to the main command**: `nmr X --flag value` runs hooks without `--flag value`.
62
-
63
- Worked examples:
64
-
65
- ```ts
66
- // .config/nmr.config.ts — extend `nmr build` with a pre-build compile step
67
- import { defineConfig } from '@williamthorsen/nmr';
68
-
69
- export default defineConfig({
70
- workspaceScripts: {
71
- 'build:pre': 'npx rdy compile',
72
- },
73
- });
74
- ```
75
-
76
- ```jsonc
77
- // packages/nmr/package.json — re-stamp .agents/nmr/AGENTS.md after every build
78
- {
79
- "scripts": {
80
- "build:post": "nmr-sync-agent-files",
81
- },
82
- }
83
- ```
84
-
85
- The second example calls the bin directly to sidestep the workspace-vs-root registry distinction.
86
-
87
- ## Agent-file sync
88
-
89
- The presence and version stamp of `.agents/nmr/AGENTS.md` is verified by `check:agent-files`, which is part of the default root `check:strict` composite. If it fails, run `nmr sync-agent-files`.
7
+ - Use `nmr <command>`, not `pnpm run <command>`, for anything nmr provides. Run bare `nmr` to list every command and the shell command it resolves to; a wrong guess reports only `Unknown command`, with no list.
8
+ - Scope follows cwd, and bare `nmr` lists both registries: from the repo root a command covers root files and every workspace; from inside a package, that package alone. `nmr -F <pkg> <command>` targets one package from anywhere; `nmr root:<command>` targets root files alone, which isolates a failure to root code.
9
+ - Never `npx nmr`: inside a git worktree it can resolve a different nmr from outside the tree. Fall back to `pnpm exec nmr`.
10
+ - The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), and the `nmr-compile` build (there is no repo-local build script).
package/README.md CHANGED
@@ -1,23 +1,54 @@
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.18.2 (2026-07-18)
6
+ ## Release notes — v0.20.0 (2026-07-28)
7
+
8
+ ### 🎉 Features
9
+
10
+ - 🚨 **Breaking:** Migrate to code-quality workflow v7 and retire the version-restatement apparatus (#503)
11
+
12
+ Migrates the code-quality workflow from `@v6` to `@v7`, which no longer requires the Node version to be specified in two places, and removes the test that previously checked for drift. Also removed are the `nmr sync-pnpm-version` command and drift-related exported functions.
13
+
14
+ Taking this upgrade requires migrating the repo's code-quality workflow to `@v7` and deleting its version-alignment test in the same change.
15
+
16
+ - 🚨 **Breaking:** Resolve workspace packages with pnpm's pattern semantics (#509)
17
+
18
+ nmr now excludes packages matched by a `!`-prefixed pattern in `pnpm-workspace.yaml` from the packages it operates on. Deep globs such as `packages/**` now match nested packages, and running a command from inside a nested package targets that package rather than an enclosing ancestor. Exclusion patterns must be quoted to take effect. nmr now requires Node.js 24.16 or later.
19
+
20
+ - Report pnpm overrides when reviewing dependencies (#514)
21
+
22
+ From the monorepo root, `nmr upgrade` now reports the repo's active pnpm dependency overrides alongside the upgrade report, making clear when a pin is the reason an expected upgrade is absent. Repos that wired up this override report themselves with an install-time hook can now remove that hook.
23
+
24
+ - Trim the bundled agent guidance to a cheatsheet (#516)
25
+
26
+ Trims the agent guidance nmr installs into consuming repos from 100 lines to 10. Only the bare essentials are now injected into context. Agents are instructed to consult the package's README for details.
7
27
 
8
28
  ### 🐛 Bug fixes
9
29
 
10
- - Fail the build on aliased imports that escape the package source (#475)
30
+ - Anchor script execution to the resolved registry's directory (#517)
11
31
 
12
- Fixes an issue where an aliased import that resolved to a target outside the package's own source (one Node could not reach at runtime) passed the build but produced published output that crashed with a module-not-found error. `nmr-compile` now fails the build and reports the unresolvable import and where it pointed, so the problem surfaces before publish instead of in production. Aliased imports that resolve outside the source but remain genuinely external and runnable continue to build and ship unchanged.
32
+ Fixes an issue where `nmr --workspace-root`, run from inside a workspace package, either failed outright or silently acted on the wrong directory. Commands now behave the same regardless of the directory they are invoked from: root commands act on the monorepo root, and package commands on their own package. Two changes for existing users: `nmr --workspace-root clean` now sweeps every workspace package instead of cleaning only the current one, and relative paths passed to a script now resolve against the directory the script runs in.
13
33
  <!-- /section:release-notes -->
14
34
 
15
35
  ## Installation
16
36
 
37
+ Requires Node.js 24.16 or later.
38
+
17
39
  ```bash
18
40
  pnpm add -D @williamthorsen/nmr
19
41
  ```
20
42
 
43
+ ### Making `nmr` resolvable
44
+
45
+ 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:
46
+
47
+ - **[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.
48
+ - **`pnpm exec nmr <command>`**. Works with no setup: pnpm resolves the bin from the workspace root.
49
+
50
+ > **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.
51
+
21
52
  ## Quick start
22
53
 
23
54
  nmr works out of the box with no configuration. It ships with built-in scripts for common monorepo tasks.
@@ -35,7 +66,7 @@ From the monorepo root:
35
66
  ```bash
36
67
  nmr test # Run root tests + recursive workspace tests
37
68
  nmr build # Build all packages
38
- nmr ci # Run check:strict && build (full CI pipeline)
69
+ nmr ci # Run build && check:strict && audit (full CI pipeline)
39
70
  ```
40
71
 
41
72
  nmr detects where you are and selects the right scripts automatically — see [context-aware resolution](#context-aware-resolution) below.
@@ -44,19 +75,26 @@ nmr detects where you are and selects the right scripts automatically — see [c
44
75
 
45
76
  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.
46
77
 
47
- | Where you run `nmr` | Registry used | `nmr test` runs |
48
- | -------------------------- | ----------------- | --------------------------------------------- |
49
- | Monorepo root | Root scripts | Root tests + `pnpm --recursive exec nmr test` |
50
- | Inside a workspace package | Workspace scripts | `pnpm exec vitest` (for that package only) |
51
- | Anywhere, with `-w` flag | Root scripts | Forces root registry regardless of location |
78
+ | Where you run `nmr` | Registry used | Working directory | `nmr test` runs |
79
+ | --------------------------------- | ----------------- | ----------------- | --------------------------------------------- |
80
+ | Monorepo root | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
81
+ | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest` (for that package only) |
82
+ | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
52
83
 
53
- Use `-w` to escape package context:
84
+ Relative paths in a script resolve against that working directory, not the invocation directory.
85
+
86
+ Use `--workspace-root` to escape package context:
54
87
 
55
88
  ```bash
56
89
  # From inside packages/nmr-core, run the root check suite
57
- nmr -w check
90
+ nmr --workspace-root check
58
91
  ```
59
92
 
93
+ Two consequences:
94
+
95
+ - `nmr --workspace-root clean` sweeps every workspace package, as `nmr clean` does from the root.
96
+ - 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.
97
+
60
98
  ## Three-tier override system
61
99
 
62
100
  Scripts resolve through three tiers. Higher tiers override lower ones:
@@ -88,6 +126,8 @@ Script values can be `string` or `string[]`. Arrays expand to chained `nmr` sub-
88
126
  // expands to: nmr lint && nmr fmt
89
127
  ```
90
128
 
129
+ 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`.
130
+
91
131
  ## Configuration
92
132
 
93
133
  Create `.config/nmr.config.ts` in the monorepo root to add or override scripts:
@@ -133,6 +173,86 @@ For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites
133
173
 
134
174
  > **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.
135
175
 
176
+ ## Pre and post hooks
177
+
178
+ 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.
179
+
180
+ Behaviors worth knowing:
181
+
182
+ - **Silent when absent**: Missing hooks produce no error and no output.
183
+ - **Skip overrides apply to hooks**: A hook value of `""` or `":"` is treated the same as not defining the hook, with no console message.
184
+ - **Skipping the main command skips its hooks**: When `X` is overridden to `""` or `":"`, neither `X:pre` nor `X:post` fires.
185
+ - **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`.
186
+ - **Passthrough args attach only to the main command**: `nmr X --flag value` runs hooks without `--flag value`.
187
+
188
+ Extend `nmr build` with a pre-build step for every workspace package:
189
+
190
+ ```ts
191
+ // .config/nmr.config.ts
192
+ import { defineConfig } from '@williamthorsen/nmr';
193
+
194
+ export default defineConfig({
195
+ workspaceScripts: {
196
+ 'build:pre': 'npx rdy compile',
197
+ },
198
+ });
199
+ ```
200
+
201
+ Attach a post-build step to one package:
202
+
203
+ ```jsonc
204
+ // packages/nmr/package.json
205
+ {
206
+ "scripts": {
207
+ "build:post": "nmr-sync-agent-files",
208
+ },
209
+ }
210
+ ```
211
+
212
+ The second example calls the bin directly, which sidesteps the workspace-versus-root registry distinction.
213
+
214
+ ## Dependency upgrades
215
+
216
+ `nmr upgrade` reports the dependency upgrades available to your repo. What it covers depends on where you run it:
217
+
218
+ | Invocation | Covers |
219
+ | ---------------------------- | ------------------------------------------- |
220
+ | `nmr upgrade` (from root) | the root `package.json` and every workspace |
221
+ | `nmr upgrade` (in a package) | that package |
222
+ | `nmr -F <package> upgrade` | that package, from anywhere |
223
+ | `nmr root:upgrade` | the root `package.json` alone |
224
+
225
+ `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.
226
+
227
+ 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:
228
+
229
+ ```bash
230
+ nmr upgrade # upgrades available within each package's version ceilings
231
+ nmr upgrade major # major upgrades, still inside the ceilings
232
+ nmr upgrade --write # apply the proposals to package.json
233
+ ```
234
+
235
+ ### Configuring upgrades
236
+
237
+ Declare version ceilings in a `taze.config.ts` at the monorepo root:
238
+
239
+ ```ts
240
+ import { defineConfig } from '@williamthorsen/nmr/taze';
241
+
242
+ export default defineConfig({
243
+ packageMode: {
244
+ // Hold @types/node at the Node major your `engines` floor requires.
245
+ '@types/node': 'minor',
246
+ },
247
+ });
248
+ ```
249
+
250
+ `defineConfig` supplies nmr's shared upgrade policy — currently a seven-day quarantine on brand-new releases — so your config carries only what is specific to your repo. Any setting you declare wins over nmr's default. Passing `undefined` clears a default rather than falling back to it, which is how you hand the quarantine policy back to `pnpm-workspace.yaml`'s `minimumReleaseAge`.
251
+
252
+ Everything the [taze configuration](https://github.com/antfu-collective/taze#config-file) accepts is accepted here.
253
+
254
+ > **Note:** `--include-locked` is part of both registry entries because a repo that pins dependencies to exact versions (pnpm's `savePrefix: ''`) has no dependency the tool would otherwise consider — without it, an upgrade pass reports nothing at all. Drop it via `.config/nmr.config.ts` if your repo declares version ranges instead.
255
+
136
256
  ## Default script registries
137
257
 
138
258
  These scripts are available out of the box. Repo-wide config (tier 2) and per-package overrides (tier 3) can add to or replace any of them.
@@ -157,6 +277,7 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
157
277
  | `test:coverage` | `pnpm exec vitest --coverage` |
158
278
  | `test:watch` | `pnpm exec vitest --watch` |
159
279
  | `typecheck` | `tsgo --noEmit` |
280
+ | `upgrade` | `nmr-taze --include-locked` |
160
281
  | `view-coverage` | `open coverage/index.html` |
161
282
 
162
283
  #### Integration test variant
@@ -178,7 +299,7 @@ A package gets this variant automatically when it contains a `vitest.integration
178
299
  | Command | Runs |
179
300
  | ------- | --------------------------------- |
180
301
  | `build` | `pnpm --recursive exec nmr build` |
181
- | `ci` | `build`, `check:strict` |
302
+ | `ci` | `build`, `check:strict`, `audit` |
182
303
  | `clean` | `nmr-clean` |
183
304
 
184
305
  #### Check and quality
@@ -236,12 +357,11 @@ A package gets this variant automatically when it contains a `vitest.integration
236
357
 
237
358
  #### Dependencies
238
359
 
239
- | Command | Runs |
240
- | ----------------- | ---------------------------------------- |
241
- | `outdated` | `pnpm outdated --compatible --recursive` |
242
- | `outdated:latest` | `pnpm outdated --recursive` |
243
- | `update` | `pnpm update --recursive` |
244
- | `update:latest` | `pnpm update --latest --recursive` |
360
+ | Command | Runs |
361
+ | --------- | --------------------------------------------------------------- |
362
+ | `upgrade` | `nmr-report-overrides && nmr-taze --include-locked --recursive` |
363
+
364
+ See [dependency upgrades](#dependency-upgrades) for the workflow and its configuration.
245
365
 
246
366
  #### Root-only
247
367
 
@@ -255,14 +375,14 @@ These scripts operate on root-level code only (not workspace packages):
255
375
  | `root:lint:strict` | `strict-lint --ignore-pattern 'packages/**' .` |
256
376
  | `root:test` | `vitest --config ./vitest.root.config.ts` |
257
377
  | `root:typecheck` | `tsgo --noEmit` |
378
+ | `root:upgrade` | `nmr-taze --include-locked` |
258
379
 
259
380
  #### Utilities
260
381
 
261
- | Command | Runs |
262
- | ------------------- | ----------------------- |
263
- | `report-overrides` | `nmr-report-overrides` |
264
- | `sync-agent-files` | `nmr-sync-agent-files` |
265
- | `sync-pnpm-version` | `nmr-sync-pnpm-version` |
382
+ | Command | Runs |
383
+ | ------------------ | ---------------------- |
384
+ | `report-overrides` | `nmr-report-overrides` |
385
+ | `sync-agent-files` | `nmr-sync-agent-files` |
266
386
 
267
387
  ## CLI reference
268
388
 
@@ -272,11 +392,13 @@ These scripts operate on root-level code only (not workspace packages):
272
392
  nmr [flags] <command> [args...]
273
393
  ```
274
394
 
395
+ 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.
396
+
275
397
  | Flag | Description | Default |
276
398
  | ------------------------ | --------------------------------------------------- | ------- |
277
399
  | `-F, --filter <pattern>` | Run command in matching packages | — |
278
400
  | `-R, --recursive` | Run command in all packages | — |
279
- | `-w, --workspace-root` | Force root script registry | — |
401
+ | `-w, --workspace-root` | Use root scripts, running at the monorepo root | — |
280
402
  | `-q, --quiet` | Suppress info messages; show full output on failure | — |
281
403
  | `-?, --help` | Show available commands | — |
282
404
  | `-V, --version` | Show version number | — |
@@ -290,14 +412,14 @@ nmr build # Compile to .js and .d.ts in one pass
290
412
 
291
413
  # From the monorepo root
292
414
  nmr test # Root tests + recursive workspace tests
293
- nmr ci # check:strict + build
415
+ nmr ci # build + check:strict + audit
294
416
 
295
417
  # Target specific packages
296
- nmr -F core test # Test only the core package
297
- nmr -R lint # Lint all workspace packages
418
+ nmr --filter core test # Test only the core package
419
+ nmr --recursive lint # Lint all workspace packages
298
420
 
299
421
  # Force root context from anywhere
300
- nmr -w check # Run root check from a package dir
422
+ nmr --workspace-root check # Run root check from a package dir
301
423
  ```
302
424
 
303
425
  ## Additional subcommands
@@ -306,7 +428,7 @@ These commands are available as `nmr` subcommands and as standalone `nmr-`-prefi
306
428
 
307
429
  ### `report-overrides`
308
430
 
309
- 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.
431
+ 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.
310
432
 
311
433
  ```bash
312
434
  nmr report-overrides
@@ -331,13 +453,16 @@ To expose the synced guidance to Claude Code sessions, add this include to the c
331
453
  @nmr/AGENTS.md
332
454
  ```
333
455
 
334
- ### `sync-pnpm-version`
456
+ #### What belongs in the synced file
335
457
 
336
- Synchronize the pnpm version from the root `package.json` `packageManager` field into the GitHub `code-quality.yaml` workflow file.
458
+ 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.
337
459
 
338
- ```bash
339
- nmr sync-pnpm-version
340
- ```
460
+ A line earns a place in `AGENTS.md` only if both hold:
461
+
462
+ 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.
463
+ 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.
464
+
465
+ 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.
341
466
 
342
467
  ## Standalone utilities
343
468
 
@@ -353,12 +478,22 @@ nmr-clean
353
478
 
354
479
  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.
355
480
 
356
- `typescript` is a peer dependency (`>=5.7.0`, required for `rewriteRelativeImportExtensions`); the consuming repo provides it. Relative imports in source must carry explicit `.ts` extensions for them to be rewritten.
481
+ `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.
357
482
 
358
483
  ```bash
359
484
  nmr-compile
360
485
  ```
361
486
 
487
+ ### `nmr-taze`
488
+
489
+ 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.
490
+
491
+ 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.
492
+
493
+ ```bash
494
+ nmr-taze --include-locked --recursive
495
+ ```
496
+
362
497
  ### `ensure-prepublish-hooks`
363
498
 
364
499
  Verify that all publishable workspace packages have a `prepublishOnly` script. Exits non-zero if any are missing.
@@ -379,25 +514,31 @@ After installing, a consuming repo's root `package.json` scripts shrink to lifec
379
514
 
380
515
  ```json
381
516
  {
382
- "prepare": "lefthook install",
383
- "postinstall": "nmr report-overrides"
517
+ "prepare": "lefthook install"
384
518
  }
385
519
  ```
386
520
 
387
521
  Per-package `package.json` files no longer need script entries. Run `nmr <command>` directly.
388
522
 
389
- ## Consistency tests
523
+ ## Workspace introspection
390
524
 
391
- Export structural consistency checks for use in your test suite:
525
+ 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:
392
526
 
393
527
  ```ts
394
- // __tests__/consistency.test.ts
395
- import { runConsistencyChecks } from '@williamthorsen/nmr/tests';
528
+ // __tests__/packages.test.ts
529
+ import { findMonorepoRoot, getWorkspacePackageDirs } from '@williamthorsen/nmr/workspace';
396
530
 
397
- runConsistencyChecks();
531
+ const monorepoRoot = findMonorepoRoot();
532
+
533
+ for (const packageDir of getWorkspacePackageDirs(monorepoRoot)) {
534
+ // assert something about every workspace package
535
+ }
398
536
  ```
399
537
 
400
- This verifies:
538
+ `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.
539
+
540
+ `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.
541
+
542
+ One divergence from pnpm: a directory counts as a package only if it holds a `package.json`, not a `package.yaml` or `package.json5`.
401
543
 
402
- - pnpm version matches between `package.json` and GitHub workflow
403
- - Node.js version matches between `.tool-versions` and GitHub workflow
544
+ 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.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/cli-taze.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('nmr-taze: build output not found — run `pnpm run build` first\n');
7
+ } else {
8
+ process.stderr.write(`nmr-taze: 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' },
@@ -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
  };
@@ -0,0 +1,10 @@
1
+ import process from 'node:process';
2
+ import { reportError } from '@williamthorsen/nmr-core';
3
+ import { runTaze } from "./commands/taze.js";
4
+ try {
5
+ process.exitCode = runTaze(process.argv.slice(2));
6
+ }
7
+ catch (error) {
8
+ reportError(error instanceof Error ? error.message : String(error));
9
+ process.exitCode = 1;
10
+ }
@@ -4,6 +4,6 @@ export interface BuildOptions {
4
4
  outdir?: string;
5
5
  }
6
6
  export declare function buildPackage(packageDir: string, options?: BuildOptions): Promise<void>;
7
- export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object): Promise<string>;
7
+ export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object, compilerVersion: string): Promise<string>;
8
8
  export declare function resolveTsconfigChain(packageDir: string, configFileName?: string): string[];
9
9
  export declare function resolveBuildCachePath(packageDir: string): string;
@@ -24,14 +24,14 @@ export async function buildPackage(packageDir, options = {}) {
24
24
  ignore: options.ignore ?? DEFAULT_IGNORE,
25
25
  });
26
26
  const dependencies = ['package.json', ...resolveTsconfigChain(packageDir)];
27
- const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
27
+ const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, ts.version, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
28
28
  if (!changed) {
29
29
  return;
30
30
  }
31
31
  emitPackage(packageDir, entryPoints, outdir);
32
32
  await writeBuildCache(cachePath, currentHash);
33
33
  }
34
- export async function computeBuildHash(packageDir, files, emitConfig) {
34
+ export async function computeBuildHash(packageDir, files, emitConfig, compilerVersion) {
35
35
  const hash = createHash('sha256');
36
36
  for (const file of [...files].sort()) {
37
37
  hash.update(file);
@@ -39,6 +39,8 @@ export async function computeBuildHash(packageDir, files, emitConfig) {
39
39
  hash.update(await readFile(path.join(packageDir, file)));
40
40
  }
41
41
  hash.update(JSON.stringify(emitConfig));
42
+ hash.update('\0');
43
+ hash.update(compilerVersion);
42
44
  return hash.digest('hex');
43
45
  }
44
46
  export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json') {
@@ -140,7 +142,7 @@ function resolveSpecifierReplacement(specifier, sourceContainingFile, compilerOp
140
142
  const rewritten = swapTypeScriptExtension(specifier);
141
143
  return rewritten === specifier ? undefined : rewritten;
142
144
  }
143
- if (!aliasPrefixes.some((prefix) => specifier === prefix || specifier.startsWith(prefix))) {
145
+ if (aliasPrefixes.every((prefix) => !(specifier === prefix || specifier.startsWith(prefix)))) {
144
146
  return undefined;
145
147
  }
146
148
  const resolved = ts.resolveModuleName(specifier, sourceContainingFile, compilerOptions, ts.sys).resolvedModule;
@@ -207,10 +209,10 @@ function findNearestNodeModulesHost(startDir) {
207
209
  }
208
210
  return current;
209
211
  }
210
- async function detectBuildChanges(packageDir, files, emitConfig, cachePath, outputPresent) {
212
+ async function detectBuildChanges(packageDir, files, emitConfig, compilerVersion, cachePath, outputPresent) {
211
213
  const packageName = path.basename(packageDir);
212
214
  const previousHash = existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : undefined;
213
- const currentHash = await computeBuildHash(packageDir, files, emitConfig);
215
+ const currentHash = await computeBuildHash(packageDir, files, emitConfig, compilerVersion);
214
216
  if (previousHash === currentHash) {
215
217
  if (outputPresent) {
216
218
  console.info(`${SKIPPED_ICON} ${packageName}: No changes detected. Skipping build.`);
@@ -235,7 +237,7 @@ async function writeBuildCache(cachePath, hash) {
235
237
  await writeFile(cachePath, hash);
236
238
  }
237
239
  function assertSupportedTypeScript() {
238
- const [majorPart, minorPart] = ts.versionMajorMinor.split('.');
240
+ const [majorPart, minorPart] = ts.versionMajorMinor.split('.', 2);
239
241
  const major = majorPart === undefined ? 0 : Number(majorPart);
240
242
  const minor = minorPart === undefined ? 0 : Number(minorPart);
241
243
  const tooOld = major < MINIMUM_TYPESCRIPT_MAJOR || (major === MINIMUM_TYPESCRIPT_MAJOR && minor < MINIMUM_TYPESCRIPT_MINOR);
@@ -1,9 +1,10 @@
1
1
  import { rm } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { loadConfig } from "../config.js";
4
- import { findContainingPackageDir, findMonorepoRoot, getWorkspacePackageDirs } from "../context.js";
4
+ import { findContainingPackageDir } from "../context.js";
5
5
  import { applyDevBin, buildWorkspaceRegistry, hasIntegrationTestConfig, resolveScript } from "../resolver.js";
6
6
  import { runCommand } from "../runner.js";
7
+ import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
7
8
  import { resolveBuildCachePath } from "./build.js";
8
9
  const CLEAN_ICON = '🧹';
9
10
  const CLEAN_COMMAND = 'clean';
@@ -1,8 +1,8 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getWorkspacePackageDirs } from "../context.js";
4
3
  import { readPackageJson } from "../helpers/package-json.js";
5
4
  import { isObject } from "../helpers/type-guards.js";
5
+ import { getWorkspacePackageDirs } from "../workspace.js";
6
6
  export const DEFAULT_HOOK = 'npm run build';
7
7
  export function ensurePrepublishHooks(monorepoRoot, options) {
8
8
  const hookCommand = options.command ?? DEFAULT_HOOK;
@@ -0,0 +1,12 @@
1
+ import type { Writable } from 'node:stream';
2
+ export interface SpawnOutcome {
3
+ status: number | null;
4
+ error?: Error | undefined;
5
+ }
6
+ export interface RunTazeOptions {
7
+ resolveCliPath?: () => string;
8
+ spawn?: (nodePath: string, argv: string[]) => SpawnOutcome;
9
+ stderr?: Writable;
10
+ }
11
+ export declare function runTaze(args: string[], options?: RunTazeOptions): number;
12
+ export declare function resolveTazeCliPath(): string;