@williamthorsen/nmr 0.38.0 → 0.40.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 (112) hide show
  1. package/.meta/changelog.json +212 -4
  2. package/.readyup/kits/default.js +124 -104
  3. package/.readyup/manifest.json +12 -7
  4. package/CHANGELOG.md +108 -0
  5. package/README.md +41 -47
  6. package/agents/README.md +8 -8
  7. package/agents/guidance/rulebooks/nmr.md +8 -8
  8. package/bin/ensure-prepublish-hooks.js +1 -1
  9. package/bin/nmr-clean.js +1 -1
  10. package/bin/nmr-compile.js +1 -1
  11. package/bin/nmr-fmt.js +1 -1
  12. package/bin/nmr-report-catalog.js +1 -1
  13. package/bin/nmr-report-overrides.js +1 -1
  14. package/bin/nmr-taze.js +1 -1
  15. package/bin/nmr.js +1 -1
  16. package/dist/esm/UserError.d.ts +1 -2
  17. package/dist/esm/check-cache.d.ts +61 -63
  18. package/dist/esm/check-cache.js +24 -24
  19. package/dist/esm/cli-ensure-prepublish-hooks.js +4 -4
  20. package/dist/esm/commands/build-output.d.ts +37 -38
  21. package/dist/esm/commands/build-output.js +9 -9
  22. package/dist/esm/commands/build.d.ts +13 -14
  23. package/dist/esm/commands/build.js +67 -65
  24. package/dist/esm/commands/clean.d.ts +6 -6
  25. package/dist/esm/commands/clean.js +11 -11
  26. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +7 -7
  27. package/dist/esm/commands/ensure-prepublish-hooks.js +41 -41
  28. package/dist/esm/commands/fmt.d.ts +8 -8
  29. package/dist/esm/commands/fmt.js +33 -33
  30. package/dist/esm/commands/report-catalog.d.ts +5 -7
  31. package/dist/esm/commands/report-catalog.js +10 -8
  32. package/dist/esm/commands/report-overrides.d.ts +0 -2
  33. package/dist/esm/commands/report-overrides.js +8 -8
  34. package/dist/esm/commands/taze.d.ts +9 -8
  35. package/dist/esm/config.d.ts +5 -5
  36. package/dist/esm/config.js +27 -27
  37. package/dist/esm/context.d.ts +1 -2
  38. package/dist/esm/context.js +8 -8
  39. package/dist/esm/default-scripts.d.ts +5 -6
  40. package/dist/esm/defineConfig.d.ts +1 -1
  41. package/dist/esm/git-ignored-paths.d.ts +9 -0
  42. package/dist/esm/git-ignored-paths.js +10 -0
  43. package/dist/esm/glyphs.d.ts +3 -3
  44. package/dist/esm/help.d.ts +4 -4
  45. package/dist/esm/help.js +21 -21
  46. package/dist/esm/helpers/clampToBytes.d.ts +4 -5
  47. package/dist/esm/helpers/clampToBytes.js +3 -3
  48. package/dist/esm/helpers/config-path.d.ts +0 -2
  49. package/dist/esm/helpers/createBoundedBuffer.d.ts +3 -3
  50. package/dist/esm/helpers/createBoundedBuffer.js +2 -2
  51. package/dist/esm/helpers/deriveExcerpt.d.ts +4 -5
  52. package/dist/esm/helpers/duration.d.ts +10 -11
  53. package/dist/esm/helpers/duration.js +7 -7
  54. package/dist/esm/helpers/filter-selection.d.ts +19 -19
  55. package/dist/esm/helpers/filter-selection.js +12 -12
  56. package/dist/esm/helpers/findClosestName.d.ts +1 -1
  57. package/dist/esm/helpers/findClosestName.js +15 -15
  58. package/dist/esm/helpers/hook-name.d.ts +2 -5
  59. package/dist/esm/helpers/package-json.d.ts +11 -10
  60. package/dist/esm/helpers/package-json.js +26 -25
  61. package/dist/esm/helpers/readStringValues.d.ts +2 -3
  62. package/dist/esm/helpers/reportClosing.d.ts +8 -9
  63. package/dist/esm/helpers/transcript.d.ts +15 -15
  64. package/dist/esm/helpers/transcript.js +14 -14
  65. package/dist/esm/helpers/type-guards.d.ts +1 -0
  66. package/dist/esm/output-style.d.ts +12 -13
  67. package/dist/esm/prettier.d.ts +2 -2
  68. package/dist/esm/prettier.js +5 -5
  69. package/dist/esm/recording.d.ts +25 -20
  70. package/dist/esm/recording.js +28 -21
  71. package/dist/esm/replay-assembly.d.ts +10 -10
  72. package/dist/esm/report-format.d.ts +15 -20
  73. package/dist/esm/report-format.js +7 -7
  74. package/dist/esm/reportCliFailure.d.ts +2 -3
  75. package/dist/esm/resolve-scripts.d.ts +4 -5
  76. package/dist/esm/resolveBinStyles.d.ts +4 -5
  77. package/dist/esm/resolveBinStyles.js +3 -3
  78. package/dist/esm/resolver.d.ts +16 -20
  79. package/dist/esm/resolver.js +15 -15
  80. package/dist/esm/runCli.d.ts +6 -6
  81. package/dist/esm/runCli.js +261 -195
  82. package/dist/esm/runner.d.ts +20 -20
  83. package/dist/esm/runner.js +18 -18
  84. package/dist/esm/steps.d.ts +51 -38
  85. package/dist/esm/steps.js +32 -27
  86. package/dist/esm/taze.d.ts +1 -1
  87. package/dist/esm/tests.d.ts +23 -18
  88. package/dist/esm/tests.js +21 -9
  89. package/dist/esm/tiers.d.ts +28 -24
  90. package/dist/esm/tiers.js +13 -9
  91. package/dist/esm/types.d.ts +5 -5
  92. package/dist/esm/verbosity.d.ts +23 -21
  93. package/dist/esm/verbosity.js +7 -7
  94. package/dist/esm/verdict.d.ts +39 -32
  95. package/dist/esm/verdict.js +63 -50
  96. package/dist/esm/vitest-source-resolution.d.ts +11 -11
  97. package/dist/esm/vitest-source-resolution.js +49 -47
  98. package/dist/esm/vitest.d.ts +27 -26
  99. package/dist/esm/vitest.js +90 -33
  100. package/dist/esm/workspace.d.ts +6 -22
  101. package/dist/esm/workspace.js +9 -67
  102. package/docs/check-cache.md +57 -57
  103. package/docs/prettier.md +10 -10
  104. package/docs/reporting.md +48 -44
  105. package/docs/scripts.md +29 -29
  106. package/docs/upgrades.md +18 -18
  107. package/docs/utilities.md +13 -13
  108. package/docs/vitest.md +68 -66
  109. package/docs/workspace.md +27 -4
  110. package/package.json +6 -6
  111. package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
  112. package/dist/esm/helpers/workspace-patterns.js +0 -29
