@williamthorsen/nmr 0.50.1 โ†’ 0.52.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,4 +1,75 @@
1
1
  [
2
+ {
3
+ "version": "0.52.0",
4
+ "date": "2026-10-24",
5
+ "sections": [
6
+ {
7
+ "title": "๐ŸŽ‰ Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Adds a ๐Ÿ“ก glyph before the host name in the rich-output `nmr` verdicts that name a host, for a passed, failed, or recalled run, which leaves plain output and verdicts reading `on this tree` unchanged. (#1105)",
12
+ "hash": "15c0d637325fff429ceae5aa5ec6e0ee19741daa",
13
+ "entry": 1
14
+ },
15
+ {
16
+ "description": "Adds the root command `lint:workflows` and its `nmr-actionlint` bin, which lint `.github/workflows/` with actionlint 1.7.12, including its shellcheck pass over each `run:` block with shellcheck 0.11.0, using binaries that nmr downloads on first use, checks against pinned SHA-256 checksums, and caches under `node_modules/.cache/nmr-tools/`. (#1107)",
17
+ "hash": "b4ddac2c4954a882d6be05962f9709e323ac3ece",
18
+ "entry": 1
19
+ },
20
+ {
21
+ "description": "Adds `lint:workflows` to the root `check`, `check:strict`, and `root:check` composites, which makes those composites and `nmr ci` fail on any actionlint finding in a repo that has workflows, need access to `github.com` on their first run, and fail on any platform other than macOS or Linux on x64 or arm64. (#1107)",
22
+ "hash": "b4ddac2c4954a882d6be05962f9709e323ac3ece",
23
+ "entry": 2
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "title": "๐Ÿ› Bug fixes",
29
+ "audience": "all",
30
+ "items": [
31
+ {
32
+ "description": "Fixes the issue that a delegated `nmr` command could not find a bin that the branch added to a workspace package, because the host's `pnpm install --frozen-lockfile` did not link new bins when the lockfile was unchanged. (#1108)",
33
+ "hash": "5b694689a51679ddfbb04f65a88d0757ca310f3f",
34
+ "entry": 1
35
+ }
36
+ ]
37
+ }
38
+ ]
39
+ },
40
+ {
41
+ "version": "0.51.0",
42
+ "date": "2026-10-24",
43
+ "sections": [
44
+ {
45
+ "title": "๐ŸŽ‰ Features",
46
+ "audience": "all",
47
+ "items": [
48
+ {
49
+ "description": "Widens the optional `vitest` peer range of `@williamthorsen/nmr` from `>=4.0.0 <5` to `>=4.0.0 <6`, adding support for Vitest 5, under which a bare `vitest` started from a package without its own config needs `--config` naming the root's `vitest.config.*`. (#1100)",
50
+ "hash": "7a8049951fd41ef398dd2b0fdb88de6086223906",
51
+ "entry": 1
52
+ },
53
+ {
54
+ "description": "Adds the `nmr-vitest` command and runs every package-scope `test*` script through it; when the package directory does not contain a `vitest.config.*` or `vite.config.*`, `nmr-vitest` passes `--config` naming the `vitest.config.*` at the monorepo root, and otherwise passes its arguments to Vitest unchanged. (#1100)",
55
+ "hash": "7a8049951fd41ef398dd2b0fdb88de6086223906",
56
+ "entry": 2
57
+ }
58
+ ]
59
+ },
60
+ {
61
+ "title": "๐Ÿงช Tests",
62
+ "audience": "dev",
63
+ "items": [
64
+ {
65
+ "description": "Captures the output that passing tests leaked into the run, asserting on each error, hint, and `TAG_RESOLVE_FAILED` YAML warning emitted by the code under test, and pipes the stdio of the child processes run by tool tests so that their output does not reach the worker's streams. (#1097)",
66
+ "hash": "ed6e4187d7f30fa27eb59b495c5f51cf07eacdbc",
67
+ "entry": 2
68
+ }
69
+ ]
70
+ }
71
+ ]
72
+ },
2
73
  {
3
74
  "version": "0.50.1",
4
75
  "date": "2026-10-22",
@@ -1,6 +1,6 @@
1
1
  /** @noformat -- @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.42.0";
3
+ export const __readyupVersion = "0.43.0";
4
4
  import { createRequire as __rdyCreateRequire } from 'node:module';
5
5
  const require = __rdyCreateRequire(import.meta.url);
6
6
 
@@ -7382,12 +7382,12 @@ import { isRecord, listTrackedFiles } from "readyup/check-utils";
7382
7382
  import { globSync } from "node:fs";
7383
7383
  import { basename, join, sep } from "node:path";
7384
7384
 
7385
- // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.3/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
7385
+ // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.4/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
7386
7386
  function isError(error) {
7387
7387
  return error instanceof Error || Object.prototype.toString.call(error) === "[object Error]";
7388
7388
  }
7389
7389
 
7390
- // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.3/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
7390
+ // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.4/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
7391
7391
  function describeError(error) {
7392
7392
  try {
7393
7393
  if (isError(error) && typeof error.message === "string" && error.message !== "") {
@@ -7646,6 +7646,7 @@ var TYPECHECK_STEP = { run: "typecheck", shouldDeclineArguments: true };
7646
7646
  var ROOT_TYPECHECK_STEP = { run: "root:typecheck", shouldDeclineArguments: true };
7647
7647
  var LINT_CHECK_STEP = { run: "lint:check", shouldRunInBackground: true };
7648
7648
  var LINT_STRICT_STEP = { run: "lint:strict", shouldRunInBackground: true };
7649
+ var LINT_WORKFLOWS_STEP = { run: "lint:workflows", shouldDeclineArguments: true };
7649
7650
  var workspaceScripts = {
7650
7651
  build: ["compile"],
7651
7652
  check: [LINT_CHECK_STEP, TYPECHECK_STEP, "fmt:check", "test"],
@@ -7660,12 +7661,12 @@ var workspaceScripts = {
7660
7661
  "lint:check": "eslint .",
7661
7662
  "lint:strict": "strict-lint",
7662
7663
  "report-catalog": "nmr-report-catalog",
7663
- test: `pnpm exec vitest ${GATE_PROJECTS}`,
7664
- "test:all": "pnpm exec vitest",
7665
- "test:coverage": `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
7666
- "test:tool": "pnpm exec vitest --project tool",
7667
- "test:unit": "pnpm exec vitest --project unit",
7668
- "test:watch": `pnpm exec vitest ${GATE_PROJECTS} --watch`,
7664
+ test: `nmr-vitest ${GATE_PROJECTS}`,
7665
+ "test:all": "nmr-vitest",
7666
+ "test:coverage": `nmr-vitest ${GATE_PROJECTS} --coverage`,
7667
+ "test:tool": "nmr-vitest --project tool",
7668
+ "test:unit": "nmr-vitest --project unit",
7669
+ "test:watch": `nmr-vitest ${GATE_PROJECTS} --watch`,
7669
7670
  typecheck: "tsgo --noEmit",
7670
7671
  // The command is a string because neither half names an nmr command: Both are binaries.
7671
7672
  upgrade: "nmr-report-catalog && nmr-taze",
@@ -7678,8 +7679,8 @@ function buildRootScripts(workspaceGlobs) {
7678
7679
  "audit:dev": "pnpm exec v11y --dev",
7679
7680
  "audit:prod": "pnpm exec v11y --prod",
7680
7681
  build: ["-R build"],
7681
- check: [LINT_CHECK_STEP, TYPECHECK_STEP, "fmt:check", "test"],
7682
- "check:strict": [LINT_STRICT_STEP, TYPECHECK_STEP, "fmt:check", "test"],
7682
+ check: [LINT_CHECK_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, "fmt:check", "test"],
7683
+ "check:strict": [LINT_STRICT_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, "fmt:check", "test"],
7683
7684
  // Excludes the audit, which in CI has a workflow of its own. The narrowed check runs against the build, so
7684
7685
  // the build declines the arguments rather than being narrowed by them.
7685
7686
  ci: [{ run: "build", shouldDeclineArguments: true }, "check:strict"],
@@ -7691,11 +7692,12 @@ function buildRootScripts(workspaceGlobs) {
7691
7692
  lint: "eslint --fix .",
7692
7693
  "lint:check": "eslint .",
7693
7694
  "lint:strict": "strict-lint",
7695
+ "lint:workflows": "nmr-actionlint",
7694
7696
  // The audit takes seconds and `ci` takes minutes, so the cheap gate fails first. The audit reads the
7695
7697
  // dependency tree, and an argument narrowing the code under test does not say anything about that tree.
7696
7698
  prepush: [{ run: "audit", shouldDeclineArguments: true }, "ci"],
7697
7699
  "report-overrides": "nmr-report-overrides",
7698
- "root:check": [ROOT_TYPECHECK_STEP, "fmt:check", "root:lint:check", "root:test"],
7700
+ "root:check": [ROOT_TYPECHECK_STEP, "fmt:check", "root:lint:check", LINT_WORKFLOWS_STEP, "root:test"],
7699
7701
  "root:lint": `eslint --fix ${ignorePatterns}.`,
7700
7702
  "root:lint:check": `eslint ${ignorePatterns}.`,
7701
7703
  "root:lint:strict": `strict-lint ${ignorePatterns}.`,
@@ -9188,7 +9190,7 @@ function hasSupportedStrictLintVersion() {
9188
9190
  });
9189
9191
  }
9190
9192
  function getMinVersion() {
9191
- const pickedFields = { "version": "0.50.1" };
9193
+ const pickedFields = { "version": "0.52.0" };
9192
9194
  if (typeof pickedFields["version"] !== "string") {
9193
9195
  throw new TypeError("nmr/package.json: 'version' must be a string");
9194
9196
  }
@@ -9362,7 +9364,7 @@ var vitestChecks = [
9362
9364
  severity: "recommend",
9363
9365
  quiet: true,
9364
9366
  check: () => noReExportOnlyVitestConfigs(),
9365
- fix: "Delete these files. Vitest resolves config by walking up from the run root, so a per-package re-export is redundant"
9367
+ fix: "Delete these files. `nmr-vitest` passes the root config to every package that does not have a config of its own, so a per-package re-export is redundant"
9366
9368
  }
9367
9369
  ];
9368
9370
  function everyTestFileNamesItsTier(cwd = process.cwd()) {
@@ -3,9 +3,9 @@
3
3
  "kits": [
4
4
  {
5
5
  "bundledDependencies": {
6
- "@williamthorsen/toolbelt.errors": "0.8.3",
7
- "@williamthorsen/toolbelt.filesystem": "0.9.3",
8
- "@williamthorsen/toolbelt.packaging": "0.6.3",
6
+ "@williamthorsen/toolbelt.errors": "0.8.4",
7
+ "@williamthorsen/toolbelt.filesystem": "0.9.4",
8
+ "@williamthorsen/toolbelt.packaging": "0.6.4",
9
9
  "@williamthorsen/toolbelt.terminal": "0.2.1",
10
10
  "ansi-regex": "6.4.0",
11
11
  "ansi-styles": "6.2.3",
@@ -24,7 +24,7 @@
24
24
  "checklist": "nmr",
25
25
  "checks": [
26
26
  "@williamthorsen/nmr in devDependencies",
27
- "@williamthorsen/nmr >= 0.50.1",
27
+ "@williamthorsen/nmr >= 0.52.0",
28
28
  "pnpm-workspace.yaml exists",
29
29
  "catalogued dependencies use catalog:",
30
30
  "package.json has packageManager field",
@@ -191,7 +191,7 @@
191
191
  "path": "lib/shared-configs.ts"
192
192
  },
193
193
  {
194
- "hash": "b3bdf8ee",
194
+ "hash": "66043902",
195
195
  "kind": "module",
196
196
  "path": "lib/vitest.ts"
197
197
  },
@@ -201,7 +201,7 @@
201
201
  "path": "lib/workspace-build.ts"
202
202
  },
203
203
  {
204
- "hash": "9dc20288",
204
+ "hash": "0d9124f7",
205
205
  "kind": "inline",
206
206
  "path": "../package.json",
207
207
  "paths": [
@@ -224,7 +224,7 @@
224
224
  "path": "../src/config.ts"
225
225
  },
226
226
  {
227
- "hash": "d2448f72",
227
+ "hash": "b8e98c81",
228
228
  "kind": "module",
229
229
  "path": "../src/default-scripts.ts"
230
230
  },
@@ -316,10 +316,10 @@
316
316
  ],
317
317
  "name": "default",
318
318
  "path": "kits/default.js",
319
- "readyupVersion": "0.42.0",
319
+ "readyupVersion": "0.43.0",
320
320
  "source": "kits/default.ts",
321
321
  "sourceHash": "9fd61bf4",
322
- "targetHash": "8ee3ff93"
322
+ "targetHash": "547201fd"
323
323
  }
324
324
  ]
325
325
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.52.0 โ€” 2026-10-24
6
+
7
+ ### ๐ŸŽ‰ Features
8
+
9
+ - Adds a ๐Ÿ“ก glyph before the host name in the rich-output `nmr` verdicts that name a host, for a passed, failed, or recalled run, which leaves plain output and verdicts reading `on this tree` unchanged. (#1105)
10
+ - Adds the root command `lint:workflows` and its `nmr-actionlint` bin, which lint `.github/workflows/` with actionlint 1.7.12, including its shellcheck pass over each `run:` block with shellcheck 0.11.0, using binaries that nmr downloads on first use, checks against pinned SHA-256 checksums, and caches under `node_modules/.cache/nmr-tools/`. (#1107)
11
+ - Adds `lint:workflows` to the root `check`, `check:strict`, and `root:check` composites, which makes those composites and `nmr ci` fail on any actionlint finding in a repo that has workflows, need access to `github.com` on their first run, and fail on any platform other than macOS or Linux on x64 or arm64. (#1107)
12
+
13
+ ### ๐Ÿ› Bug fixes
14
+
15
+ - Fixes the issue that a delegated `nmr` command could not find a bin that the branch added to a workspace package, because the host's `pnpm install --frozen-lockfile` did not link new bins when the lockfile was unchanged. (#1108)
16
+
17
+ ## 0.51.0 โ€” 2026-10-24
18
+
19
+ ### ๐ŸŽ‰ Features
20
+
21
+ - Widens the optional `vitest` peer range of `@williamthorsen/nmr` from `>=4.0.0 <5` to `>=4.0.0 <6`, adding support for Vitest 5, under which a bare `vitest` started from a package without its own config needs `--config` naming the root's `vitest.config.*`. (#1100)
22
+ - Adds the `nmr-vitest` command and runs every package-scope `test*` script through it; when the package directory does not contain a `vitest.config.*` or `vite.config.*`, `nmr-vitest` passes `--config` naming the `vitest.config.*` at the monorepo root, and otherwise passes its arguments to Vitest unchanged. (#1100)
23
+
24
+ ### ๐Ÿงช Tests
25
+
26
+ - Captures the output that passing tests leaked into the run, asserting on each error, hint, and `TAG_RESOLVE_FAILED` YAML warning emitted by the code under test, and pipes the stdio of the child processes run by tool tests so that their output does not reach the worker's streams. (#1097)
27
+
5
28
  ## 0.50.1 โ€” 2026-10-22
6
29
 
7
30
  ### ๐Ÿ› Bug fixes
package/README.md CHANGED
@@ -5,12 +5,17 @@
5
5
  Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes โ€” v0.50.1 (2026-10-22)
8
+ ## Release notes โ€” v0.52.0 (2026-10-24)
9
+
10
+ ### ๐ŸŽ‰ Features
11
+
12
+ - Adds a ๐Ÿ“ก glyph before the host name in the rich-output `nmr` verdicts that name a host, for a passed, failed, or recalled run, which leaves plain output and verdicts reading `on this tree` unchanged. (#1105)
13
+ - Adds the root command `lint:workflows` and its `nmr-actionlint` bin, which lint `.github/workflows/` with actionlint 1.7.12, including its shellcheck pass over each `run:` block with shellcheck 0.11.0, using binaries that nmr downloads on first use, checks against pinned SHA-256 checksums, and caches under `node_modules/.cache/nmr-tools/`. (#1107)
14
+ - Adds `lint:workflows` to the root `check`, `check:strict`, and `root:check` composites, which makes those composites and `nmr ci` fail on any actionlint finding in a repo that has workflows, need access to `github.com` on their first run, and fail on any platform other than macOS or Linux on x64 or arm64. (#1107)
9
15
 
10
16
  ### ๐Ÿ› Bug fixes
11
17
 
12
- - Fixes the issue that the `self-hosted runner` checklist in the `nmr` readyup kit joined every readiness failure into one semicolon-separated line, which hid that a public repo rules out the runner whatever else holds; a public repo now reports its visibility alone, and a private repo lists each failure on its own line. (#1091)
13
- - Fixes the issue that the checklist's missing-runner message stated a recent `williamthorsen/hq` allocation as the cause, when it is only a possible one. (#1091)
18
+ - Fixes the issue that a delegated `nmr` command could not find a bin that the branch added to a workspace package, because the host's `pnpm install --frozen-lockfile` did not link new bins when the lockfile was unchanged. (#1108)
14
19
  <!-- /section:release-notes -->
15
20
 
16
21
  ## Installation
@@ -57,7 +62,7 @@ From the monorepo root:
57
62
  ```bash
58
63
  nmr test # Run root tests + recursive workspace tests
59
64
  nmr build # Build all packages
60
- nmr ci # Build, then run the strict checks
65
+ nmr ci # Build, then run the strict checks, workflow lint included
61
66
  nmr prepush # Run everything the remote runs, before a push
62
67
  ```
63
68
 
@@ -80,11 +85,11 @@ nmr detects the directory from which it is invoked and selects the right scripts
80
85
 
81
86
  nmr's key feature is that the same command runs different scripts depending on where it is invoked. It walks up from the current directory to find `pnpm-workspace.yaml`, then checks whether the current directory is inside a workspace package directory.
82
87
 
83
- | Where `nmr` is invoked | Registry used | Working directory | `nmr test` runs |
84
- | --------------------------------- | ----------------- | ----------------- | -------------------------------------------------------------------- |
85
- | Monorepo root | Root scripts | Monorepo root | `root:test`, then `-R test` |
86
- | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest --project unit --project tool` (that package only) |
87
- | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | `root:test`, then `-R test` |
88
+ | Where `nmr` is invoked | Registry used | Working directory | `nmr test` runs |
89
+ | --------------------------------- | ----------------- | ----------------- | -------------------------------------------------------------- |
90
+ | Monorepo root | Root scripts | Monorepo root | `root:test`, then `-R test` |
91
+ | Inside a workspace package | Workspace scripts | The package root | `nmr-vitest --project unit --project tool` (that package only) |
92
+ | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | `root:test`, then `-R test` |
88
93
 
89
94
  Relative paths in a script resolve against that working directory, not the invocation directory.
90
95
 
@@ -206,7 +211,7 @@ export default {
206
211
 
207
212
  ```console
208
213
  $ nmr --delegate-to imac2 ci
209
- โœ… my-repo: ci: passed on imac2 in 1m 48s
214
+ โœ… my-repo: ci: passed on ๐Ÿ“ก imac2 in 1m 48s
210
215
  ```
211
216
 
212
217
  A delegation consults the [check-result cache](#check-result-cache) first, so a tree that already passed is not sent to the host; a pass that a host earned does not satisfy a run here. A repo whose checks must run on the machine that has the tree declares `delegate: false` in its config. [Delegating to another machine](docs/delegate.md) covers the user config, `--delegate`, `NMR_DELEGATE`, the rules that decide where a command runs, which commands can be delegated, how nmr chooses among several hosts, how the cache is consulted, what the host runs, how failures are reported, and setting up the host.
@@ -317,7 +322,7 @@ rdy list --from npm:@williamthorsen/nmr # what nmr publishes
317
322
  - [Machine lock](docs/machine-lock.md): Which commands wait for one another, where the lock lives, and bypassing it
318
323
  - [Delegating to another machine](docs/delegate.md): The user config, `--delegate`, `--delegate-to`, `NMR_DELEGATE`, where a command runs, choosing among several hosts, what the host runs, and setting it up
319
324
  - [Dependency upgrades](docs/upgrades.md): `nmr upgrade`, the upgrade policy, `report-catalog`, `report-overrides`, and `nmr-taze`
320
- - [Standalone utilities](docs/utilities.md): `nmr-clean`, `nmr-compile`, `nmr-fmt`, and `ensure-prepublish-hooks`
325
+ - [Standalone utilities](docs/utilities.md): `nmr-actionlint`, `nmr-clean`, `nmr-compile`, `nmr-fmt`, `nmr-vitest`, and `ensure-prepublish-hooks`
321
326
  - [Shared Vitest config](docs/vitest.md): Test tiers, test selections, the conventions check, and customization
322
327
  - [Shared Prettier config](docs/prettier.md): What it formats and how to customize it
323
328
  - [Workspace introspection](docs/workspace.md): The two workspace lookups
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/cli-actionlint.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('nmr-actionlint: build output not found; run `pnpm run bootstrap` first\n');
7
+ } else {
8
+ process.stderr.write(`nmr-actionlint: failed to load: ${error.message}\n`);
9
+ }
10
+ process.exit(1);
11
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/cli-vitest.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('nmr-vitest: build output not found; run `pnpm run bootstrap` first\n');
7
+ } else {
8
+ process.stderr.write(`nmr-vitest: failed to load: ${error.message}\n`);
9
+ }
10
+ process.exit(1);
11
+ }
@@ -11,6 +11,7 @@ export const DEFAULT_CACHEABLE_COMMANDS = [
11
11
  'fmt:check',
12
12
  'lint:check',
13
13
  'lint:strict',
14
+ 'lint:workflows',
14
15
  'root:check',
15
16
  'root:lint:check',
16
17
  'root:lint:strict',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import process from 'node:process';
2
+ import { reportError } from '@williamthorsen/nmr-core';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { runActionlint } from "./commands/actionlint.js";
5
+ import { findMonorepoRoot } from "./workspace.js";
6
+ try {
7
+ process.exitCode = await runActionlint(findMonorepoRoot(), process.argv.slice(2));
8
+ }
9
+ catch (error) {
10
+ reportError(describeError(error));
11
+ process.exitCode = 1;
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import process from 'node:process';
2
+ import { runNmrVitest } from "./commands/run-vitest.js";
3
+ process.exitCode = runNmrVitest(process.argv.slice(2), process.cwd());
@@ -0,0 +1,23 @@
1
+ import { type WorkflowTool } from './workflow-tools.js';
2
+ /** How a spawned actionlint ended. */
3
+ export interface ActionlintOutcome {
4
+ error?: Error | undefined;
5
+ signal: NodeJS.Signals | null;
6
+ status: number | null;
7
+ }
8
+ export interface RunActionlintOptions {
9
+ /** Directory under which the pinned tools are cached; defaults to `node_modules/.cache/nmr-tools` at the root. */
10
+ cacheRoot?: string;
11
+ ensure?: (tool: WorkflowTool, cacheRoot: string) => Promise<string>;
12
+ spawn?: (file: string, args: string[], cwd: string) => ActionlintOutcome;
13
+ }
14
+ /**
15
+ * Lints the monorepo's GitHub Actions workflows with the pinned actionlint, running its shellcheck pass with the
16
+ * pinned shellcheck, and returns actionlint's exit status. A repo without a workflows directory passes.
17
+ */
18
+ export declare function runActionlint(monorepoRoot: string, args: readonly string[], options?: RunActionlintOptions): Promise<number>;
19
+ /**
20
+ * Builds actionlint's arguments. The pyflakes integration is disabled so that the result does not depend on whether
21
+ * the machine happens to have pyflakes on `PATH`.
22
+ */
23
+ export declare function buildActionlintArgs(shellcheckPath: string, args: readonly string[]): string[];
@@ -0,0 +1,31 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describeSignalExitCode } from "../child-process.js";
5
+ import { ensureTool } from "./workflow-tools.js";
6
+ const WORKFLOWS_DIR = path.join('.github', 'workflows');
7
+ export async function runActionlint(monorepoRoot, args, options = {}) {
8
+ if (!fs.existsSync(path.join(monorepoRoot, WORKFLOWS_DIR))) {
9
+ console.info(`No workflows to lint: ${WORKFLOWS_DIR}/ does not exist.`);
10
+ return 0;
11
+ }
12
+ const cacheRoot = options.cacheRoot ?? path.join(monorepoRoot, 'node_modules', '.cache', 'nmr-tools');
13
+ const ensure = options.ensure ?? ((tool, root) => ensureTool(tool, { cacheRoot: root }));
14
+ const actionlintPath = await ensure('actionlint', cacheRoot);
15
+ const shellcheckPath = await ensure('shellcheck', cacheRoot);
16
+ const outcome = (options.spawn ?? spawnInherited)(actionlintPath, buildActionlintArgs(shellcheckPath, args), monorepoRoot);
17
+ if (outcome.error !== undefined) {
18
+ throw new Error(`Cannot run actionlint: ${outcome.error.message}`, { cause: outcome.error });
19
+ }
20
+ if (outcome.signal !== null) {
21
+ return describeSignalExitCode(outcome.signal);
22
+ }
23
+ return outcome.status ?? 1;
24
+ }
25
+ export function buildActionlintArgs(shellcheckPath, args) {
26
+ return [`-shellcheck=${shellcheckPath}`, '-pyflakes=', ...args];
27
+ }
28
+ function spawnInherited(file, args, cwd) {
29
+ const result = spawnSync(file, args, { cwd, stdio: 'inherit' });
30
+ return { error: result.error, signal: result.signal, status: result.status };
31
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Runs the Vitest that `cwd` resolves, naming the monorepo root's config when the working directory does not have
3
+ * one of its own, and returns the exit code to report.
4
+ */
5
+ export declare function runNmrVitest(argv: string[], cwd?: string): number;
6
+ /**
7
+ * Returns `argv` with `--config` naming the monorepo root's `vitest.config.*` prepended, or `argv` unchanged when
8
+ * it already names a config, when `cwd` contains a Vitest or Vite config that Vitest loads itself, or when there is
9
+ * no monorepo root config to name.
10
+ *
11
+ * Vitest 5 does not search parent directories for a config, so a package without its own would otherwise run
12
+ * without the shared one.
13
+ */
14
+ export declare function chooseVitestConfigArgs(argv: string[], cwd: string): string[];
@@ -0,0 +1,79 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { reportError } from '@williamthorsen/nmr-core';
7
+ import { findMonorepoRoot as findMonorepoRootOrNothing } from '@williamthorsen/nmr-core/workspace';
8
+ import { describeSignalExitCode } from "../child-process.js";
9
+ import { isObject } from "../helpers/type-guards.js";
10
+ const VITEST_PACKAGE = 'vitest';
11
+ const SUPPORTED_VITEST_RANGE = '>=4.0.0 <6';
12
+ const CONFIG_EXTENSIONS = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'];
13
+ export function runNmrVitest(argv, cwd = process.cwd()) {
14
+ const cli = resolveVitestCli(cwd);
15
+ if (!cli.ok) {
16
+ reportError(`nmr-vitest: ${cli.error}`);
17
+ return 1;
18
+ }
19
+ const result = spawnSync(process.execPath, [cli.cliPath, ...chooseVitestConfigArgs(argv, cwd)], {
20
+ cwd,
21
+ stdio: 'inherit',
22
+ });
23
+ if (result.error) {
24
+ reportError(`nmr-vitest: could not run \`${VITEST_PACKAGE}\` (${result.error.message}).`);
25
+ return 1;
26
+ }
27
+ if (result.signal !== null)
28
+ return describeSignalExitCode(result.signal);
29
+ return result.status ?? 1;
30
+ }
31
+ export function chooseVitestConfigArgs(argv, cwd) {
32
+ if (namesConfig(argv))
33
+ return argv;
34
+ if (findConfigFile(cwd, ['vitest.config', 'vite.config']) !== undefined)
35
+ return argv;
36
+ const monorepoRoot = findMonorepoRootOrNothing(cwd);
37
+ if (monorepoRoot === undefined)
38
+ return argv;
39
+ const rootConfig = findConfigFile(monorepoRoot, ['vitest.config']);
40
+ return rootConfig === undefined ? argv : ['--config', rootConfig, ...argv];
41
+ }
42
+ function findConfigFile(dir, stems) {
43
+ for (const stem of stems) {
44
+ for (const extension of CONFIG_EXTENSIONS) {
45
+ const candidate = path.join(dir, `${stem}.${extension}`);
46
+ if (existsSync(candidate))
47
+ return candidate;
48
+ }
49
+ }
50
+ return undefined;
51
+ }
52
+ function namesConfig(argv) {
53
+ for (const arg of argv) {
54
+ if (arg === '--')
55
+ return false;
56
+ if (arg === '--config' || arg === '-c' || arg.startsWith('--config='))
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+ function resolveVitestCli(cwd) {
62
+ const missingResult = {
63
+ ok: false,
64
+ error: `requires Vitest ${SUPPORTED_VITEST_RANGE}, but \`${VITEST_PACKAGE}\` could not be resolved from ${cwd}. Install a version in that range as the \`${VITEST_PACKAGE}\` peer dependency.`,
65
+ };
66
+ let manifestPath;
67
+ try {
68
+ manifestPath = createRequire(path.join(cwd, 'package.json')).resolve(`${VITEST_PACKAGE}/package.json`);
69
+ }
70
+ catch {
71
+ return missingResult;
72
+ }
73
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
74
+ const bin = isObject(manifest) && isObject(manifest['bin']) ? manifest['bin'][VITEST_PACKAGE] : undefined;
75
+ if (typeof bin !== 'string') {
76
+ return { ok: false, error: `\`${VITEST_PACKAGE}\` at ${path.dirname(manifestPath)} does not declare a CLI.` };
77
+ }
78
+ return { ok: true, cliPath: path.resolve(path.dirname(manifestPath), bin) };
79
+ }
@@ -0,0 +1,39 @@
1
+ /** A program that `nmr-actionlint` downloads rather than takes from `PATH`. */
2
+ export type WorkflowTool = 'actionlint' | 'shellcheck';
3
+ /** A supported pair of `process.platform` and `process.arch`, joined by a hyphen. */
4
+ export type ToolPlatform = 'darwin-arm64' | 'darwin-x64' | 'linux-arm64' | 'linux-x64';
5
+ /** One release of a tool: where each platform's archive is, its SHA-256, and the binary's path inside it. */
6
+ export interface ToolPin {
7
+ archiveMember: string;
8
+ assets: Record<ToolPlatform, {
9
+ sha256: string;
10
+ url: string;
11
+ }>;
12
+ version: string;
13
+ }
14
+ export type ToolPins = Record<WorkflowTool, ToolPin>;
15
+ /** The subset of `fetch` that the download uses. */
16
+ export type FetchFunction = (url: string, init?: RequestInit) => Promise<Response>;
17
+ export interface EnsureToolOptions {
18
+ /** Directory under which each tool version is cached in a directory of its own. */
19
+ cacheRoot: string;
20
+ arch?: string;
21
+ fetch?: FetchFunction;
22
+ pins?: ToolPins;
23
+ platform?: NodeJS.Platform;
24
+ }
25
+ /**
26
+ * The pinned releases. To move a pin, change its version and URLs and take each SHA-256 from the release's `digest`
27
+ * as `gh api repos/{owner}/{repo}/releases/tags/{tag}` reports it, confirming actionlint's against the release's
28
+ * `checksums.txt`. The pins move only with a release of nmr.
29
+ */
30
+ export declare const TOOL_PINS: ToolPins;
31
+ /**
32
+ * Returns the path of the tool's pinned binary, downloading, verifying, and caching it first on a cache miss.
33
+ *
34
+ * A failure at any step leaves the cache without the tool, and a concurrent run never sees a partial binary: The
35
+ * binary is assembled in a scratch directory beside its target and renamed into place only once verified.
36
+ */
37
+ export declare function ensureTool(tool: WorkflowTool, options: EnsureToolOptions): Promise<string>;
38
+ /** Maps Node's platform and architecture onto a supported pair, rejecting every other pair. */
39
+ export declare function resolveToolPlatform(platform: NodeJS.Platform, arch: string): ToolPlatform;
@@ -0,0 +1,140 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { describeError } from '@williamthorsen/toolbelt.errors';
7
+ import { UserError } from "../UserError.js";
8
+ const SUPPORTED_PLATFORMS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'];
9
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
10
+ const ACTIONLINT_RELEASE = 'https://github.com/rhysd/actionlint/releases/download/v1.7.12';
11
+ const SHELLCHECK_RELEASE = 'https://github.com/koalaman/shellcheck/releases/download/v0.11.0';
12
+ export const TOOL_PINS = {
13
+ actionlint: {
14
+ archiveMember: 'actionlint',
15
+ assets: {
16
+ 'darwin-arm64': {
17
+ sha256: 'aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f',
18
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_darwin_arm64.tar.gz`,
19
+ },
20
+ 'darwin-x64': {
21
+ sha256: '5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644',
22
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_darwin_amd64.tar.gz`,
23
+ },
24
+ 'linux-arm64': {
25
+ sha256: '325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6',
26
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_linux_arm64.tar.gz`,
27
+ },
28
+ 'linux-x64': {
29
+ sha256: '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8',
30
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_linux_amd64.tar.gz`,
31
+ },
32
+ },
33
+ version: '1.7.12',
34
+ },
35
+ shellcheck: {
36
+ archiveMember: 'shellcheck-v0.11.0/shellcheck',
37
+ assets: {
38
+ 'darwin-arm64': {
39
+ sha256: '339b930feb1ea764467013cc1f72d09cd6b869ebf1013296ba9055ab2ffbd26f',
40
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.darwin.aarch64.tar.gz`,
41
+ },
42
+ 'darwin-x64': {
43
+ sha256: 'c2c15e08df0e8fbc374c335b230a7ee958c313fa5714817a59aa59f1aa594f51',
44
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.darwin.x86_64.tar.gz`,
45
+ },
46
+ 'linux-arm64': {
47
+ sha256: '68a8133197a50beb8803f8d42f9908d1af1c5540d4bb05fdfca8c1fa47decefc',
48
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.linux.aarch64.tar.gz`,
49
+ },
50
+ 'linux-x64': {
51
+ sha256: 'b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6',
52
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.linux.x86_64.tar.gz`,
53
+ },
54
+ },
55
+ version: '0.11.0',
56
+ },
57
+ };
58
+ export async function ensureTool(tool, options) {
59
+ const pin = (options.pins ?? TOOL_PINS)[tool];
60
+ const asset = pin.assets[resolveToolPlatform(options.platform ?? process.platform, options.arch ?? process.arch)];
61
+ const targetDir = path.join(options.cacheRoot, `${tool}-${pin.version}`);
62
+ const binaryPath = path.join(targetDir, tool);
63
+ if (fs.existsSync(binaryPath)) {
64
+ return binaryPath;
65
+ }
66
+ createCacheRoot(options.cacheRoot);
67
+ const scratchDir = `${targetDir}.tmp-${process.pid}`;
68
+ fs.rmSync(scratchDir, { force: true, recursive: true });
69
+ fs.mkdirSync(scratchDir);
70
+ try {
71
+ const archive = await download(asset.url, options.fetch ?? globalThis.fetch);
72
+ const actualSha256 = createHash('sha256').update(archive).digest('hex');
73
+ if (actualSha256 !== asset.sha256) {
74
+ throw new Error(`Checksum mismatch for ${asset.url}: expected sha256 ${asset.sha256}, got ${actualSha256}`);
75
+ }
76
+ const archivePath = path.join(scratchDir, 'archive.tar.gz');
77
+ fs.writeFileSync(archivePath, archive);
78
+ const extractDir = path.join(scratchDir, 'extract');
79
+ fs.mkdirSync(extractDir);
80
+ extractMember(archivePath, extractDir, pin.archiveMember);
81
+ const stageDir = path.join(scratchDir, 'stage');
82
+ fs.mkdirSync(stageDir);
83
+ const stagedBinary = path.join(stageDir, tool);
84
+ fs.renameSync(path.join(extractDir, pin.archiveMember), stagedBinary);
85
+ fs.chmodSync(stagedBinary, 0o755);
86
+ publishStage(stageDir, targetDir, binaryPath);
87
+ }
88
+ finally {
89
+ fs.rmSync(scratchDir, { force: true, recursive: true });
90
+ }
91
+ return binaryPath;
92
+ }
93
+ export function resolveToolPlatform(platform, arch) {
94
+ const key = `${platform}-${arch}`;
95
+ const supported = SUPPORTED_PLATFORMS.find((candidate) => candidate === key);
96
+ if (supported === undefined) {
97
+ throw new UserError(`Workflow linting does not support ${key}; the supported platforms are ${SUPPORTED_PLATFORMS.join(', ')}`);
98
+ }
99
+ return supported;
100
+ }
101
+ function createCacheRoot(cacheRoot) {
102
+ try {
103
+ fs.mkdirSync(cacheRoot, { recursive: true });
104
+ }
105
+ catch (error) {
106
+ throw new Error(`Cannot create the tool cache at ${cacheRoot}`, { cause: error });
107
+ }
108
+ }
109
+ async function download(url, fetchImpl) {
110
+ let response;
111
+ try {
112
+ response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
113
+ }
114
+ catch (error) {
115
+ throw new Error(`Cannot download ${url}: ${describeError(error)}`, { cause: error });
116
+ }
117
+ if (!response.ok) {
118
+ throw new Error(`Cannot download ${url}: HTTP ${response.status}`);
119
+ }
120
+ return Buffer.from(await response.arrayBuffer());
121
+ }
122
+ function extractMember(archivePath, extractDir, member) {
123
+ const result = spawnSync('tar', ['-xzf', archivePath, '-C', extractDir, member], { encoding: 'utf8' });
124
+ if (result.error !== undefined) {
125
+ throw new Error(`Cannot run tar to extract ${member}: ${result.error.message}`, { cause: result.error });
126
+ }
127
+ if (result.status !== 0) {
128
+ throw new Error(`tar could not extract ${member} from ${archivePath}: ${result.stderr.trim()}`);
129
+ }
130
+ }
131
+ function publishStage(stageDir, targetDir, binaryPath) {
132
+ try {
133
+ fs.renameSync(stageDir, targetDir);
134
+ }
135
+ catch (error) {
136
+ if (!fs.existsSync(binaryPath)) {
137
+ throw error;
138
+ }
139
+ }
140
+ }
@@ -4,6 +4,7 @@ const TYPECHECK_STEP = { run: 'typecheck', shouldDeclineArguments: true };
4
4
  const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', shouldDeclineArguments: true };
5
5
  const LINT_CHECK_STEP = { run: 'lint:check', shouldRunInBackground: true };
6
6
  const LINT_STRICT_STEP = { run: 'lint:strict', shouldRunInBackground: true };
7
+ const LINT_WORKFLOWS_STEP = { run: 'lint:workflows', shouldDeclineArguments: true };
7
8
  export const workspaceScripts = {
8
9
  build: ['compile'],
9
10
  check: [LINT_CHECK_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
@@ -18,12 +19,12 @@ export const workspaceScripts = {
18
19
  'lint:check': 'eslint .',
19
20
  'lint:strict': 'strict-lint',
20
21
  'report-catalog': 'nmr-report-catalog',
21
- test: `pnpm exec vitest ${GATE_PROJECTS}`,
22
- 'test:all': 'pnpm exec vitest',
23
- 'test:coverage': `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
24
- 'test:tool': 'pnpm exec vitest --project tool',
25
- 'test:unit': 'pnpm exec vitest --project unit',
26
- 'test:watch': `pnpm exec vitest ${GATE_PROJECTS} --watch`,
22
+ test: `nmr-vitest ${GATE_PROJECTS}`,
23
+ 'test:all': 'nmr-vitest',
24
+ 'test:coverage': `nmr-vitest ${GATE_PROJECTS} --coverage`,
25
+ 'test:tool': 'nmr-vitest --project tool',
26
+ 'test:unit': 'nmr-vitest --project unit',
27
+ 'test:watch': `nmr-vitest ${GATE_PROJECTS} --watch`,
27
28
  typecheck: 'tsgo --noEmit',
28
29
  upgrade: 'nmr-report-catalog && nmr-taze',
29
30
  'view-coverage': 'open coverage/index.html',
@@ -35,8 +36,8 @@ export function buildRootScripts(workspaceGlobs) {
35
36
  'audit:dev': 'pnpm exec v11y --dev',
36
37
  'audit:prod': 'pnpm exec v11y --prod',
37
38
  build: ['-R build'],
38
- check: [LINT_CHECK_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
39
- 'check:strict': [LINT_STRICT_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
39
+ check: [LINT_CHECK_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
40
+ 'check:strict': [LINT_STRICT_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
40
41
  ci: [{ run: 'build', shouldDeclineArguments: true }, 'check:strict'],
41
42
  clean: 'nmr-clean',
42
43
  fix: ['lint', 'fmt'],
@@ -46,9 +47,10 @@ export function buildRootScripts(workspaceGlobs) {
46
47
  lint: 'eslint --fix .',
47
48
  'lint:check': 'eslint .',
48
49
  'lint:strict': 'strict-lint',
50
+ 'lint:workflows': 'nmr-actionlint',
49
51
  prepush: [{ run: 'audit', shouldDeclineArguments: true }, 'ci'],
50
52
  'report-overrides': 'nmr-report-overrides',
51
- 'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', 'root:test'],
53
+ 'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', LINT_WORKFLOWS_STEP, 'root:test'],
52
54
  'root:lint': `eslint --fix ${ignorePatterns}.`,
53
55
  'root:lint:check': `eslint ${ignorePatterns}.`,
54
56
  'root:lint:strict': `strict-lint ${ignorePatterns}.`,
@@ -5,5 +5,5 @@
5
5
  * `noop` is a neutral circle: Nothing ran because the repo asked for nothing to run, which is neither a
6
6
  * failure nor a block.
7
7
  */
8
- export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "clean" | "catalog" | "noop" | "package" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
8
+ export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "clean" | "host" | "catalog" | "noop" | "package" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
9
9
  export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
@@ -2,6 +2,7 @@ import { defineGlyphSet } from '@williamthorsen/nmr-core';
2
2
  export const NMR_GLYPHS = defineGlyphSet({
3
3
  catalog: { plain: '', rich: '\u{1F4DA}' },
4
4
  clean: { plain: '', rich: '\u{1F9F9}' },
5
+ host: { plain: '', rich: '\u{1F4E1}' },
5
6
  noop: { plain: 'NOOP', rich: '\u{26AA}' },
6
7
  overrides: { plain: '', rich: '\u{1F512}' },
7
8
  package: { plain: '', rich: '\u{1F4E6}' },
@@ -14,6 +14,8 @@ export function runHostSession(params) {
14
14
  const COMMAND_NOT_FOUND_EXIT_CODE = 127;
15
15
  const DELEGATION_FAILURE_EXIT_CODE = 255;
16
16
  const STOP_GRACE_MS = 5_000;
17
+ const NODE_MODULES_DIR = path.join(params.rootDir, 'node_modules');
18
+ const BINS_RECORD_PATH = path.join(NODE_MODULES_DIR, '.nmr-delegate-bins');
17
19
  const token = crypto.randomUUID();
18
20
  let isHolding = false;
19
21
  let activeChild;
@@ -36,6 +38,10 @@ export function runHostSession(params) {
36
38
  return;
37
39
  }
38
40
  }
41
+ const binsFingerprint = readBinsFingerprint();
42
+ if (binsFingerprint === undefined || readBinsRecord() !== binsFingerprint) {
43
+ removeWorkspaceState();
44
+ }
39
45
  const install = await runPhase('pnpm', ['install', '--frozen-lockfile'], params.rootDir, {
40
46
  ...process.env,
41
47
  CI: 'true',
@@ -46,6 +52,8 @@ export function runHostSession(params) {
46
52
  fail('install', install);
47
53
  return;
48
54
  }
55
+ if (binsFingerprint !== undefined)
56
+ writeBinsRecord(binsFingerprint);
49
57
  emit({ kind: 'run' });
50
58
  fs.mkdirSync(params.runDir, { recursive: true });
51
59
  fs.mkdirSync(params.passManifestDir, { recursive: true });
@@ -146,6 +154,42 @@ export function runHostSession(params) {
146
154
  return true;
147
155
  return state.holder !== undefined && isProcessGone(state.holder.pid);
148
156
  }
157
+ function readBinsFingerprint() {
158
+ let listing;
159
+ try {
160
+ listing = childProcess.execFileSync('git', ['ls-files', '-z', '--', '*package.json'], {
161
+ cwd: params.rootDir,
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'ignore'],
164
+ });
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ const lines = listing
170
+ .split('\0')
171
+ .filter((file) => path.basename(file) === 'package.json')
172
+ .toSorted()
173
+ .map((file) => {
174
+ try {
175
+ const manifest = JSON.parse(fs.readFileSync(path.join(params.rootDir, file), 'utf8'));
176
+ const fields = typeof manifest === 'object' && manifest !== null ? { ...manifest } : {};
177
+ return JSON.stringify([file, fields['name'], fields['bin']]);
178
+ }
179
+ catch {
180
+ return JSON.stringify([file]);
181
+ }
182
+ });
183
+ return crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
184
+ }
185
+ function readBinsRecord() {
186
+ try {
187
+ return fs.readFileSync(BINS_RECORD_PATH, 'utf8');
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ }
149
193
  function readErrorCode(error) {
150
194
  return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
151
195
  }
@@ -218,6 +262,14 @@ export function runHostSession(params) {
218
262
  fs.rmSync(LOCK_PATH, { force: true });
219
263
  }
220
264
  }
265
+ function removeWorkspaceState() {
266
+ const names = fs.existsSync(NODE_MODULES_DIR) ? fs.readdirSync(NODE_MODULES_DIR) : [];
267
+ for (const name of names) {
268
+ if (name.startsWith('.pnpm-workspace-state') && name.endsWith('.json')) {
269
+ fs.rmSync(path.join(NODE_MODULES_DIR, name), { force: true });
270
+ }
271
+ }
272
+ }
221
273
  function runPhase(file, args, cwd, env) {
222
274
  const head = [];
223
275
  const tail = [];
@@ -274,4 +326,12 @@ export function runHostSession(params) {
274
326
  catch {
275
327
  }
276
328
  }
329
+ function writeBinsRecord(fingerprint) {
330
+ try {
331
+ fs.mkdirSync(NODE_MODULES_DIR, { recursive: true });
332
+ fs.writeFileSync(BINS_RECORD_PATH, fingerprint);
333
+ }
334
+ catch {
335
+ }
336
+ }
277
337
  }
@@ -21,7 +21,8 @@ export declare function checkTestFileConventions(options?: TestFileConventionsOp
21
21
  * remedy. Outside Vitest it does nothing.
22
22
  *
23
23
  * A second `@vitest/runner` copy is never handed the running state, and its `describe` fails with a bare `TypeError`.
24
- * The default `probe` comes from `vitest/suite`, which re-exports the same copy as the `describe` imported here.
24
+ * The default `probe` is `TestRunner.getCurrentSuite`, imported from the same `vitest` as `describe`, so both come from
25
+ * one runner copy.
25
26
  *
26
27
  * @internal - Exported only to enable testing
27
28
  */
package/dist/esm/tests.js CHANGED
@@ -1,5 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { getCurrentSuite } from 'vitest/suite';
1
+ import { describe, expect, it, TestRunner } from 'vitest';
3
2
  import { findMisplacedTestFiles, findUntieredTestFiles, TIER_NAMES } from "./tiers.js";
4
3
  import { findMonorepoRoot } from "./workspace.js";
5
4
  const MISPLACED_REMEDY = 'Every test file must be under a `__tests__` directory, the only place from which the shared Vitest config collects. Move each file below into one, or name its directory in `excludedBasenames`.';
@@ -18,7 +17,7 @@ export function checkTestFileConventions(options = {}) {
18
17
  });
19
18
  });
20
19
  }
21
- export function assertImportedRunnerIsActive(probe = getCurrentSuite) {
20
+ export function assertImportedRunnerIsActive(probe = TestRunner.getCurrentSuite) {
22
21
  if (process.env['VITEST'] === undefined)
23
22
  return;
24
23
  try {
@@ -23,7 +23,7 @@ const MIN_CUT_BYTES = Buffer.byteLength(TRUNCATION_MARK);
23
23
  const REPLAY_MARKER = 'replayed:';
24
24
  function describeOutcome(verdict, style) {
25
25
  const statuses = STATUS_GLYPHS[style];
26
- const onHost = verdict.host === undefined ? '' : ` on ${verdict.host}`;
26
+ const onHost = verdict.host === undefined ? '' : ` on ${formatHost(verdict.host, style)}`;
27
27
  switch (verdict.outcome) {
28
28
  case 'passed':
29
29
  return { marker: statuses.passed.text, phrase: `passed${onHost} in ${formatDuration(verdict.durationMs)}` };
@@ -35,9 +35,10 @@ function describeOutcome(verdict, style) {
35
35
  case 'recalled': {
36
36
  const saving = formatSaving(verdict.savedMs);
37
37
  const savingClause = saving === undefined ? '' : `, ${saving}`;
38
+ const source = verdict.host === undefined ? 'this tree' : formatHost(verdict.host, style);
38
39
  return {
39
40
  marker: statuses.skipped.text,
40
- phrase: `passed ${formatDuration(verdict.ageMs)} ago on ${verdict.host ?? 'this tree'}${savingClause}`,
41
+ phrase: `passed ${formatDuration(verdict.ageMs)} ago on ${source}${savingClause}`,
41
42
  };
42
43
  }
43
44
  case 'no-op':
@@ -87,6 +88,10 @@ function findCutTarget(sizesBytes, longestSizeBytes, overrunBytes) {
87
88
  function isWithinBudget(renderedLine) {
88
89
  return Buffer.byteLength(renderedLine) <= LINE_BUDGET_BYTES;
89
90
  }
91
+ function formatHost(host, style) {
92
+ const glyph = NMR_GLYPHS[style].host.text;
93
+ return glyph === '' ? host : `${glyph} ${host}`;
94
+ }
90
95
  function readAttribution(verdict) {
91
96
  if (verdict.outcome !== 'recalled' || verdict.replay === undefined) {
92
97
  return [];
@@ -10,7 +10,7 @@ The cache is a working-tree cache, not a build cache: It answers "has this exact
10
10
 
11
11
  ## What is cached
12
12
 
13
- Cacheable by default: `check`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
13
+ Cacheable by default: `check`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `lint:workflows`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
14
14
 
15
15
  Everything else runs every time:
16
16
 
@@ -124,7 +124,7 @@ The excerpt is a line; nmr also keeps the run's whole transcript. `nmr --log <co
124
124
  ```console
125
125
  $ nmr --log test
126
126
  ๐Ÿ’พ nmr: test โ€” recorded 2026-08-12T15:04:05.412Z (12m ago), ran in 12.4s
127
- $ pnpm exec vitest --project unit --project tool
127
+ $ nmr-vitest --project unit --project tool
128
128
 
129
129
  โœ“ src/__tests__/resolver.unit.test.ts (18 tests) 12ms
130
130
 
package/docs/delegate.md CHANGED
@@ -5,7 +5,7 @@ nmr runs a command on another machine over SSH, against the working tree on this
5
5
  ```console
6
6
  $ nmr --delegate-to imac2 ci
7
7
  # ... the host's own output and verdicts ...
8
- โœ… my-repo: ci: passed on imac2 in 1m 48s
8
+ โœ… my-repo: ci: passed on ๐Ÿ“ก imac2 in 1m 48s
9
9
  ```
10
10
 
11
11
  A machine that runs several worktrees' checks at once runs out of memory, and the [machine lock](machine-lock.md) protects it by making the checks wait for one another. Delegation moves a check to a machine that has the room for it instead. Which hosts a machine can reach is a fact about the machine, so a user config on the machine, not the repo, names them and turns delegation on.
@@ -61,7 +61,7 @@ With several hosts, nmr asks every host at once, with one `ssh` call each, wheth
61
61
 
62
62
  ```console
63
63
  $ NMR_DELEGATE=imac2,imac3 nmr ci
64
- โœ… my-repo: ci: passed on imac3 in 1m 52s
64
+ โœ… my-repo: ci: passed on ๐Ÿ“ก imac3 in 1m 52s
65
65
  ```
66
66
 
67
67
  - **A single host is not asked.** The delegation goes to it directly.
@@ -98,7 +98,7 @@ $ nmr --delegate-to imac2 ci
98
98
 
99
99
  nmr records the working tree as it is on disk as a commit: tracked changes, staged or not, and untracked files that git does not ignore. This is the tree that a local run checks. The commit is built in a temporary index, so the repository's index, refs, and stash do not change.
100
100
 
101
- nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each repository on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. nmr does not remove these checkouts.
101
+ nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each repository on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. When the workspace packages' `bin` entries differ from those of the checkout's last successful install, nmr first removes pnpm's workspace state, so that the install links the new bins, which an unchanged lockfile would otherwise let pnpm skip; that install reruns the workspace's `prepare` scripts. nmr does not remove these checkouts.
102
102
 
103
103
  - **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
104
104
  - **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. A delegation flag refuses such a tree; under `NMR_DELEGATE` or the user config, the command runs here, and nmr prints one line saying why.
@@ -123,7 +123,7 @@ The host's nmr prints its output and verdicts, and nmr here adds one verdict for
123
123
  A failure of the delegation itself, rather than of the command, exits 255, the status with which `ssh` reports its own failures, and names the step that failed, after what that step printed:
124
124
 
125
125
  ```console
126
- โŒ my-repo: ci: failed on imac2 in 10.0s (exit 255) โ€” could not prepare imac2
126
+ โŒ my-repo: ci: failed on ๐Ÿ“ก imac2 in 10.0s (exit 255) โ€” could not prepare imac2
127
127
  ```
128
128
 
129
129
  The steps are `prepare`, which reaches the host and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
package/docs/scripts.md CHANGED
@@ -182,7 +182,7 @@ Neither is bound to a git hook. `prepush` is named for when a developer runs it,
182
182
 
183
183
  ### Check and quality
184
184
 
185
- `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.
185
+ `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. At the root, both then run [`lint:workflows`](#workflow-lint) before typecheck, and `root:check` runs it after `root:lint:check`.
186
186
 
187
187
  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`.
188
188
 
@@ -194,6 +194,14 @@ Requires `eslint` >= 10 and, for `lint:strict`, `@williamthorsen/strict-lint` >=
194
194
 
195
195
  The run never enters a package, so per-package `lint:pre` and `lint:post` hooks do not fire from the root. They still run from inside the package and under `nmr -F <package> lint`.
196
196
 
197
+ ### Workflow lint
198
+
199
+ `lint:workflows`, a root command, lints the GitHub Actions workflows under `.github/workflows/` with [actionlint](https://github.com/rhysd/actionlint), including its shellcheck pass over every `run:` block, and fails on any finding. A repo without `.github/workflows/` passes. The command runs `nmr-actionlint`, which forwards trailing arguments to actionlint, so `nmr lint:workflows .github/workflows/ci.yaml` lints that file alone; inside `check`, `check:strict`, and `root:check`, the step declines them.
200
+
201
+ The result does not depend on what the machine has installed. nmr pins actionlint 1.7.12 and shellcheck 0.11.0, downloads each from its upstream GitHub release on first use, verifies it against the SHA-256 recorded in nmr, and caches it under `node_modules/.cache/nmr-tools/` at the monorepo root. The first run therefore needs access to `github.com`, and a failed download or a checksum mismatch fails the command. The pyflakes integration is off. The pins move only with a release of nmr.
202
+
203
+ The supported platforms are macOS and Linux on x64 and arm64; on any other, the command fails and names the platform. actionlint reads its own configuration from `.github/actionlint.yaml`, through which a repo can ignore a finding.
204
+
197
205
  ### Root-only
198
206
 
199
207
  The `root:`-prefixed commands operate on root-level code only (not workspace packages).
package/docs/utilities.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Standalone utilities
2
2
 
3
- The binaries behind nmr's default `clean`, `compile`, and `fmt` commands, and a check for the `prepublishOnly` hooks of publishable packages.
3
+ The binaries behind nmr's default `clean`, `compile`, `fmt`, `lint:workflows`, and package `test` commands, and a check for the `prepublishOnly` hooks of publishable packages.
4
+
5
+ ## `nmr-actionlint`
6
+
7
+ Lint the monorepo's GitHub Actions workflows with pinned actionlint and shellcheck binaries, forwarding any arguments to actionlint. This is the default root `lint:workflows` script; [Workflow lint](scripts.md#workflow-lint) covers the pins, the download, and the supported platforms.
8
+
9
+ ```bash
10
+ nmr-actionlint
11
+ ```
4
12
 
5
13
  ## `nmr-clean`
6
14
 
@@ -63,6 +71,16 @@ Pass `--no-cache`, or set `NMR_NO_CACHE=1`, to run uncached. `nmr --no-cache fmt
63
71
  nmr-fmt --check --no-cache
64
72
  ```
65
73
 
74
+ ## `nmr-vitest`
75
+
76
+ Run Vitest from a package directory with the monorepo's shared config. Vitest 5 does not search parent directories for a config, so `nmr-vitest` names it: When the working directory does not contain a `vitest.config.*` or `vite.config.*` of its own, it passes `--config` naming the `vitest.config.*` at the monorepo root, the directory that contains `pnpm-workspace.yaml`. It does not search the directories in between. A package with a config of its own, a monorepo root without a `vitest.config.*`, and a directory outside any monorepo all run Vitest without the flag. An explicit `--config` or `-c` ahead of any `--` passes through unchanged, and so does every other argument. This is the command behind every package-scope `test*` script.
77
+
78
+ `vitest` is an optional peer dependency (`>=4.0.0 <6`), resolved from the working directory alone and never from nmr's installation, because a second copy of the runner is not handed the state of the one that loads the tests. Run without it, `nmr-vitest` fails with an error naming the peer and the range. Its exit status is Vitest's, or 128 plus the signal number when a signal ends Vitest.
79
+
80
+ ```bash
81
+ nmr-vitest --project unit --project tool
82
+ ```
83
+
66
84
  ## `ensure-prepublish-hooks`
67
85
 
68
86
  Verify that all publishable workspace packages have a `prepublishOnly` script. Exits non-zero if any are missing.
package/docs/vitest.md CHANGED
@@ -4,7 +4,7 @@ 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 <6`), 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
 
@@ -58,6 +58,8 @@ Every package resolves the same six test commands. Nothing is detected on disk:
58
58
 
59
59
  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.
60
60
 
61
+ The package scripts run [`nmr-vitest`](utilities.md#nmr-vitest), which names the root config when a package does not have one of its own. Vitest 5 does not search parent directories for a config, so under it a bare `vitest`, or an editor's test runner, started from such a package needs `--config` naming the root's `vitest.config.*`; Vitest 4 finds it unaided. A repo that overrides a package `test*` script with its own `vitest` invocation passes the flag there too.
62
+
61
63
  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.
62
64
 
63
65
  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.
@@ -100,7 +102,7 @@ checkTestFileConventions({ excludedBasenames: ['cypress', 'generated'] });
100
102
 
101
103
  **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.
102
104
 
103
- 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.
105
+ Expect the first run to fail in a repo that has never gated this. Vitest 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.
104
106
 
105
107
  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.
106
108
 
@@ -157,7 +159,7 @@ It folds across layers like the other two, the last layer to declare it winning.
157
159
 
158
160
  **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.
159
161
 
160
- **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.
162
+ **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 that does not declare `vite` itself, whose Vite is the one that Vitest brings in: a dependency under Vitest 4 and an auto-installed peer under Vitest 5. Both majors accept Vite 6 and 7: On those, the emitted key does nothing and the aliased import fails exactly as it would with the flag off.
161
163
 
162
164
  ## Customizing by scope
163
165
 
@@ -215,7 +217,7 @@ Both factories fold any number of layers left to right: A later layer wins on a
215
217
 
216
218
  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.
217
219
 
218
- **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.
220
+ **A package's own `vite.config.ts` also counts as that one config.** Vitest loads a `vitest.config.*` or `vite.config.*` that it finds in the run root, and [`nmr-vitest`](utilities.md#nmr-vitest) names the root config only for a package that does not contain either, so a package with only a Vite config 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.
219
221
 
220
222
  **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.
221
223
 
@@ -249,7 +251,7 @@ The collection exclusions do not name fixtures, and that asymmetry is intentiona
249
251
 
250
252
  ## Root-scoped tests
251
253
 
252
- A monorepo's own root-level tests need a second config, because the package config is found by walking up from a package directory:
254
+ A monorepo's own root-level tests need a second config, because [`nmr-vitest`](utilities.md#nmr-vitest) passes the root's `vitest.config.*` to every package that does not have a config of its own, and that config covers packages:
253
255
 
254
256
  ```ts
255
257
  // vitest.root.config.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.50.1",
3
+ "version": "0.52.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -58,12 +58,14 @@
58
58
  "bin": {
59
59
  "ensure-prepublish-hooks": "bin/ensure-prepublish-hooks.js",
60
60
  "nmr": "bin/nmr.js",
61
+ "nmr-actionlint": "bin/nmr-actionlint.js",
61
62
  "nmr-clean": "bin/nmr-clean.js",
62
63
  "nmr-compile": "bin/nmr-compile.js",
63
64
  "nmr-fmt": "bin/nmr-fmt.js",
64
65
  "nmr-report-catalog": "bin/nmr-report-catalog.js",
65
66
  "nmr-report-overrides": "bin/nmr-report-overrides.js",
66
- "nmr-taze": "bin/nmr-taze.js"
67
+ "nmr-taze": "bin/nmr-taze.js",
68
+ "nmr-vitest": "bin/nmr-vitest.js"
67
69
  },
68
70
  "files": [
69
71
  ".meta/changelog.json",
@@ -77,9 +79,9 @@
77
79
  "!docs/*.v*.md"
78
80
  ],
79
81
  "dependencies": {
80
- "@williamthorsen/nmr-core": "0.13.5",
81
- "@williamthorsen/toolbelt.errors": "0.8.3",
82
- "@williamthorsen/toolbelt.filesystem": "0.9.3",
82
+ "@williamthorsen/nmr-core": "0.13.6",
83
+ "@williamthorsen/toolbelt.errors": "0.8.4",
84
+ "@williamthorsen/toolbelt.filesystem": "0.9.4",
83
85
  "glob": "13.0.6",
84
86
  "prettier": "3.9.10",
85
87
  "prettier-plugin-sh": "0.20.2",
@@ -87,20 +89,20 @@
87
89
  "unconfig": "7.5.0"
88
90
  },
89
91
  "devDependencies": {
90
- "@williamthorsen/toolbelt.testing": "0.6.3",
91
- "@williamthorsen/toolbelt.vitest": "0.10.3",
92
+ "@williamthorsen/toolbelt.testing": "0.7.0",
93
+ "@williamthorsen/toolbelt.vitest": "0.12.0",
92
94
  "esbuild": "0.28.2",
93
- "readyup": "0.42.0",
95
+ "readyup": "0.43.0",
94
96
  "tinyglobby": "0.2.17",
95
97
  "typescript": "6.0.3",
96
98
  "vite": "8.3.4",
97
- "vitest": "4.1.11",
99
+ "vitest": "5.0.5",
98
100
  "yaml": "2.9.1"
99
101
  },
100
102
  "peerDependencies": {
101
103
  "typescript": ">=5.7.0 <7",
102
104
  "vite": ">=8.0.0 <9",
103
- "vitest": ">=4.0.0 <5"
105
+ "vitest": ">=4.0.0 <6"
104
106
  },
105
107
  "peerDependenciesMeta": {
106
108
  "typescript": {