@williamthorsen/nmr 0.43.0 → 0.44.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.
@@ -1,3 +1,4 @@
1
+ import os from 'node:os';
1
2
  import path from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { defaultExclude, mergeConfig } from 'vitest/config';
@@ -6,9 +7,12 @@ import { isObject } from "./helpers/type-guards.js";
6
7
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
7
8
  import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
8
9
  import { getWorkspacePackageDirs } from "./workspace.js";
10
+ import { toWorkspacePackageGlobs } from "./workspace-globs.js";
9
11
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
10
12
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
11
13
  const TIER_TIMEOUT_MS = 30_000;
14
+ const SMALL_MACHINE_MAX_CORES = 2;
15
+ const SMALL_MACHINE_MAX_WORKERS = 3;
12
16
  const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
13
17
  const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
14
18
  const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
@@ -49,7 +53,7 @@ export function defineRootVitestConfig(...layers) {
49
53
  return buildConfig(layers, {
50
54
  coverageInclude: [],
51
55
  ignoredPathsRoot: monorepoRoot,
52
- projectExclude: getWorkspaceExcludePatterns(monorepoRoot),
56
+ projectExclude: toWorkspacePackageGlobs(monorepoRoot, getWorkspacePackageDirs(monorepoRoot)),
53
57
  projectRoot: monorepoRoot,
54
58
  });
55
59
  }
@@ -95,6 +99,7 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
95
99
  })),
96
100
  ];
97
101
  const collectionExclude = buildCollectionExclude(layers, ignoredPathsRoot);
102
+ const maxWorkers = resolveDefaultMaxWorkers();
98
103
  const shouldIsolateGit = resolveFlag(layers, 'shouldIsolateGit', true);
