@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
@@ -3,21 +3,90 @@
3
3
  "kits": [
4
4
  {
5
5
  "bundledDependencies": {
6
- "@williamthorsen/toolbelt.errors": "0.8.2",
6
+ "@williamthorsen/toolbelt.errors": "0.8.3",
7
+ "@williamthorsen/toolbelt.filesystem": "0.9.3",
8
+ "@williamthorsen/toolbelt.packaging": "0.6.3",
9
+ "@williamthorsen/toolbelt.terminal": "0.2.1",
10
+ "ansi-regex": "6.4.0",
11
+ "ansi-styles": "6.2.3",
12
+ "cli-truncate": "6.1.1",
13
+ "diff": "9.0.0",
14
+ "get-east-asian-width": "1.7.0",
15
+ "is-fullwidth-code-point": "5.1.0",
16
+ "slice-ansi": "9.0.1",
17
+ "string-width": "8.3.0",
18
+ "strip-ansi": "7.2.0",
19
+ "wrap-ansi": "10.0.2",
7
20
  "yaml": "2.9.1"
8
21
  },
9
22
  "checklists": [
10
- "nmr"
23
+ "nmr",
24
+ "self-hosted runner"
11
25
  ],
12
26
  "esbuildVersion": "0.28.2",
13
27
  "inputs": [
14
28
  {
15
- "hash": "50b5aac3",
29
+ "hash": "aed52073",
30
+ "kind": "module",
31
+ "path": "../../nmr-core/dist/esm/cache-store.js"
32
+ },
33
+ {
34
+ "hash": "54384dca",
35
+ "kind": "module",
36
+ "path": "../../nmr-core/dist/esm/gitOutputLimit.js"
37
+ },
38
+ {
39
+ "hash": "6b7d0243",
40
+ "kind": "module",
41
+ "path": "../../nmr-core/dist/esm/hasErrnoCode.js"
42
+ },
43
+ {
44
+ "hash": "211453c6",
45
+ "kind": "module",
46
+ "path": "../../nmr-core/dist/esm/hashWorkingTree.js"
47
+ },
48
+ {
49
+ "hash": "41232d10",
50
+ "kind": "module",
51
+ "path": "../../nmr-core/dist/esm/helpers/workspace-patterns.js"
52
+ },
53
+ {
54
+ "hash": "191f7c24",
55
+ "kind": "module",
56
+ "path": "../../nmr-core/dist/esm/index.js"
57
+ },
58
+ {
59
+ "hash": "2507870d",
60
+ "kind": "module",
61
+ "path": "../../nmr-core/dist/esm/parseArgs.js"
62
+ },
63
+ {
64
+ "hash": "14547f78",
65
+ "kind": "module",
66
+ "path": "../../nmr-core/dist/esm/terminal.js"
67
+ },
68
+ {
69
+ "hash": "3c395f31",
70
+ "kind": "module",
71
+ "path": "../../nmr-core/dist/esm/updateManagedFile.js"
72
+ },
73
+ {
74
+ "hash": "2224ec3a",
75
+ "kind": "module",
76
+ "path": "../../nmr-core/dist/esm/workspace.js"
77
+ },
78
+ {
79
+ "hash": "24bde686",
80
+ "kind": "module",
81
+ "path": "../../nmr-core/dist/esm/writeFileWithCheck.js"
82
+ },
83
+ {
84
+ "hash": "aaeb1a84",
16
85
  "kind": "module",
17
86
  "path": "kits/default.ts"
18
87
  },
19
88
  {
20
- "hash": "caf06cd5",
89
+ "hash": "22efcac6",
21
90
  "kind": "inline",
22
91
  "path": "../package.json",
23
92
  "paths": [
@@ -25,7 +94,12 @@
25
94
  ]
26
95
  },
27
96
  {
28
- "hash": "9336f4c0",
97
+ "hash": "7e066d18",
98
+ "kind": "module",
99
+ "path": "../src/config.ts"
100
+ },
101
+ {
102
+ "hash": "0fb883a1",
29
103
  "kind": "module",
30
104
  "path": "../src/default-scripts.ts"
31
105
  },
@@ -35,22 +109,87 @@
35
109
  "path": "../src/git-ignored-paths.ts"
36
110
  },
37
111
  {
38
- "hash": "23646439",
112
+ "hash": "ae257d50",
113
+ "kind": "module",
114
+ "path": "../src/helpers/config-path.ts"
115
+ },
116
+ {
117
+ "hash": "a663548a",
118
+ "kind": "module",
119
+ "path": "../src/helpers/escapeControlCharacters.ts"
120
+ },
121
+ {
122
+ "hash": "47249ca4",
123
+ "kind": "module",
124
+ "path": "../src/helpers/findClosestName.ts"
125
+ },
126
+ {
127
+ "hash": "3efe4131",
128
+ "kind": "module",
129
+ "path": "../src/helpers/hook-name.ts"
130
+ },
131
+ {
132
+ "hash": "8850834f",
133
+ "kind": "module",
134
+ "path": "../src/helpers/package-json.ts"
135
+ },
136
+ {
137
+ "hash": "61629be6",
138
+ "kind": "module",
139
+ "path": "../src/helpers/readStringValues.ts"
140
+ },
141
+ {
142
+ "hash": "56d2bb48",
143
+ "kind": "module",
144
+ "path": "../src/helpers/type-guards.ts"
145
+ },
146
+ {
147
+ "hash": "78ca6bd4",
39
148
  "kind": "module",
40
149
  "path": "../src/resolve-scripts.ts"
41
150
  },
42
151
  {
43
- "hash": "19dec003",
152
+ "hash": "15247f0e",
153
+ "kind": "module",
154
+ "path": "../src/resolver.ts"
155
+ },
156
+ {
157
+ "hash": "ed585ab6",
158
+ "kind": "module",
159
+ "path": "../src/steps.ts"
160
+ },
161
+ {
162
+ "hash": "d1e8e302",
44
163
  "kind": "module",
45
164
  "path": "../src/tiers.ts"
165
+ },
166
+ {
167
+ "hash": "faad986a",
168
+ "kind": "module",
169
+ "path": "../src/UserError.ts"
170
+ },
171
+ {
172
+ "hash": "857c630c",
173
+ "kind": "module",
174
+ "path": "../src/verbosity.ts"
175
+ },
176
+ {
177
+ "hash": "f08b6ff3",
178
+ "kind": "module",
179
+ "path": "../src/workspace-globs.ts"
180
+ },
181
+ {
182
+ "hash": "922e7155",
183
+ "kind": "module",
184
+ "path": "../src/workspace.ts"
46
185
  }
47
186
  ],
48
187
  "name": "default",
49
188
  "path": "kits/default.js",
50
189
  "readyupVersion": "0.40.0",
51
190
  "source": "kits/default.ts",
52
- "sourceHash": "50b5aac3",
53
- "targetHash": "7a5cd413"
191
+ "sourceHash": "aaeb1a84",
192
+ "targetHash": "a8b07f51"
54
193
  }