package/docs/vitest.md CHANGED
@@ -4,40 +4,40 @@ The Vitest config that `@williamthorsen/nmr/vitest` publishes: its test tiers an
4
4
 
5
5
  The [README](../README.md#shared-vitest-config) shows the one-line config file.
6
6
 
7
- `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. `vite` is an optional peer as well (`>=8.0.0 <9`), required only by the [`tsconfigPaths` setting](#resolving-through-tsconfig-paths); a repo that declares a `vite` of its own below that range sees an unmet-peer warning whether or not it sets the flag.
7
+ `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. `vite` is an optional peer as well (`>=8.0.0 <9`), required only by the [`tsconfigPaths` setting](#resolving-through-tsconfig-paths); a repo that declares a `vite` of its own below that range sees an unmet-peer warning whether or not it sets the flag.
8
8
 
9
9
  ## Test tiers
10
10
 
11
- The config declares four projects, an ordered ladder named for the furthest thing a test reaches:
11
+ The config declares four projects, an ordered ladder named for the furthest thing that a test reaches:
12
12
 
13
- | Project | Matches | Reaches |
14
- | ----------- | -------------------------------------- | ---------------------------------------------------- |
15
- | `unit` | every test file the others don't claim | nothing outside the package's own dependency closure |
16
- | `tool` | `*.tool.test.{ts,tsx}` | a program the environment must supply |
17
- | `localhost` | `*.localhost.test.{ts,tsx}` | a service running on this machine |
18
- | `remote` | `*.remote.test.{ts,tsx}` | a machine that isn't this one |
13
+ | Project | Matches | Reaches |
14
+ | ----------- | ------------------------------------------- | ---------------------------------------------------- |
15
+ | `unit` | every test file that the others don't claim | nothing outside the package's own dependency closure |
16
+ | `tool` | `*.tool.test.{ts,tsx}` | a program that the environment must supply |
17
+ | `localhost` | `*.localhost.test.{ts,tsx}` | a service running on this machine |
18
+ | `remote` | `*.remote.test.{ts,tsx}` | a machine that isn't this one |
19
19
 
20
20
  All four match only under a `__tests__` directory.
21
21
 
22
- **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`.
22
+ **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 is: A bundled dependency is inside the package's own closure and is always present, so reaching one is `unit`.
23
23
 
24
- **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.
24
+ **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. It is `unit` because it reaches nothing beyond the test process _while running_. The filesystem belongs to `unit` for the same reason the tiers exist: They gate on what must be available, and the filesystem always is.
25
25
 
26
26
  **Every test file names its tier**, in the form `<subject>[.<aspect>].<tier>.test.ts`. Only the segment immediately before `.test.` selects a project, so an earlier one is free for documentation: `resolveConfig.packaged.unit.test.ts` reads as a companion to `resolveConfig.unit.test.ts` and still names `unit`.
27
27
 
28
- `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 a safety net, not a licence to omit the tier: a file whose tier segment is missing or misspelt runs under `unit` and reports success, so no test run distinguishes it from a conformant one until the repo declares the [conventions check](#gating-the-test-file-conventions). In a repo that has not, [nmr's readyup kit](../README.md#conformance-checks) is what reports those files.
28
+ Because `unit` is defined by subtracting the tiers rather than by an allow-list of infixes, a file such as `parser.smoke.test.ts` runs under `unit` instead of being silently dropped. That is a safety net, not a licence to omit the tier: A file whose tier segment is missing or misspelt runs under `unit` and reports success. Until the repo declares the [conventions check](#gating-the-test-file-conventions), a test run cannot distinguish that file from a conformant one. In a repo that has not, [nmr's readyup kit](../README.md#conformance-checks) reports those files.
29
29
 
30
- `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.
30
+ `localhost` and `remote` do not have a 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.
31
31
 
32
- 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.
32
+ Every tier above `unit` has a 30-second `testTimeout` and `hookTimeout`, whereas `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, and raising the test budget alone never affects it. `unit` keeps the tight budgets, which is what makes a hung unit test fail fast.
33
33
 
34
- 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.
34
+ To raise a budget for one tier alone, use the `tiers` seam below. The `project` seam merges over both budgets as well, but like every option passed through it the value applies to all four projects at once -- raising a tier's budget raises `unit`'s with it. To raise the timeout for a single file rather than a whole tier, pass a timeout to the individual test or hook, which stays the narrower tool.
35
35
 
36
36
  ### Test selections
37
37
 
38
38
  Select the tiers at run time with `--project`, which unions when repeated and accepts negation.
39
39
 
40
- 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.
40
+ 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 adding extra config files.
41
41
 
42
42
  | To run | Command |
43
43
  | --------------------------------------------- | ----------- |
@@ -46,90 +46,92 @@ Every package resolves the same six test commands. Nothing is detected on disk:
46
46
  | Tests reaching a program alone | `test:tool` |
47
47
  | Every tier, including those needing a service | `test:all` |
48
48
 
49
- `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`.
49
+ `test` names the tiers that it runs rather than negating the ones that 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`.
50
50
 
51
- `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.
51
+ `test:coverage` and `test:watch` are the same selection as `test` in a different mode. nmr does not define `test:tool --coverage` equivalents, because they are not needed: Everything after a command name is forwarded untouched, and `nmr test:tool --coverage` already works.
52
52
 
53
53
  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.
54
54
 
55
- 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.
55
+ A run that does not collect any test files passes. That is what lets `nmr test:tool` fan out across a monorepo in which most packages do not have any tool-tier tests, and it means a package without any tests at all does not need a `"test": ""` override.
56
56
 
57
- The root registry carries the same six names, 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.
57
+ The root registry defines the same six names, 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.
58
58
 
59
- `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.
59
+ `test:coverage` chains `root:test` rather than a `root:test:coverage`, because the root config does not report any coverage of its own; packages cover their own sources.
60
60
 
61
- `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`.
61
+ `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`.
62
62
 
63
63
  ## Gating the test-file conventions
64
64
 
65
- Both halves of the convention are silent on their own: an untiered file runs under the residual `unit` and passes, and a file outside `__tests__` is collected by nothing at all. `@williamthorsen/nmr/tests` exports a check that reports both, which a repo declares from a one-line test of its own:
65
+ Both halves of the convention are silent on their own: An untiered file runs under the residual `unit` and passes, and a file outside `__tests__` is collected by nothing at all. `@williamthorsen/nmr/tests` exports a check that reports both, which a repo declares from a one-line test of its own:
66
66
 
67
67
  ```ts
68
- // __tests__/test-file-conventions.unit.test.ts
68
+ // __tests__/test-file-conventions.tool.test.ts
69
69
  import { checkTestFileConventions } from '@williamthorsen/nmr/tests';
70
70
 
71
71
  checkTestFileConventions();
72
72
  ```
73
73
 
74
- It declares one suite with an assertion per half, each carrying the remedy that fixes it: the four tier names and the rename form, or the `__tests__` directory the file belongs in. The guard names its own tier like any other test file, so a misnamed guard reports itself.
74
+ It declares one suite with an assertion per half, each stating the remedy that fixes it: the four tier names and the rename form, or the `__tests__` directory in which the file belongs. The guard names its own tier like any other test file, so a misnamed guard reports itself. That tier is `tool`, because the check asks git which paths it ignores.
75
75
 
76
- A repo linting with `vitest/require-hook` needs a disable directive on that call, which the rule reads as setup work where it declares the suite:
76
+ A repo linting with `vitest/require-hook` needs a disable directive on that call, which the rule reads as setup work, whereas it declares the suite:
77
77
 
78
78
  ```ts
79
- // eslint-disable-next-line vitest/require-hook -- the call declares the suite, where the rule reads it as setup work.
79
+ // eslint-disable-next-line vitest/require-hook -- the call declares the suite, whereas the rule reads it as setup work.
80
80
  checkTestFileConventions();
81
81
  ```
82
82
 
83
- The sweep starts at the monorepo root rather than at the directory Vitest supplies, so one call covers the whole repo from whichever package the run began in. `rootDir` overrides that, for a check pointed at a tree other than the repo's own.
83
+ Because the sweep starts at the monorepo root rather than at the directory that Vitest supplies, one call covers the whole repo from whichever package the run began in. `rootDir` overrides that, for a check pointed at a tree other than the repo's own.
84
84
 
85
- An exported check rather than a step nmr runs for you, because nothing nmr runs reaches the whole tree: `nmr test` fans out per package, and a direct `vitest` invocation runs no nmr script at all. A test the repo owns also lets the repo scope what the sweep covers.
85
+ The check is exported rather than run by nmr as a step, because nothing that nmr runs covers the whole tree: `nmr test` fans out per package, and a direct `vitest` invocation does not run any nmr script at all. A test that the repo owns also lets the repo scope what the sweep covers.
86
86
 
87
- `exclude` names directory basenames the sweep prunes at any depth, additive to the ones nmr always prunes:
87
+ `excludedBasenames` names directory basenames for the sweep to prune at any depth, additive to the ones that nmr always prunes:
88
88
 
89
89
  ```ts
90
- checkTestFileConventions({ exclude: ['cypress', 'generated'] });
90
+ checkTestFileConventions({ excludedBasenames: ['cypress', 'generated'] });
91
91
  ```
92
92
 
93
- **Pass the same array to [`testCollectionExclude`](#what-the-config-excludes).** The two describe one scope, and naming a directory in only one is a defect in either direction. Pruned from the sweep alone, the directory's test files still run and still report nothing, which is the silence this check exists to end. Excluded from collection alone, the sweep reports files a consumer has no reason to act on.
93
+ **Pass the same array to [`testCollectionExclude`](#what-the-config-excludes).** The two describe one scope, and naming a directory in only one is a defect in either direction. When the directory is pruned from the sweep alone, its test files still run and still report nothing, which is the silence that this check exists to end. When it is excluded from collection alone, the sweep reports files that a consumer does not need to act on.
94
94
 
95
- Expect the first run to fail in a repo that has never gated this. Vitest 4 excludes only `node_modules` and `.git` by default, so a `__tests__` tree under a generated or vendored directory is collected and runs today; naming that directory in both lists is the fix, rather than widening what nmr prunes for everyone.
95
+ **Paths that git ignores are out of scope for both.** The sweep skips every untracked path that git ignores under the swept root, and the shared configs exclude the same paths from collection, so local build output such as a stale `.netlify/` directory is neither reported nor run. Git decides what is ignored: A tracked file stays in scope even when it matches an ignore pattern. Outside a git repository, or when git cannot run, nothing counts as ignored and only the named directories are pruned. A generated directory that the repo's `.gitignore` already covers therefore does not need an entry in either list.
96
96
 
97
- In a repo that has not declared the check, [nmr's readyup kit](../README.md#conformance-checks) reports both halves and warns that the check is missing. Once a test file under `__tests__` imports it, the kit skips both reports and names that file as the reason. Only the check reads the repo's `exclude`, and a kit report beside it could only repeat its findings or name a directory that the repo has pruned.
97
+ Expect the first run to fail in a repo that has never gated this. Vitest 4 excludes only `node_modules` and `.git` by default, so a `__tests__` tree under a generated or vendored directory that git tracks is collected and runs today; naming that directory in both lists is the fix, rather than widening what nmr prunes for everyone.
98
+
99
+ In a repo that has not declared the check, [nmr's readyup kit](../README.md#conformance-checks) reports both halves and warns that the check is missing. Once a test file under `__tests__` imports it, the kit skips both reports and names that file as the reason. Only the check reads the repo's `excludedBasenames`, and a kit report beside it could only repeat its findings or name a directory that the repo has pruned.
98
100
 
99
101
  ## What the config supplies
100
102
 
101
- Two settings a workspace test runner needs almost universally are part of what the factory produces, so a config file declares neither:
103
+ Two settings that a workspace test runner needs almost universally are part of what the factory produces, so a config file declares neither:
102
104
 
103
- - **A package outside `node_modules` resolves from source.** A cross-package import reaches the dependency's `.ts` rather than its `dist/`, through the `source` condition declared by its `exports` map, so a suite runs without a prior build. The reach is what the boundary names: a workspace package, or one linked into the repo from elsewhere, whose real path sits outside every `node_modules`. A package declaring no such condition is unaffected, and so is every package under `node_modules`, whatever its `exports` map declares.
104
- - **Git subprocesses are isolated.** Every project loads a setup file that points `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at the null device, sets `GIT_CONFIG_NOSYSTEM` and `GIT_ATTR_NOSYSTEM`, and injects `core.excludesFile` and `core.attributesFile` through `GIT_CONFIG_COUNT` and its key/value pairs, so a test that spawns `git` reads neither the developer's identity, nor a signing config that can block on a passphrase, nor their global ignore rules and attributes. Those last two need a step of their own: neither path is config-derived, so git resolves each from `$XDG_CONFIG_HOME/git/`, or from `~/.config/git/` where that variable is unset, whatever the config variables say. `GIT_CONFIG_GLOBAL` already replaces `$XDG_CONFIG_HOME/git/config`, so those two are the rest of what that directory supplies; the system attributes file at `$(prefix)/etc/gitattributes` is reached by no config variable at all, which is what `GIT_ATTR_NOSYSTEM` covers. Redirecting `XDG_CONFIG_HOME` would reach the per-user pair as well, and is not used, because other tools spawned by a test read that variable for configuration of their own: pnpm fails to start under `XDG_CONFIG_HOME=/dev/null`. An injected key outranks repository-local config, so a repository setting its own `core.excludesFile` is overridden under the isolation; a test needing its own value passes it on the invocation as `git -c core.excludesFile=...`, which outranks the injection. The setup file leads each project's `setupFiles`, ahead of every entry the layers add, because a shared setup establishes the environment the rest run in.
105
+ - **A package outside `node_modules` resolves from source.** Because a cross-package import resolves to the dependency's `.ts` rather than its `dist/`, through the `source` condition declared by its `exports` map, a suite runs without a prior build. The reach is what the boundary names: a workspace package, or one linked into the repo from elsewhere, whose real path is outside every `node_modules`. A package that does not declare a `source` condition is unaffected, and so is every package under `node_modules`, whatever its `exports` map declares.
106
+ - **Git subprocesses are isolated.** Every project loads a setup file that points `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at the null device, sets `GIT_CONFIG_NOSYSTEM` and `GIT_ATTR_NOSYSTEM`, and injects `core.excludesFile` and `core.attributesFile` through `GIT_CONFIG_COUNT` and its key/value pairs, so a test that spawns `git` reads neither the developer's identity, nor a signing config that can block on a passphrase, nor their global ignore rules and attributes. Those last two need a step of their own: Because neither path is config-derived, git resolves each from `$XDG_CONFIG_HOME/git/`, or from `~/.config/git/` when that variable is unset, whatever the config variables say. `GIT_CONFIG_GLOBAL` already replaces `$XDG_CONFIG_HOME/git/config`; those two are the rest of what that directory supplies. The system attributes file at `$(prefix)/etc/gitattributes` is not named by any config variable at all, which is what `GIT_ATTR_NOSYSTEM` covers. Redirecting `XDG_CONFIG_HOME` would cover the per-user pair as well, but the setup file does not redirect it, because other tools spawned by a test read that variable for configuration of their own: pnpm fails to start under `XDG_CONFIG_HOME=/dev/null`. An injected key outranks repository-local config, so a repository setting its own `core.excludesFile` is overridden under the isolation; a test needing its own value passes it on the invocation as `git -c core.excludesFile=...`, which outranks the injection. The setup file leads each project's `setupFiles`, ahead of every entry that the layers add, because a shared setup establishes the environment in which the rest run.
105
107
 
106
- Both defaults exist because omitting either is silent rather than loud: the tiers still exist and the suite still runs green, testing `dist/` and the developer's git identity instead. Turn either off through its own option, which folds across layers like any other, the last layer to declare it winning:
108
+ Both defaults exist because omitting either is silent rather than loud: The tiers still exist and the suite still runs green, testing `dist/` and the developer's git identity instead. Turn either off through its own option, which folds across layers like any other, the last layer to declare it winning:
107
109
 
108
110
  ```ts
109
- export default defineVitestConfig({ isolateGit: false, resolveFromSource: false });
111
+ export default defineVitestConfig({ shouldIsolateGit: false, shouldResolveFromSource: false });
110
112
  ```
111
113
 
112
- `resolveFromSource: false` is for a repo whose own packages declare a `source` condition that it does not want in tests; `isolateGit: false` is for a suite meant to read the developer's own git configuration.
114
+ `shouldResolveFromSource: false` is for a repo whose own packages declare a `source` condition that it does not want in tests; `shouldIsolateGit: false` is for a suite meant to read the developer's own git configuration.
113
115
 
114
- **Vite replaces its condition defaults rather than extending them.** A config writing `conditions: ['my-condition']` by hand therefore drops `module`, so a dependency exposing a `module` entry falls through to whatever its `exports` lists next. A replaced list still resolves `node` and `development` under Vitest, which is why nothing in a test run reports the loss. The factory emits Vite's defaults for each environment, whichever way `resolveFromSource` is set, so a layer adding a condition extends a complete list rather than replacing one.
116
+ **Vite replaces its condition defaults rather than extending them.** A config writing `conditions: ['my-condition']` by hand therefore drops `module`, so a dependency exposing a `module` entry falls through to whatever its `exports` lists next. A replaced list still resolves `node` and `development` under Vitest, which is why nothing in a test run reports the loss. Because the factory emits Vite's defaults for each environment, whichever way `shouldResolveFromSource` is set, a layer adding a condition extends a complete list rather than replacing one.
115
117
 
116
- **The condition is resolved by a plugin rather than named as a Vite condition, which is what bounds its reach.** Vitest turns `ssr.resolve.conditions` into `--conditions` flags on the worker process, where Node applies every entry to each package that it resolves natively. Node refuses to strip types from a file under `node_modules`, so a `source` condition reaching that far makes any dependency declaring a TypeScript entry fail to load, the dependency of a dependency included. Resolving inside Vite keeps the condition where nmr decides its reach, and leaves a published package to resolve through its other conditions however it declares `source`.
118
+ **The condition is resolved by a plugin rather than named as a Vite condition, which is what bounds its reach.** Vitest turns `ssr.resolve.conditions` into `--conditions` flags on the worker process, where Node applies every entry to each package that it resolves natively. Node refuses to strip types from a file under `node_modules`, so a `source` condition reaching that far makes any dependency declaring a TypeScript entry fail to load, the dependency of a dependency included. Resolving inside Vite lets nmr decide the condition's reach, and leaves a published package to resolve through its other conditions however it declares `source`.
117
119
 
118
- **The boundary is Vite's resolution, not the package's role.** A workspace package that an externalized dependency imports resolves through Node, which carries no `source` condition, so it reaches that package's build output. Nothing else in a suite resolves that way.
120
+ **The boundary is Vite's resolution, not the package's role.** Because Node does not apply the `source` condition, a workspace package that an externalized dependency imports resolves through Node to its build output. Nothing else in a suite resolves that way.
119
121
 
120
122
  ## Resolving through tsconfig paths
121
123
 
122
- A third resolution setting sits alongside those two, and this one is off. `tsconfigPaths: true` emits Vite's `resolve.tsconfigPaths`, so a test reaches an aliased specifier through the `paths` its `tsconfig.json` declares, the way `tsc` does:
124
+ A third resolution setting exists alongside those two, and this one is off. `tsconfigPaths: true` emits Vite's `resolve.tsconfigPaths`, so a test resolves an aliased specifier through the `paths` that its `tsconfig.json` declares, the way `tsc` does:
123
125
 
124
126
  ```ts
125
127
  export default defineVitestConfig({ tsconfigPaths: true });
126
128
  ```
127
129
 
128
- It folds across layers like the other two, the last layer to declare it winning. It needs no `ssr` counterpart the way a condition does: Vite holds this setting outside its per-environment resolve options, so the single key reaches the environment a test's own imports resolve through.
130
+ It folds across layers like the other two, the last layer to declare it winning. It does not need an `ssr` counterpart the way a condition does: Because Vite keeps this setting outside its per-environment resolve options, the single key applies to the environment through which a test's own imports resolve.
129
131
 
130
- **It is an opt-in because its silent direction is the reverse of the two defaults'.** Leaving it off fails loudly, with an unresolved import naming the specifier. Turning it on is the silent direction, for a repo that declares `paths` for `tsc` alone: a specifier that resolved through a package's `exports` starts resolving through the alias instead, and nothing in the run reports the switch. A repo declaring no `paths` is unaffected either way.
132
+ **It is an opt-in because its silent direction is the reverse of the two defaults'.** Leaving it off fails loudly, with an unresolved import naming the specifier. Turning it on is the silent direction, for a repo that declares `paths` for `tsc` alone: A specifier that resolved through a package's `exports` starts resolving through the alias instead, and nothing in the run reports the switch. A repo that does not declare `paths` is unaffected either way.
131
133
 
132
- **It requires Vite 8**, where `resolve.tsconfigPaths` arrived. nmr declares `vite` as an optional peer at `>=8.0.0 <9`, so a repo holding a stale `vite` of its own hears about it at install time. That check does not reach a repo whose only Vite comes in through Vitest's own dependency, and Vitest 4 accepts Vite 6 and 7: on those, the emitted key does nothing and the aliased import fails exactly as it would with the flag off.
134
+ **It requires Vite 8**, the version that introduced `resolve.tsconfigPaths`. nmr declares `vite` as an optional peer at `>=8.0.0 <9`, so a repo that declares a stale `vite` of its own sees a warning at install time. That check does not apply to a repo whose only Vite comes in through Vitest's own dependency, and Vitest 4 accepts Vite 6 and 7: On those, the emitted key does nothing and the aliased import fails exactly as it would with the flag off.
133
135
 
134
136
  ## Customizing by scope
135
137
 
@@ -146,19 +148,19 @@ export default defineVitestConfig({
146
148
  });
147
149
  ```
148
150
 
149
- `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.
151
+ `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. Because every surface merges into the generated config rather than replacing it, overriding one coverage field leaves the rest intact.
150
152
 
151
- `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.
153
+ `tiers` is keyed by tier name and applies to all four, `unit` included. A key that does not name a tier throws and names the valid ones: Ignoring it would leave the suite green on the budget that the key failed to change, which nothing in the run reports. A tier target sets only the keys that it names, so raising `testTimeout` alone leaves that tier's `hookTimeout` at 30 seconds.
152
154
 
153
- 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.
155
+ Arrays concatenate rather than replace. `exclude` and `setupFiles` therefore add to what the config already declares, and a surface can neither narrow `include` nor drop a default exclusion. Adding an `include` pattern through `project` widens all four projects at once: A file matching it is collected by each and runs four times.
154
156
 
155
- `resolve.conditions` concatenates too, onto the Vite defaults the factory already emits, 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. Every entry also reaches Node, which resolves each package reached by a test's imports under `node_modules`, so a condition added here is one that the package's `exports` map may answer. `resolve.alias` is the one key that merges override-first, so a later alias takes precedence over an earlier one.
157
+ `resolve.conditions` concatenates too, onto the Vite defaults that the factory already emits, but layer order does not matter 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 that an earlier layer contributed. Every entry also reaches Node, which resolves each package reached by a test's imports under `node_modules`, so a condition added here is one that the package's `exports` map may match. `resolve.alias` is the one key that merges override-first: A later alias takes precedence over an earlier one.
156
158
 
157
- **A condition for the tests' own resolution goes under `ssr`.** `resolve` is per-environment, and Vitest resolves a test's imports through the server environment, so `root: { ssr: { resolve: { conditions: ['my-condition'] } } }` is the seam that reaches them. A top-level `root: { resolve: { conditions: [...] } }` entry reaches the client environment, which only browser-mode tests resolve through; it composes into that array and never reports that a node test did not see it. `resolve.alias` is not per-environment, so a top-level alias reaches both.
159
+ **A condition for the tests' own resolution goes under `ssr`.** Because `resolve` is per-environment and Vitest resolves a test's imports through the server environment, `root: { ssr: { resolve: { conditions: ['my-condition'] } } }` is the seam that applies to them. A top-level `root: { resolve: { conditions: [...] } }` entry applies to the client environment, which only browser-mode tests resolve through; it composes into that array and never reports that a node test did not see it. `resolve.alias` is not per-environment, so a top-level alias applies to both.
158
160
 
159
161
  ## Sharing options across config files
160
162
 
161
- 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:
163
+ Vitest resolves one config per run: A package that adds its own `vitest.config.ts` stops seeing the repo's root config entirely -- not the one setting that it meant to change, all of them. Pass the shared settings as a layer instead:
162
164
 
163
165
  ```ts
164
166
  // vitest.shared.ts
@@ -181,21 +183,21 @@ import { shared } from '../../vitest.shared.ts';
181
183
  export default defineVitestConfig(shared, { project: { environment: 'jsdom' } });
182
184
  ```
183
185
 
184
- 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.
186
+ 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, which lets `defineVitestConfig(shared, isCI ? ciLayer : undefined)` compose 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.
185
187
 
186
- 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.
188
+ Order matters for `setupFiles`, because a shared setup file establishes the environment in which the package's own then runs. A later layer's `project` block likewise wins over an earlier layer's `tiers` target: The nearer config is the more deliberate.
187
189
 
188
- **A package's own `vite.config.ts` fills that one slot too.** Vitest searches for a config by ascending from the run root and stopping at the first directory holding any of `vitest.config.*` or `vite.config.*`, so a package carrying only a Vite config ends the search there and never reaches the root config. Within a directory `vitest.config.*` is tried first, which makes the fix a Vitest config beside the Vite one, calling `defineVitestConfig()` as any other package config does. Without it the package declares no projects and every tier-selecting command fails with `No projects matched the filter`, `nmr ci` among them. [nmr's readyup kit](../README.md#conformance-checks) reports a package in that state.
190
+ **A package's own `vite.config.ts` also counts as that one config.** Vitest searches for a config by ascending from the run root and stopping at the first directory containing any of `vitest.config.*` or `vite.config.*`, so a package with only a Vite config ends the search there and never reaches the root config. Within a directory `vitest.config.*` is tried first, which makes the fix a Vitest config beside the Vite one, calling `defineVitestConfig()` as any other package config does. Without it the package does not declare any projects, and every tier-selecting command fails with `No projects matched the filter`, `nmr ci` among them. [nmr's readyup kit](../README.md#conformance-checks) reports a package in that state.
189
191
 
190
192
  **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.
191
193
 
192
- **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.
194
+ **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.
193
195
 
194
- 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. Source resolution and git isolation need no such guard: the factory supplies both, so a config file cannot omit them by forgetting a layer.
196
+ 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 that excludes one package's tests. Source resolution and git isolation do not need such a guard: The factory supplies both, so a config file cannot omit them by forgetting a layer.
195
197
 
196
198
  ## What the config excludes
197
199
 
198
- Collection skips `**/node_modules/**`, `**/.git/**`, `**/coverage/**`, and `**/dist/**`. Coverage skips `**/__{fixtures,mocks,tests}__/**`, `**/index.ts`, and `**/*.d.ts`.
200
+ Collection skips `**/node_modules/**`, `**/.git/**`, `**/coverage/**`, and `**/dist/**`, and every untracked path that git ignores: `defineVitestConfig` asks git about the working directory, which is Vitest's default root, and `defineRootVitestConfig` about `monorepoRoot`. Each ignored path becomes a glob anchored to that root, and a tracked file matching an ignore pattern is still collected. These are the paths that the [conventions check](#gating-the-test-file-conventions) skips; outside a git repository, neither skips anything on git's account. Coverage skips `**/__{fixtures,mocks,tests}__/**`, `**/index.ts`, and `**/*.d.ts`.
199
201
 
200
202
  `testCollectionExclude` adds to the collection list. It takes directory basenames rather than globs, matched at any depth, so the same array serves the [conventions check](#gating-the-test-file-conventions):
201
203
 
@@ -203,19 +205,19 @@ Collection skips `**/node_modules/**`, `**/.git/**`, `**/coverage/**`, and `**/d
203
205
  export default defineVitestConfig({ testCollectionExclude: ['cypress', 'generated'] });
204
206
  ```
205
207
 
206
- Every layer's entries are concatenated, and a name the shared config already prunes is emitted once. A repo needing a glob rather than a directory name has the `project` seam's own `exclude`.
208
+ Every layer's entries are concatenated, and a name that the shared config already prunes is emitted once. A repo needing a glob rather than a directory name has the `project` seam's own `exclude`.
207
209
 
208
- 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.
210
+ Because both seams concatenate, a consumer can add an exclusion but never remove one. A pattern therefore belongs in these lists only if it prevents a _silent_ failure, one that a consumer cannot self-diagnose. A visible failure, such as a stray file at 0% in the coverage report, is left to the consumer's own `project` seam.
209
211
 
210
- 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.
212
+ 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 in a coverage report shows up as a diagnosable 0% entry.
211
213
 
212
214
  ### Where to put fixtures
213
215
 
214
- 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.
216
+ Because a `__fixtures__/` directory is excluded from coverage at any depth, fixture data stops counting against a package's numbers whether or not a test imports it.
215
217
 
216
- 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.
218
+ Placing it outside `__tests__/` settles a second problem: Anything named `*.test.{ts,tsx}` under `__tests__/` is collected and run, whatever it contains. A fixture with that name becomes a failing test. `src/__fixtures__/` resolves both halves; `src/__tests__/__fixtures__/` resolves the coverage half alone.
217
219
 
218
- 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.
220
+ The collection exclusions do not name 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 not have any recourse.
219
221
 
220
222
  ## Root-scoped tests
221
223
 
@@ -228,6 +230,6 @@ import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest';
228
230
  export default defineRootVitestConfig({ monorepoRoot: import.meta.dirname });
229
231
  ```
230
232
 
231
- 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.
233
+ 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 does not report any coverage of its own; packages cover their own sources.
232
234
 
233
- `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.
235
+ `monorepoRoot` is required, and because the config is at the monorepo root, it is always `import.meta.dirname`. Stating the root rather than searching for it 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. `defineRootVitestConfig` throws for a directory that does not contain `pnpm-workspace.yaml`, naming the directory.
package/docs/workspace.md CHANGED
@@ -1,11 +1,34 @@
1
1
  # Workspace introspection
2
2
 
3
- The two pnpm-workspace lookups that `@williamthorsen/nmr/workspace` publishes.
3
+ The pnpm-workspace lookups that `@williamthorsen/nmr/workspace` publishes. The resolution itself lives in
4
+ `@williamthorsen/nmr-core/workspace`, which release-kit reads too; nmr re-exports the total primitives and adds
5
+ two projections that throw, so a CLI caller keeps nmr's error boundary.
4
6
 
5
7
  `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.
6
8
 
7
- `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. It throws if `monorepoRoot` holds no `pnpm-workspace.yaml`. 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.
9
+ `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. It throws if `monorepoRoot` does not contain a `pnpm-workspace.yaml`. Patterns follow 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 that the positive patterns matched regardless of where they appear in the list. Nothing under `node_modules` is ever returned.
8
10
 
9
- One divergence from pnpm: a directory counts as a package only if it holds a `package.json`, not a `package.yaml` or `package.json5`.
11
+ One divergence from pnpm: a directory counts as a package only if it contains a `package.json`, not a `package.yaml` or `package.json5`.
10
12
 
11
- 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.
13
+ `resolveWorkspace(monorepoRoot)` is the primitive that both projections read, and the one that a caller uses when an empty workspace is not a failure. It throws nothing, returning a discriminated result instead:
14
+
15
+ | `kind` | What it contains |
16
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------- |
17
+ | `not-a-workspace` | Nothing. The directory does not contain a `pnpm-workspace.yaml`, so it does not declare a workspace at all. |
18
+ | `packages` | `packageDirs`, the resolved absolute directories, and `patterns`, the `packages` list that the manifest declares. |
19
+ | `empty` | `cause`, which of four conditions emptied the resolution, and the same `patterns`. |
20
+
21
+ | `cause` | What left the workspace empty |
22
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `no-packages-list` | The manifest does not declare a `packages` list: It does not contain a YAML document, lacks a `packages` key, or sets that key to null or to an empty list. `patterns` is empty. |
24
+ | `no-pattern` | The list reaches the matcher with nothing positive, holding only `!` entries or entries that YAML left empty. |
25
+ | `no-package` | The patterns reach the matcher and do not match any directory containing a `package.json`, which is the divergence above. |
26
+ | `all-excluded` | The `!` entries exclude every directory matched by the positive patterns. |
27
+ | `unreadable-packages` | The `packages` value is not a list of strings, so nothing it declares reaches the matcher. `patterns` is empty. |
28
+ | `unreadable-manifest` | Because the manifest does not contain valid YAML, nothing it declares reaches the matcher. `patterns` is empty, and the resolver does not decide any other cause. |
29
+
30
+ Three causes separate what the manifest declares from what the reader could make of it, because one remedy does not fix all three: `no-packages-list` is a manifest asking for nothing, which pnpm resolves to the root package alone; `unreadable-packages` is a manifest asking for something the reader refused; and `no-pattern` is a list read in full whose every entry failed to become a positive pattern. A caller treating an empty resolution as a failure needs to tell the first apart from the other two. A manifest that the parser rejects outright is `unreadable-manifest`, because the pattern remedies repair nothing for a reader whose manifest declares a pattern above a syntax error. `all-excluded` is decided by matching the positive patterns a second time without the exclusion set, which reads the filesystem only on this path, and only when the first resolution came back empty.
31
+
32
+ `isMonorepoRoot(dir)`, `readWorkspaceOverrides(monorepoRoot)`, and `readWorkspacePackageNames(packageDirs)` are re-exported unchanged. Each returns a value rather than throwing: `readWorkspaceOverrides` returns nothing when the manifest is missing, unreadable, or unparseable, or does not declare an `overrides` block, and `readWorkspacePackageNames` passes over a manifest it cannot read.
33
+
34
+ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -77,22 +77,22 @@
77
77
  "!docs/*.v*.md"
78
78
  ],
79
79
  "dependencies": {
80
- "@williamthorsen/nmr-core": "0.12.0",
80
+ "@williamthorsen/nmr-core": "0.13.1",
81
81
  "@williamthorsen/toolbelt.errors": "0.8.1",
82
82
  "glob": "13.0.6",
83
83
  "prettier-plugin-sh": "0.19.0",
84
- "taze": "21.1.0",
85
- "yaml": "2.9.0"
84
+ "taze": "21.1.0"
86
85
  },
87
86
  "devDependencies": {
88
87
  "@williamthorsen/toolbelt.filesystem": "0.9.1",
89
88
  "@williamthorsen/toolbelt.testing": "0.6.1",
90
89
  "@williamthorsen/toolbelt.vitest": "0.10.1",
91
- "prettier": "3.9.6",
90
+ "esbuild": "0.28.2",
91
+ "prettier": "3.9.9",
92
92
  "readyup": "0.38.0",
93
93
  "tinyglobby": "0.2.17",
94
94
  "typescript": "6.0.3",
95
- "vite": "8.3.0",
95
+ "vite": "8.3.1",
96
96
  "vitest": "4.1.11"
97
97
  },
98
98
  "peerDependencies": {
@@ -1,8 +0,0 @@
1
- /**
2
- * Resolves pnpm workspace patterns to absolute package directories.
3
- *
4
- * Applies pnpm's algorithm: every pattern is rewritten to match a manifest, `!`-prefixed patterns become
5
- * exclusions, and the matcher decides the rest. Exclusions filter the entire positive match set
6
- * irrespective of declaration order.
7
- */
8
- export declare function resolvePackageDirs(monorepoRoot: string, patterns: string[]): string[];
@@ -1,29 +0,0 @@
1
- import { globSync } from 'node:fs';
2
- import path from 'node:path';
3
- const MANIFEST = 'package.json';
4
- const ALWAYS_EXCLUDED = ['**/node_modules/**'];
5
- export function resolvePackageDirs(monorepoRoot, patterns) {
6
- const included = [];
7
- const excluded = [];
8
- for (const pattern of patterns) {
9
- if (pattern.trim() === '')
10
- continue;
11
- const isNegated = pattern.startsWith('!');
12
- const target = isNegated ? excluded : included;
13
- target.push(toManifestPattern(isNegated ? pattern.slice(1) : pattern));
14
- }
15
- if (included.length === 0) {
16
- return [];
17
- }
18
- const options = {
19
- cwd: monorepoRoot,
20
- exclude: [...excluded, ...ALWAYS_EXCLUDED],
21
- followSymlinks: true,
22
- };
23
- const matches = globSync(included, options);
24
- const dirs = matches.map((match) => path.dirname(path.resolve(monorepoRoot, match)));
25
- return [...new Set(dirs)].toSorted();
26
- }
27
- function toManifestPattern(pattern) {
28
- return `${pattern.replace(/\/?$/, '')}/${MANIFEST}`;
29
- }