99
104
  return projectTiers.map(({ exclude, include, name, timeoutMs }) => {
100
105
  const project = {
@@ -103,6 +108,7 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
103
108
  test: {
104
109
  exclude: [...collectionExclude, ...exclude, ...extraExclude],
105
110
  include,
111
+ ...(maxWorkers !== undefined && { maxWorkers }),
106
112
  name,
107
113
  ...(shouldIsolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
108
114
  ...(timeoutMs !== undefined && { hookTimeout: timeoutMs, testTimeout: timeoutMs }),
@@ -169,10 +175,8 @@ function formatOptionKeys(keys) {
169
175
  .map((key) => `\`${key}\``)
170
176
  .join(', ');
171
177
  }
172
- function getWorkspaceExcludePatterns(monorepoRoot) {
173
- return getWorkspacePackageDirs(monorepoRoot)
174
- .map((dir) => `${path.relative(monorepoRoot, dir).split(path.sep).join('/')}/**`)
175
- .toSorted();
178
+ function resolveDefaultMaxWorkers() {
179
+ return os.availableParallelism() <= SMALL_MACHINE_MAX_CORES ? SMALL_MACHINE_MAX_WORKERS : undefined;
176
180
  }
177
181
  function resolveFlag(layers, name, isOnByDefault) {
178
182
  let resolvedFlag = isOnByDefault;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Renders each package directory as a glob, relative to `monorepoRoot`, that matches everything beneath it, sorted. A
3
+ * directory that is the root itself is skipped, since its glob would match the whole repo.
4
+ */
5
+ export declare function toWorkspacePackageGlobs(monorepoRoot: string, packageDirs: readonly string[]): string[];
@@ -0,0 +1,8 @@
1
+ import path from 'node:path';
2
+ export function toWorkspacePackageGlobs(monorepoRoot, packageDirs) {
3
+ return packageDirs
4
+ .map((dir) => path.relative(monorepoRoot, dir).split(path.sep).join('/'))
5
+ .filter((relativeDir) => relativeDir !== '')
6
+ .map((relativeDir) => `${relativeDir}/**`)
7
+ .toSorted();
8
+ }
@@ -148,6 +148,8 @@ Try these in order; the first is almost always the right one.
148
148
  | `rm -rf node_modules/.cache/nmr-check` | Forgets every recorded pass, leaving build output alone. |
149
149
  | `nmr clean` | Forgets every recorded pass and removes build output, at any scope. |
150
150
 
151
+ `--no-cache` and `NMR_NO_CACHE=1` also bypass the Prettier cache that `nmr-fmt` keeps, because nmr exports `NMR_NO_CACHE=1` to the commands that it runs. nmr exports it too when a command is given trailing arguments, so `nmr fmt:check <pathspec>` runs uncached. See [`nmr-fmt`'s cache](utilities.md#cache).
152
+
151
153
  `--no-cache` belongs before the command name. After it, it is an argument to the command rather than a flag to nmr; nmr reports this rather than silently ignoring the bypass.
152
154
 
153
155
  ## Reserved environment variables
package/docs/scripts.md CHANGED
@@ -35,6 +35,21 @@ A script value is a string or an array of steps. Only the built-in defaults and
35
35
 
36
36
  Each array element is a command name, optionally preceded by nmr's own flags, split on whitespace: `-R test` runs the element in every package, the way `nmr -R test` does. An element containing a quoted argument or shell syntax is rejected when the config loads: Name that command as a script of its own and reference the name.
37
37
 
38
+ #### Running a step in the background
39
+
40
+ A composite runs its steps in series. An element declared with `shouldRunInBackground` starts and is left running while the steps after it run:
41
+
42
+ ```ts
43
+ // "check:strict": [{ run: "lint:strict", shouldRunInBackground: true }, "typecheck", "fmt:check", "test"]
44
+ // nmr lint:strict runs alongside nmr typecheck, then nmr fmt:check, then nmr test
45
+ ```
46
+
47
+ The background step's output is held rather than streamed, and printed whole at the first step boundary after the step ends, so it never interleaves with another step's output. When nmr writes to a terminal, the step receives `FORCE_COLOR=1`, unless `NO_COLOR` or `FORCE_COLOR` is already set, so that its tools keep their colour. Its stdin is the null device.
48
+
49
+ A failing step in the foreground still ends the sequence, but the composite waits for every background step that has started and prints its output. The composite fails if any step failed, and each step's own verdict line names its failure. While a background step runs, nmr forwards a SIGINT or SIGTERM that it receives to every running step.
50
+
51
+ Put a step in the background when it uses fewer cores than the machine has and the steps after it do not read what it writes. The default `check` and `check:strict` run their lint step this way, because type-aware lint runs on one core while the test fan-out leaves cores idle.
52
+
38
53
  #### Where trailing arguments go
39
54
 
40
55
  nmr passes trailing arguments to every step of a composite, so `nmr fix --dry-run` runs `nmr lint --dry-run`, then `nmr fmt --dry-run`. A step whose work the arguments cannot narrow declines them by taking the long form of an element, and runs unnarrowed:
@@ -91,14 +106,15 @@ A minimal `.config/nmr.config.ts` is in the [README](../README.md#configuration)
91
106
 
92
107
  ### `defineConfig` fields
93
108
 
94
- | Field | Type | Description |
95
- | ------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
96
- | `build` | `{ extraIgnorePatterns?: string[] }` | Patterns added to the build's ignore set (package config only) |
97
- | `checkCache` | `{ enabled?: boolean; extraCommands?: string[]; excludeCommands?: string[] }` | Which commands the [check-result cache](check-cache.md#check-result-cache) may skip |
98
- | `workspaceScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the workspace registry (tier 2) |
99
- | `rootScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the root registry (tier 2) |
100
- | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
101
- | `output` | `{ commandVerbosity?: CommandVerbosity; extraAgentEnvVars?: string[] }` | How loudly runs report command output; see [configuring](reporting.md#configuring) |
109
+ | Field | Type | Description |
110
+ | ------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
111
+ | `build` | `{ extraIgnorePatterns?: string[] }` | Patterns added to the build's ignore set (package config only) |
112
+ | `checkCache` | `{ enabled?: boolean; extraCommands?: string[]; excludeCommands?: string[] }` | Which commands the [check-result cache](check-cache.md#check-result-cache) may skip |
113
+ | `workspaceScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the workspace registry (tier 2) |
114
+ | `rootScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the root registry (tier 2) |
115
+ | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
116
+ | `output` | `{ commandVerbosity?: CommandVerbosity; extraAgentEnvVars?: string[] }` | How loudly runs report command output; see [configuring](reporting.md#configuring) |
117
+ | `selfHostedRunner` | `{ exempt: string }` | Why a qualifying repo keeps CI off the self-hosted runners; stops [nmr's readyup kit](../README.md#conformance-checks) from recommending `CI_RUNS_ON` (root config only). The reason is structural: a circular dependency, a platform or capacity the runners lack, or network reach. A missing tool is installed by a workflow step instead |
102
118
 
103
119
  All fields are optional. [Script values](#script-values) describes `ScriptValue` and its step form, and both types are exported from `@williamthorsen/nmr/config`.
104
120
 
@@ -153,6 +169,8 @@ Neither is bound to a git hook. `prepush` is named for when a developer runs it,
153
169
 
154
170
  ### Check and quality
155
171
 
172
+ `check` and `check:strict` start their lint step first and [run it in the background](#running-a-step-in-the-background), alongside typecheck, the format check, and the tests.
173
+
156
174
  Neither gate computes coverage: Instrumentation multiplies the wall time of the run that they exist to keep short. Coverage is [`test:coverage`](vitest.md#test-selections), run as a step of its own; a repo that wants it inside the gate [overrides](#configuration) `check:strict`.
157
175
 
158
176
  ### Lint
package/docs/upgrades.md CHANGED
@@ -40,7 +40,7 @@ export default defineConfig({
40
40
  });
41
41
  ```
42
42
 
43
- `defineConfig` supplies nmr's shared upgrade policy, so a repo's config contains only what is specific to that repo. The policy is a seven-day quarantine on brand-new releases and the pair of settings that report a dependency pinned to an exact version: Locked dependencies are included, and the range searched is `minor`. Any setting that a repo's config declares wins over nmr's default. Passing `undefined` clears a default rather than falling back to it; that is how a config hands the quarantine policy back to `pnpm-workspace.yaml`'s `minimumReleaseAge`. `mode` is the exception: The upgrade tool has a default of its own for it, and clearing nmr's leaves the tool without a valid range to search. Name a mode instead.
43
+ `defineConfig` supplies nmr's shared upgrade policy, so a repo's config contains only what is specific to that repo. The policy is a seven-day quarantine on brand-new releases and the pair of settings that report a dependency pinned to an exact version: Locked dependencies are included, and the range searched is `minor`. Any setting that a repo's config declares wins over nmr's default. Passing `undefined` clears a default rather than falling back to it; that is how a config hands the quarantine policy back to `pnpm-workspace.yaml`'s `minimumReleaseAge`. Clearing `mode` hands the range back to the upgrade tool's own default, which follows the range that each dependency declares.
44
44
 
45
45
  Everything the [taze configuration](https://github.com/antfu-collective/taze#config-file) accepts is accepted here, with five exceptions that the upgrade tool does not read from any config file at all: `concurrency`, `githubActions`, `ignoreOtherWorkspaces`, `nodeVersion`, and `requestTimeout`. The tool's CLI writes a default for each of them over whatever the config declares ([taze#317](https://github.com/antfu-collective/taze/issues/317)), so the command line is the only place they can be set. nmr's ReadyUp kit warns when a repo's config declares one, and [request timeout](#request-timeout) below shows how to set it instead.
46
46
 
package/docs/utilities.md CHANGED
@@ -51,6 +51,18 @@ A file that git ignores is never formatted, even when named directly. Because tr
51
51
 
52
52
  `nmr-fmt` drops paths that the index names but the filesystem does not have (a file deleted but not yet staged), submodule gitlinks, and symlinks whatever they point at, before calling Prettier, which rejects a symlink named explicitly. It also skips a path that it is denied access to, such as a `.envrc` that a sandbox shields, and names each one on stderr, so that a check that passes does not hide a file that it never read. Any other failure to stat a listed path fails the run, with an error that names the path.
53
53
 
54
+ ### Cache
55
+
56
+ `nmr-fmt` passes Prettier's content-keyed cache (`--cache --cache-strategy content`), so a run reformats only the files whose content changed since a run that passed them. The cache files live in `node_modules/.cache/prettier/nmr-fmt/` at the repository root, one per directory from which `nmr-fmt` runs, because Prettier keys each entry by its path relative to that directory. A repository whose root does not contain a `node_modules` directory runs uncached rather than having one created.
57
+
58
+ A cached entry is reused only while the Prettier version, the Node version, the resolved Prettier options, and the repository's lockfile are all unchanged. Prettier's own key covers the first three. It does not cover plugin code, so each cache file's name also contains a fingerprint of the root's lockfile, and a dependency change, a plugin upgrade included, starts a fresh cache. `nmr-fmt` deletes cache files with an outdated fingerprint, and it discards a cache file that is not valid JSON rather than letting Prettier fail on it. A failure to prepare the cache prints a warning and leaves the run uncached; it never fails the run.
59
+
60
+ Pass `--no-cache`, or set `NMR_NO_CACHE=1`, to run uncached. `nmr --no-cache fmt:check` sets that variable, so it bypasses both nmr's [check cache](check-cache.md) and this one. Because the fingerprint makes a cache from an earlier lockfile harmless, CI can restore the whole `node_modules/.cache/prettier/nmr-fmt/` directory under a fallback key.
61
+
62
+ ```bash
63
+ nmr-fmt --check --no-cache
64
+ ```
65
+
54
66
  ## `ensure-prepublish-hooks`
55
67
 
56
68
  Verify that all publishable workspace packages have a `prepublishOnly` script. Exits non-zero if any are missing.
package/docs/vitest.md CHANGED
@@ -119,6 +119,12 @@ export default defineVitestConfig({ shouldIsolateGit: false, shouldResolveFromSo
119
119
 
120
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.
121
121
 
122
+ ### Worker count on small machines
123
+
124
+ 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.
125
+
126
+ 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.
127
+
122
128
  ## Resolving through tsconfig paths
123
129
 
124
130
  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 +160,8 @@ export default defineVitestConfig({
154
160
 
155
161
  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
162
 
163
+ `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.
164
+
157
165
  `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
166
 
159
167
  **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.44.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -78,21 +78,21 @@
78
78
  ],
79
79
  "dependencies": {
80
80
  "@williamthorsen/nmr-core": "0.13.2",
81
- "@williamthorsen/toolbelt.errors": "0.8.2",
82
- "@williamthorsen/toolbelt.filesystem": "0.9.2",
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
  },