55
194
  ]
56
195
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.45.0 — 2026-10-12
6
+
7
+ ### 🎉 Features
8
+
9
+ - Adds `maintenance.auto=false` to the git config that the Vitest git-isolation setup file injects, so that a git command run by a test does not start a detached `git maintenance` process that can still be writing under `.git` when the test removes the repository. (#1033)
10
+
11
+ ### 🐛 Bug fixes
12
+
13
+ - Fixes the issue that `nmr-fmt` failed a run in which every path matched by the pathspecs was one that it skips, such as a symlink, a submodule gitlink, or an unreadable file, and makes such a run exit 0 and name the skipped paths on stderr. (#1010)
14
+ - Fixes the issue that a `rootScripts` or `workspaceScripts` entry in `.config/nmr.config.ts` that names its own key, as a shell segment or as a step-list element, re-entered nmr without bound, by rejecting the entry when the config loads with an error that names the file, the field, the key, and the edit that resolves it. (#1011)
15
+ - Fixes the issue that a package's own `package.json` entry running `nmr -R <same command>` re-entered that package without bound instead of being discarded or rejected as a self-reference. (#1011)
16
+ - Fixes the issue that nmr treated a root entry such as `pnpm --recursive exec nmr build` as a re-invocation of its own command, because it ignored the `-r`, `--filter`, and `-w` flags that pnpm applies to the nmr that it launches. (#1011)
17
+ - Fixes the issue that `nmr` hung when a script entry re-entered its own command in the same directory without nmr recognizing the self-reference, as `nmr -F own-pkg build` in `own-pkg`'s own `build` or a cycle through two entries does, by failing the run with an error that names the command and the directory and lists the commands that the run had entered, outermost first. (#1012)
18
+ - Fixes the issue that the test-tier definitions in `vitest.md` gave a test that starts its own loopback server three defensible tiers: `tool` is now a process that the test starts or a program that the environment supplies, `localhost` a service already running on this machine, and an in-process server from the package's own dependency closure `unit`. (#1023)
19
+ - Fixes the issue that `nmr`'s warning about a `package.json` script that runs `nmr` through a shell sent an entry whose whole value is `nmr <name>` to `.config/nmr.config.ts`, when the accurate edit is to replace that value with the command of the `<name>` script in the same file. (#1030)
20
+
21
+ ### ♻️ Refactoring
22
+
23
+ - Moves the work of `runInvocation` in `nmr` into the phase functions `establishInvocation`, `planRun`, and `executePlan`, which `runCli` calls in order, and changes `runGated` and `reportRecording` to take a named `RunPlan` in place of an options object that repeated its fields. (#1014)
24
+
25
+ ### 🧪 Tests
26
+
27
+ - Replaces the `GIT_CEILING_DIRECTORIES` stub with a `GIT_DIR` stub that points to a missing path in the no-repository test of `check-cache-gate.tool.test.ts`, and sets `maintenance.auto` to `false` in the file's fixture repository, so that git neither finds a leftover of the removed `repo/.git` nor starts a detached maintenance process that writes under `.git`. (#1032)
28
+
29
+ ## 0.44.0 — 2026-10-11
30
+
31
+ ### 🎉 Features
32
+
33
+ - Enables Prettier's content-keyed cache in `nmr-fmt` (run by `nmr fmt` and `nmr fmt:check`), which skips each file whose content, Prettier version, resolved options, and lockfile are unchanged since a run that passed it, and adds `--no-cache` and `NMR_NO_CACHE=1` to bypass it. (#993)
34
+ - Adds the `shouldRunInBackground` field to a composite element in `.config/nmr.config.ts`, which starts the step, runs the steps after it without waiting, prints the step's output whole after the step ends, and fails the composite if the step fails. (#1003)
35
+ - Adds a `self-hosted runner` checklist to nmr's readyup kit, which reads the repo's GitHub settings through `gh`, recommends setting `CI_RUNS_ON` when a private repo with an online self-hosted runner and compatible workflows has not set it, and warns about each failing condition once the variable is set. (#1006)
36
+ - Adds the root-only config key `selfHostedRunner.exempt`, a non-empty reason that stops the kit from recommending `CI_RUNS_ON` for a repo that qualifies for the self-hosted runners but stays off them. (#1006)
37
+
38
+ ### 🪦 Removed
39
+
40
+ - 🚨 **Breaking:** Removes the zero-argument form of `getDefaultRootScripts` from the `@williamthorsen/nmr/scripts` export, which now requires the monorepo root so that it can read the declared workspaces. (#992)
41
+
42
+ ### 🐛 Bug fixes
43
+
44
+ - Fixes the issue that `root:lint`, `root:lint:check`, and `root:lint:strict` linted every workspace outside `packages/` under the root ESLint config, by excluding each workspace package that `pnpm-workspace.yaml` declares, with its `!` exclusions honored, in place of the fixed `packages/**`. (#992)
45
+ - Fixes the issue that `defineConfig({ mode: undefined })` emptied the `nmr upgrade` report, by translating a cleared `mode` into the `'default'` mode of taze (the upgrade tool that `nmr upgrade` runs), which follows the range that each dependency declares. (#1008)
46
+
47
+ ### ⚡ Performance
48
+
49
+ - Sets `maxWorkers: 3` on every project built by `defineVitestConfig` and `defineRootVitestConfig` when the machine has 2 or fewer cores, such as GitHub's standard runner for a private repo, on which Vitest's default ran every test file serially on one worker. (#994)
50
+ - Changes the default `check` and `check:strict` to start lint first and run it in the background, so that a local check, `nmr ci`, and `nmr prepush` no longer wait for type-aware lint to finish before typecheck, the format check, and the tests run. (#1003)
51
+
52
+ ### 📚 Documentation
53
+
54
+ - Documents in `docs/vitest.md` that Vitest's `--maxWorkers` flag has no effect on a machine with 2 or fewer cores, and that `VITEST_MAX_WORKERS`, `--no-file-parallelism`, or `maxWorkers` in the `project` seam overrides the default there. (#994)
55
+
5
56
  ## 0.43.0 — 2026-10-09
6
57
 
7
58
  ### 🎉 Features
package/README.md CHANGED
@@ -5,11 +5,21 @@
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.43.0 (2026-10-09)
8
+ ## Release notes — v0.45.0 (2026-10-12)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Extends `nmr-compile` to compile `.tsx` sources and to rewrite relative `.tsx` specifiers to `.js`, and leaves the JSX transform to the package's own tsconfig `jsx` option. (#988)
12
+ - Adds `maintenance.auto=false` to the git config that the Vitest git-isolation setup file injects, so that a git command run by a test does not start a detached `git maintenance` process that can still be writing under `.git` when the test removes the repository. (#1033)
13
+
14
+ ### 🐛 Bug fixes
15
+
16
+ - Fixes the issue that `nmr-fmt` failed a run in which every path matched by the pathspecs was one that it skips, such as a symlink, a submodule gitlink, or an unreadable file, and makes such a run exit 0 and name the skipped paths on stderr. (#1010)
17
+ - Fixes the issue that a `rootScripts` or `workspaceScripts` entry in `.config/nmr.config.ts` that names its own key, as a shell segment or as a step-list element, re-entered nmr without bound, by rejecting the entry when the config loads with an error that names the file, the field, the key, and the edit that resolves it. (#1011)
18
+ - Fixes the issue that a package's own `package.json` entry running `nmr -R <same command>` re-entered that package without bound instead of being discarded or rejected as a self-reference. (#1011)
19
+ - Fixes the issue that nmr treated a root entry such as `pnpm --recursive exec nmr build` as a re-invocation of its own command, because it ignored the `-r`, `--filter`, and `-w` flags that pnpm applies to the nmr that it launches. (#1011)
20
+ - Fixes the issue that `nmr` hung when a script entry re-entered its own command in the same directory without nmr recognizing the self-reference, as `nmr -F own-pkg build` in `own-pkg`'s own `build` or a cycle through two entries does, by failing the run with an error that names the command and the directory and lists the commands that the run had entered, outermost first. (#1012)
21
+ - Fixes the issue that the test-tier definitions in `vitest.md` gave a test that starts its own loopback server three defensible tiers: `tool` is now a process that the test starts or a program that the environment supplies, `localhost` a service already running on this machine, and an in-process server from the package's own dependency closure `unit`. (#1023)
22
+ - Fixes the issue that `nmr`'s warning about a `package.json` script that runs `nmr` through a shell sent an entry whose whole value is `nmr <name>` to `.config/nmr.config.ts`, when the accurate edit is to replace that value with the command of the `<name>` script in the same file. (#1030)
13
23
  <!-- /section:release-notes -->
14
24
 
15
25
  ## Installation
@@ -107,12 +117,14 @@ Scripts resolve through three tiers. Higher tiers override lower ones:
107
117
  2. **Repo-wide config**: Additions and overrides in `.config/nmr.config.ts`
108
118
  3. **Per-package overrides**: Scripts in a package's `package.json`
109
119
 
110
- [Scripts and configuration](docs/scripts.md#three-tier-override-system) shows a worked example, how to skip a script for one package, and what nmr does with a `package.json` entry that re-invokes its own command.
120
+ [Scripts and configuration](docs/scripts.md#three-tier-override-system) shows a worked example, how to skip a script for one package, and what nmr does with a `package.json` or `.config/nmr.config.ts` entry that re-invokes its own command.
111
121
 
112
122
  ## Pre and post hooks
113
123
 
114
124
  Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same three tiers as any other script (built-in defaults, then `.config/nmr.config.ts`, then per-package `package.json`). Wrapping is uniform: Nested invocations from composite expansion get their own hook treatment. A failing hook ends the sequence, and its exit code propagates.
115
125
 
126
+ A composite runs its steps in series, except a step declared with `shouldRunInBackground`, which runs alongside the steps after it. The default `check` and `check:strict` run lint that way; [Running a step in the background](docs/scripts.md#running-a-step-in-the-background) covers how its output prints and how it fails.
127
+
116
128
  Extend `nmr build` with a pre-build step for every workspace package:
117
129
 
118
130
  ```ts
@@ -255,6 +267,8 @@ Wiring the sync to `postinstall` keeps it current without a hand-run command:
255
267
 
256
268
  nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry, the test-file conventions ([tier names](docs/vitest.md#test-tiers) and `__tests__` placement), that the repo's own suite gates them, that every `bin` target is a committed wrapper rather than build output that pnpm cannot link at install time, that `files` covers the output that each wrapper loads, that the root install scripts do not run `lefthook install` without `lefthook check-install`, and that the `package.json` files in the tree declare neither a `pnpm` field, from which pnpm 11 does not read any key, nor a `workspaces` field, which pnpm ignores. Those settings belong in `pnpm-workspace.yaml`, and `pnpx codemod run pnpm-v10-to-v11` moves the `pnpm` field's. It also checks that every manifest declares a package listed by a `pnpm-workspace.yaml` catalog as `catalog:` rather than as a literal version. A peer range is exempt, and a version that must differ belongs in a named catalog, referenced as `catalog:<name>`. The kit is included in the package, so it checks against the nmr version installed rather than whatever a repository ref happens to point at, and a tier added or renamed in nmr takes effect in the repo when it upgrades nmr. A repo that declares the [conventions check](docs/vitest.md#gating-the-test-file-conventions) gates both conventions in its own test run, and the kit then leaves the test files to that check.
257
269
 
270
+ A second checklist, `self-hosted runner`, reads the repo's GitHub settings through `gh` and recommends setting `CI_RUNS_ON` to `"self-hosted"` when the repo is private, has an online `self-hosted` runner, does not pin a code-quality workflow older than v8 or override its `runs-on`, and runs Node.js and pnpm only after a step installs them and does not run `gh`, which the image lacks. Once the variable is set, the checklist warns about each of those conditions that fails. It skips when `gh` is not installed or not authenticated. A repo that qualifies but keeps its CI off the runners records why in the root config's [`selfHostedRunner.exempt`](docs/scripts.md#defineconfig-fields).
271
+
258
272
  Add `readyup` as a devDependency, then name nmr in its config:
259
273
 
260
274
  ```ts
@@ -13,5 +13,5 @@ delivery: ambient
13
13
  - A check that already passed on the current working tree skips, printing `SKIP <scope>: <command>: passed …` and, when the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it. Read the replayed excerpt rather than running the check again: Never pass `--no-cache` to get a "real" run, which takes the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`. A composite replays its constituents' excerpts, each attributed as `<scope>: <command>: <excerpt>` and cut at the line's ceiling when there are too many to fit. A skip without an excerpt is one whose run wrote to a terminal, or one whose constituents recorded none.
14
14
  - `nmr --log <command>` prints the whole run behind that excerpt -- up to 256 KiB, led by when it was recorded, how long it ran, and the command string that produced it. Output that you need in full is a recording to read rather than a command to run again. It runs nothing, and it is held to the tree rather than to the terminal: A recording made by a piped run prints for a reader at one. A composite prints its constituents' excerpts, not their transcripts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. When there is nothing to show, `--log` says so on one line, names which case it is, and exits non-zero.
15
15
  - Every command that nmr runs reports one line -- `PASS <scope>: <command>: passed in 12.4s`, `FAIL … failed in 1.2s (exit 1)`, `SKIP … passed 4m ago on this tree`, or `NOOP … skipped, the override is empty` -- and a composite reports alongside every command into which it expands. Those are the words that a pipe and a CI log get; a terminal gets ✅, ❌, ⏩, and ⚪ in their place, and `--output-style rich` or `NMR_OUTPUT_STYLE=rich` forces the emoji on any stream. The lines print in every verbosity, so `nmr -q <command>` is the low-noise way to run rather than a silent one: It withholds the output of the commands that nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. nmr reads its own flags only before the command name and passes everything after the name to the commands that run: `nmr ci -q` hands `-q` to `nmr-fmt`, which rejects it. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
16
- - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing that the test reaches -- `tool` (a program supplied by the environment), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --sources` reports both and warns that the check is missing.
16
+ - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing that the test reaches -- `tool` (a process that the test starts, or a program that the environment supplies), `localhost` (a service already running on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --sources` reports both and warns that the check is missing.
17
17
  - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (the repo does not define a build script of its own); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; and `check-cache.md` for the check-result cache's key, the retention key to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
@@ -0,0 +1,26 @@
1
+ /** The directory, relative to the repository root, holding one Prettier cache file per scope. */
2
+ export declare const FMT_CACHE_DIRECTORY: string;
3
+ export interface PrepareFmtCacheOptions {
4
+ cwd: string;
5
+ env: NodeJS.ProcessEnv;
6
+ repositoryRoot: string;
7
+ shouldBypassCache: boolean;
8
+ }
9
+ export type PrepareFmtCacheResult = {
10
+ kind: 'cached';
11
+ location: string;
12
+ } | {
13
+ kind: 'uncached';
14
+ } | {
15
+ kind: 'failed';
16
+ reason: string;
17
+ };
18
+ /**
19
+ * Resolves the Prettier cache file for this scope, after pruning cache files keyed to an outdated lockfile and
20
+ * discarding a corrupt one, or reports why the run goes uncached.
21
+ *
22
+ * The file name combines a hash of the scope with a fingerprint of the repository's lockfiles. Prettier keys
23
+ * entries by path relative to its working directory, so each scope needs a file of its own; Prettier's own key
24
+ * covers its version and the resolved options but not plugin code, which the lockfile fingerprint covers.
25
+ */
26
+ export declare function prepareFmtCache(options: PrepareFmtCacheOptions): PrepareFmtCacheResult;
@@ -0,0 +1,71 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describeError } from '@williamthorsen/toolbelt.errors';
5
+ import { NO_CACHE_ENV_VAR } from "../check-cache.js";
6
+ export const FMT_CACHE_DIRECTORY = path.join('node_modules', '.cache', 'prettier', 'nmr-fmt');
7
+ const LOCKFILE_NAMES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lock', 'bun.lockb'];
8
+ const NO_LOCKFILE_MARKER = 'nolock';
9
+ const HASH_LENGTH = 16;
10
+ const CACHE_FILE_PATTERN = /^([0-9a-f]{16})\.([0-9a-f]{16}|nolock)\.json$/;
11
+ export function prepareFmtCache(options) {
12
+ const { cwd, env, repositoryRoot, shouldBypassCache } = options;
13
+ if (shouldBypassCache || env[NO_CACHE_ENV_VAR] === '1')
14
+ return { kind: 'uncached' };
15
+ try {
16
+ if (statSync(path.join(repositoryRoot, 'node_modules'), { throwIfNoEntry: false })?.isDirectory() !== true) {
17
+ return { kind: 'uncached' };
18
+ }
19
+ const cacheDirectory = path.join(repositoryRoot, FMT_CACHE_DIRECTORY);
20
+ mkdirSync(cacheDirectory, { recursive: true });
21
+ const lockfileHash = hashLockfiles(repositoryRoot);
22
+ pruneOutdatedCacheFiles(cacheDirectory, lockfileHash);
23
+ const scope = path.relative(repositoryRoot, realpathSync(cwd)).split(path.sep).join('/');
24
+ const location = path.join(cacheDirectory, `${hash(scope)}.${lockfileHash}.json`);
25
+ if (isCorruptCacheFile(location))
26
+ rmSync(location, { force: true });
27
+ return { kind: 'cached', location };
28
+ }
29
+ catch (error) {
30
+ return { kind: 'failed', reason: describeError(error) };
31
+ }
32
+ }
33
+ function hash(input) {
34
+ return createHash('sha256').update(input).digest('hex').slice(0, HASH_LENGTH);
35
+ }
36
+ function hashLockfiles(repositoryRoot) {
37
+ const digest = createHash('sha256');
38
+ let hasLockfile = false;
39
+ for (const name of LOCKFILE_NAMES) {
40
+ const lockfilePath = path.join(repositoryRoot, name);
41
+ if (statSync(lockfilePath, { throwIfNoEntry: false })?.isFile() !== true)
42
+ continue;
43
+ digest.update(`${name}\0`).update(readFileSync(lockfilePath)).update('\0');
44
+ hasLockfile = true;
45
+ }
46
+ return hasLockfile ? digest.digest('hex').slice(0, HASH_LENGTH) : NO_LOCKFILE_MARKER;
47
+ }
48
+ function isCorruptCacheFile(location) {
49
+ let content;
50
+ try {
51
+ content = readFileSync(location, 'utf8');
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ try {
57
+ JSON.parse(content);
58
+ return false;
59
+ }
60
+ catch {
61
+ return true;
62
+ }
63
+ }
64
+ function pruneOutdatedCacheFiles(cacheDirectory, lockfileHash) {
65
+ for (const name of readdirSync(cacheDirectory)) {
66
+ const match = CACHE_FILE_PATTERN.exec(name);
67
+ if (match !== null && match[2] !== lockfileHash) {
68
+ rmSync(path.join(cacheDirectory, name), { force: true });
69
+ }
70
+ }
71
+ }
@@ -1,9 +1,13 @@
1
1
  export type FormatMode = 'check' | 'write';
2
2
  export interface FormatTargets {
3
+ /** Listed paths that Prettier cannot be handed, such as symlinks and submodule gitlinks. */
4
+ excludedFiles: string[];
3
5
  /** Paths relative to the working directory, as `git ls-files` emits them. */
4
6
  files: string[];
5
7
  /** Absolute paths to every `.prettierignore` governing the repository, root-most first. */
6
8
  ignorePaths: string[];
9
+ /** The repository's top-level directory, as git reports it. */
10
+ repositoryRoot: string;
7
11
  /** Listed paths that this process is denied access to, each with the errno code that its stat raised. */
8
12
  unreadableFiles: UnreadableFile[];
9
13
  }
@@ -20,7 +24,8 @@ export type ResolveTargetsResult = {
20
24
  };
21
25
  /**
22
26
  * Formats or checks the files that git reports for `cwd`, and returns the exit code to report. A repository
23
- * without a Prettier config of its own is formatted with the house config.
27
+ * without a Prettier config of its own is formatted with the house config. The run uses Prettier's content-keyed
28
+ * cache unless `--no-cache` or `NMR_NO_CACHE=1` bypasses it.
24
29
  *
25
30
  * Trailing arguments are git pathspecs, not Prettier flags; an unrecognized option is rejected rather
26
31
  * than passed along, since git would read it as a pathspec matching nothing and the run would report
@@ -39,13 +44,15 @@ export declare function runFmt(argv: string[], cwd?: string): Promise<number>;
39
44
  * `pathspecs` narrow the selection and are passed to git verbatim: They have git pathspec semantics rather
40
45
  * than shell glob semantics.
41
46
  *
42
- * A listed path that this process is denied access to is set aside in `unreadableFiles`; any other stat failure
43
- * fails the resolution.
47
+ * A listed path that Prettier cannot be handed is set aside in `excludedFiles`, and one that this process is denied
48
+ * access to in `unreadableFiles`; any other stat failure fails the resolution.
44
49
  */
45
50
  export declare function resolveFormatTargets(cwd: string, pathspecs?: string[]): ResolveTargetsResult;
46
51
  /** @internal */
47
52
  export interface RunPrettierOptions {
48
53
  cliPath: string;
54
+ /** Absolute path of the cache file to pass to Prettier; the run is uncached when it is absent. */
55
+ cacheLocation?: string | undefined;
49
56
  /** Passed as `--config`, which replaces Prettier's own config discovery. */
50
57
  configPath?: string | undefined;
51
58
  mode: FormatMode;
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { hasErrnoCode, reportError } from '@williamthorsen/nmr-core';
9
9
  import { describeError } from '@williamthorsen/toolbelt.errors';
10
10
  import { isObject } from "../helpers/type-guards.js";
11
+ import { prepareFmtCache } from "./fmt-cache.js";
11
12
  const IGNORE_FILENAME = '.prettierignore';
12
13
  const LIST_FILES_ARGS = ['ls-files', '-z', '--cached', '--others', '--exclude-standard'];
13
14
  const IGNORE_FILE_PATHSPEC = `*${IGNORE_FILENAME}`;
@@ -15,7 +16,7 @@ const UNREADABLE_CODES = ['EACCES', 'EPERM'];
15
16
  const PRETTIER_PACKAGE = 'prettier';
16
17
  const DEFAULT_CONFIG_PATH = fileURLToPath(new URL(`../prettier-default-config${path.extname(fileURLToPath(import.meta.url))}`, import.meta.url));
17
18
  const ARGUMENT_BUDGET_BYTES = 100_000;
18
- const USAGE = 'Usage: nmr-fmt (--check | --write) [pathspec...]';
19
+ const USAGE = 'Usage: nmr-fmt (--check | --write) [--no-cache] [pathspec...]';
19
20
  const MODE_ARGS = {
20
21
  check: ['--check'],
21
22
  write: ['--list-different', '--write'],
@@ -32,15 +33,20 @@ export async function runFmt(argv, cwd = process.cwd()) {
32
33
  reportError(`nmr-fmt: ${resolvedTargets.error}`);
33
34
  return 1;
34
35
  }
35
- const { files, ignorePaths, unreadableFiles } = resolvedTargets.targets;
36
+ const { excludedFiles, files, ignorePaths, repositoryRoot, unreadableFiles } = resolvedTargets.targets;
36
37
  for (const { file, code } of unreadableFiles) {
37
38
  process.stderr.write(`nmr-fmt: skipped ${file}, which cannot be read (${code})\n`);
38
39
  }
39
40
  if (files.length === 0) {
40
- if (parsedArgs.pathspecs.length > 0) {
41
- reportError(`nmr-fmt: the pathspecs did not match any formattable file: ${parsedArgs.pathspecs.join(', ')}`);
41
+ if (parsedArgs.pathspecs.length === 0)
42
+ return 0;
43
+ if (excludedFiles.length === 0 && unreadableFiles.length === 0) {
44
+ reportError(`nmr-fmt: the pathspecs did not match any file that git lists: ${parsedArgs.pathspecs.join(', ')}`);
42
45
  return 1;
43
46
  }
47
+ if (excludedFiles.length > 0) {
48
+ process.stderr.write(`nmr-fmt: nothing to format; skipped paths that are not regular files: ${excludedFiles.join(', ')}\n`);
49
+ }
44
50
  return 0;
45
51
  }
46
52
  const cli = resolvePrettierCli(cwd);
@@ -53,7 +59,17 @@ export async function runFmt(argv, cwd = process.cwd()) {
53
59
  reportError(`nmr-fmt: ${repositoryConfig.error}`);
54
60
  return 1;
55
61
  }
62
+ const cache = prepareFmtCache({
63
+ cwd,
64
+ env: process.env,
65
+ repositoryRoot,
66
+ shouldBypassCache: parsedArgs.shouldBypassCache,
67
+ });
68
+ if (cache.kind === 'failed') {
69
+ process.stderr.write(`nmr-fmt: running without the Prettier cache: ${cache.reason}\n`);
70
+ }
56
71
  return runPrettier({
72
+ cacheLocation: cache.kind === 'cached' ? cache.location : undefined,
57
73
  cliPath: cli.cliPath,
58
74
  configPath: repositoryConfig.found ? undefined : DEFAULT_CONFIG_PATH,
59
75
  mode: parsedArgs.mode,
@@ -73,6 +89,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
73
89
  const ignoreFiles = runGit([...LIST_FILES_ARGS, '--', IGNORE_FILE_PATHSPEC], repositoryRoot);
74
90
  if (!ignoreFiles.ok)
75
91
  return ignoreFiles;
92
+ const excludedFiles = [];
76
93
  const files = [];
77
94
  const unreadableFiles = [];
78
95
  const listedPaths = dedupe(splitNulSeparated(listedFiles.stdout));
@@ -80,6 +97,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
80
97
  const status = classifyListedFile(cwd, file);
81
98
  switch (status.kind) {
82
99
  case 'excluded':
100
+ excludedFiles.push(file);
83
101
  break;
84
102
  case 'failed':
85
103
  return { ok: false, error: status.error };
@@ -97,8 +115,10 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
97
115
  return {
98
116
  ok: true,
99
117
  targets: {
118
+ excludedFiles: excludedFiles.toSorted(),
100
119
  files: files.toSorted(),
101
120
  ignorePaths: dedupe([path.join(repositoryRoot, IGNORE_FILENAME), ...discoveredIgnorePaths]),
121
+ repositoryRoot,
102
122
  unreadableFiles,
103
123
  },
104
124
  };
@@ -117,8 +137,13 @@ function classifyListedFile(cwd, file) {
117
137
  }
118
138
  function parseFmtArgs(argv) {
119
139
  let mode;
140
+ let shouldBypassCache = false;
120
141
  const pathspecs = [];
121
142
  for (const arg of argv) {
143
+ if (arg === '--no-cache') {
144
+ shouldBypassCache = true;
145
+ continue;
146
+ }
122
147
  if (arg === '--check' || arg === '--write') {
123
148
  const requestedMode = arg === '--check' ? 'check' : 'write';
124
149
  if (mode !== undefined && mode !== requestedMode) {
@@ -135,7 +160,7 @@ function parseFmtArgs(argv) {
135
160
  if (mode === undefined) {
136
161
  return { ok: false, error: 'Pass --check or --write.' };
137
162
  }
138
- return { ok: true, mode, pathspecs };
163
+ return { ok: true, mode, pathspecs, shouldBypassCache };
139
164
  }
140
165
  function resolvePrettierCli(cwd) {
141
166
  const missingResult = {
@@ -172,12 +197,15 @@ async function findRepositoryConfig(manifestPath, cwd) {
172
197
  }
173
198
  }
174
199
  export function runPrettier(options) {
175
- const { cliPath, configPath, mode, files, ignorePaths, cwd, budgetBytes = ARGUMENT_BUDGET_BYTES } = options;
200
+ const { cacheLocation, cliPath, configPath, mode, files, ignorePaths, cwd, budgetBytes = ARGUMENT_BUDGET_BYTES, } = options;
176
201
  const prettierArgs = [
177
202
  cliPath,
178
203
  ...(configPath === undefined ? [] : ['--config', configPath]),
179
204
  '--ignore-unknown',
180
205
  ...ignorePaths.flatMap((ignorePath) => ['--ignore-path', ignorePath]),
206
+ ...(cacheLocation === undefined
207
+ ? []
208
+ : ['--cache', '--cache-strategy', 'content', '--cache-location', cacheLocation]),
181
209
  ...MODE_ARGS[mode],
182
210
  ];
183
211
  let firstFailureStatus = 0;