@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.
- package/.meta/changelog.json +168 -0
- package/.readyup/kits/default.js +1331 -136
- package/.readyup/manifest.json +148 -9
- package/CHANGELOG.md +51 -0
- package/README.md +17 -3
- package/agents/guidance/rulebooks/nmr.md +1 -1
- package/dist/esm/commands/fmt-cache.d.ts +26 -0
- package/dist/esm/commands/fmt-cache.js +71 -0
- package/dist/esm/commands/fmt.d.ts +10 -3
- package/dist/esm/commands/fmt.js +34 -6
- package/dist/esm/config.js +74 -9
- package/dist/esm/default-scripts.d.ts +10 -1
- package/dist/esm/default-scripts.js +45 -39
- package/dist/esm/defineConfig.d.ts +1 -1
- package/dist/esm/entry-chain.d.ts +24 -0
- package/dist/esm/entry-chain.js +57 -0
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/help.d.ts +1 -1
- package/dist/esm/help.js +3 -3
- package/dist/esm/helpers/createOutputRecorder.d.ts +17 -0
- package/dist/esm/helpers/createOutputRecorder.js +22 -0
- package/dist/esm/helpers/escapeControlCharacters.d.ts +10 -0
- package/dist/esm/helpers/escapeControlCharacters.js +12 -0
- package/dist/esm/resolve-scripts.d.ts +5 -3
- package/dist/esm/resolve-scripts.js +7 -3
- package/dist/esm/resolver.d.ts +5 -4
- package/dist/esm/resolver.js +16 -6
- package/dist/esm/runCli.js +146 -101
- package/dist/esm/runner.d.ts +7 -0
- package/dist/esm/runner.js +105 -15
- package/dist/esm/steps.d.ts +50 -18
- package/dist/esm/steps.js +82 -13
- package/dist/esm/taze.d.ts +5 -2
- package/dist/esm/taze.js +4 -1
- package/dist/esm/tiers.d.ts +5 -4
- package/dist/esm/types.d.ts +10 -0
- package/dist/esm/vitest-git-isolation.js +3 -1
- package/dist/esm/vitest.js +9 -5
- package/dist/esm/workspace-globs.d.ts +5 -0
- package/dist/esm/workspace-globs.js +8 -0
- package/docs/check-cache.md +2 -0
- package/docs/reporting.md +2 -0
- package/docs/scripts.md +36 -10
- package/docs/upgrades.md +1 -1
- package/docs/utilities.md +14 -2
- package/docs/vitest.md +20 -8
- package/package.json +7 -7
package/.readyup/manifest.json
CHANGED
|
@@ -3,21 +3,90 @@
|
|
|
3
3
|
"kits": [
|
|
4
4
|
{
|
|
5
5
|
"bundledDependencies": {
|
|
6
|
-
"@williamthorsen/toolbelt.errors": "0.8.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
53
|
-
"targetHash": "
|
|
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.
|
|
8
|
+
## Release notes — v0.45.0 (2026-10-12)
|
|
9
9
|
|
|
10
10
|
### 🎉 Features
|
|
11
11
|
|
|
12
|
-
-
|
|
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
|
|
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
|
|
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;
|
package/dist/esm/commands/fmt.js
CHANGED
|
@@ -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
|
|
41
|
-
|
|
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;
|