@williamthorsen/nmr 0.43.0 → 0.45.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 (47) hide show
  1. package/.meta/changelog.json +168 -0
  2. package/.readyup/kits/default.js +1331 -136
  3. package/.readyup/manifest.json +148 -9
  4. package/CHANGELOG.md +51 -0
  5. package/README.md +17 -3
  6. package/agents/guidance/rulebooks/nmr.md +1 -1
  7. package/dist/esm/commands/fmt-cache.d.ts +26 -0
  8. package/dist/esm/commands/fmt-cache.js +71 -0
  9. package/dist/esm/commands/fmt.d.ts +10 -3
  10. package/dist/esm/commands/fmt.js +34 -6
  11. package/dist/esm/config.js +74 -9
  12. package/dist/esm/default-scripts.d.ts +10 -1
  13. package/dist/esm/default-scripts.js +45 -39
  14. package/dist/esm/defineConfig.d.ts +1 -1
  15. package/dist/esm/entry-chain.d.ts +24 -0
  16. package/dist/esm/entry-chain.js +57 -0
  17. package/dist/esm/glyphs.d.ts +1 -1
  18. package/dist/esm/help.d.ts +1 -1
  19. package/dist/esm/help.js +3 -3
  20. package/dist/esm/helpers/createOutputRecorder.d.ts +17 -0
  21. package/dist/esm/helpers/createOutputRecorder.js +22 -0
  22. package/dist/esm/helpers/escapeControlCharacters.d.ts +10 -0
  23. package/dist/esm/helpers/escapeControlCharacters.js +12 -0
  24. package/dist/esm/resolve-scripts.d.ts +5 -3
  25. package/dist/esm/resolve-scripts.js +7 -3
  26. package/dist/esm/resolver.d.ts +5 -4
  27. package/dist/esm/resolver.js +16 -6
  28. package/dist/esm/runCli.js +146 -101
  29. package/dist/esm/runner.d.ts +7 -0
  30. package/dist/esm/runner.js +105 -15
  31. package/dist/esm/steps.d.ts +50 -18
  32. package/dist/esm/steps.js +82 -13
  33. package/dist/esm/taze.d.ts +5 -2
  34. package/dist/esm/taze.js +4 -1
  35. package/dist/esm/tiers.d.ts +5 -4
  36. package/dist/esm/types.d.ts +10 -0
  37. package/dist/esm/vitest-git-isolation.js +3 -1
  38. package/dist/esm/vitest.js +9 -5
  39. package/dist/esm/workspace-globs.d.ts +5 -0
  40. package/dist/esm/workspace-globs.js +8 -0
  41. package/docs/check-cache.md +2 -0
  42. package/docs/reporting.md +2 -0
  43. package/docs/scripts.md +36 -10
  44. package/docs/upgrades.md +1 -1
  45. package/docs/utilities.md +14 -2
  46. package/docs/vitest.md +20 -8
  47. package/package.json +7 -7
package/docs/vitest.md CHANGED
@@ -10,16 +10,20 @@ The [README](../README.md#shared-vitest-config) shows the one-line config file.
10
10
 
11
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 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 |
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 process that the test starts, or a program that the environment supplies |
17
+ | `localhost` | `*.localhost.test.{ts,tsx}` | a service already 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 is: A bundled dependency is inside the package's own closure and is always present, so reaching one is `unit`.
22
+ **A tier names what the environment must supply for a test to run.** A test is `tool` when it reaches a process that it starts, or a program that the environment supplies. A spawned process is itself something that the test reaches, even when it runs the package's own code, because the machine sets how long the process takes to start and to run.
23
+
24
+ **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. What decides is who supplies the program, and a package declares that in its `peerDependencies`. Those, plus whatever binaries it spawns, mark where its `tool` boundary lies: A bundled dependency is inside the package's own closure and is always present, so reaching one in-process is `unit`.
25
+
26
+ **A service that the test starts itself takes the tier of the code that runs it.** A test that calls `net.Server.listen(0)` on a server from its own dependency closure and reaches it with `fetch` is `unit`: Both ends of the loopback connection are inside the test process, and the connection is how the test invokes that code. The same server started as a child process is `tool`, as is a service whose program the environment supplies, such as a `postgres` binary. `localhost` is for a service that is already running when the test starts, such as a separately started backend or a seeded database.
23
27
 
24
28
  **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
29
 
@@ -103,7 +107,7 @@ In a repo that has not declared the check, [nmr's readyup kit](../README.md#conf
103
107
  Two settings that a workspace test runner needs almost universally are part of what the factory produces, so a config file declares neither:
104
108
 
105
109
  - **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.
110
+ - **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`, `core.attributesFile`, and `maintenance.auto=false` 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, and a command such as `git commit` does not start a detached `git maintenance` process that could still be writing under `.git` when the test removes the repository. 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.
107
111
 
108
112
  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:
109
113
 
@@ -119,6 +123,12 @@ export default defineVitestConfig({ shouldIsolateGit: false, shouldResolveFromSo
119
123
 
120
124
  **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.
121
125
 
126
+ ### Worker count on small machines
127
+
128
+ On a machine with 2 or fewer cores, the factory gives every project `maxWorkers: 3`. Vitest's run-mode default is one fewer worker than the core count, so a 2-core CI runner, GitHub's standard runner for a private repo, would otherwise run every test file serially on one worker. Three workers are faster there, because the main process transforms files while the workers run them. On a machine with more cores, the projects do not set `maxWorkers`, and Vitest's own default applies.
129
+
130
+ Where the default applies, Vitest's `--maxWorkers` flag has no effect, because Vitest does not forward that flag to projects. Override the default with the `VITEST_MAX_WORKERS` environment variable, with `--no-file-parallelism` for a single worker, or with `maxWorkers` in the `project` seam.
131
+
122
132
  ## Resolving through tsconfig paths
123
133
 
124
134
  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:
@@ -154,6 +164,8 @@ export default defineVitestConfig({
154
164
 
155
165
  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.
156
166
 
167
+ `maxWorkers` goes under `project`. Vitest requires every project in a run to share one worker count, so a `tiers` target that sets a different `maxWorkers` for one tier makes Vitest throw at startup.
168
+
157
169
  `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.
158
170
 
159
171
  **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.43.0",
3
+ "version": "0.45.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.13.2",
81
- "@williamthorsen/toolbelt.errors": "0.8.2",
82
- "@williamthorsen/toolbelt.filesystem": "0.9.2",
80
+ "@williamthorsen/nmr-core": "0.13.3",
81
+ "@williamthorsen/toolbelt.errors": "0.8.3",
82
+ "@williamthorsen/toolbelt.filesystem": "0.9.3",
83
83
  "glob": "13.0.6",
84
84
  "prettier": "3.9.9",
85
85
  "prettier-plugin-sh": "0.20.2",
86
86
  "taze": "21.3.0"
87
87
  },
88
88
  "devDependencies": {
89
- "@williamthorsen/toolbelt.testing": "0.6.2",
90
- "@williamthorsen/toolbelt.vitest": "0.10.2",
89
+ "@williamthorsen/toolbelt.testing": "0.6.3",
90
+ "@williamthorsen/toolbelt.vitest": "0.10.3",
91
91
  "esbuild": "0.28.2",
92
92
  "readyup": "0.40.0",
93
93
  "tinyglobby": "0.2.17",
94
94
  "typescript": "6.0.3",
95
- "vite": "8.3.1",
95
+ "vite": "8.3.2",
96
96
  "vitest": "4.1.11",
97
97
  "yaml": "2.9.1"
98
98
  },