@williamthorsen/nmr 0.44.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.
@@ -5,6 +5,7 @@
5
5
  "bundledDependencies": {
6
6
  "@williamthorsen/toolbelt.errors": "0.8.3",
7
7
  "@williamthorsen/toolbelt.filesystem": "0.9.3",
8
+ "@williamthorsen/toolbelt.packaging": "0.6.3",
8
9
  "@williamthorsen/toolbelt.terminal": "0.2.1",
9
10
  "ansi-regex": "6.4.0",
10
11
  "ansi-styles": "6.2.3",
@@ -29,11 +30,6 @@
29
30
  "kind": "module",
30
31
  "path": "../../nmr-core/dist/esm/cache-store.js"
31
32
  },
32
- {
33
- "hash": "a5f44396",
34
- "kind": "module",
35
- "path": "../../nmr-core/dist/esm/findPackageRoot.js"
36
- },
37
33
  {
38
34
  "hash": "54384dca",
39
35
  "kind": "module",
@@ -55,7 +51,7 @@
55
51
  "path": "../../nmr-core/dist/esm/helpers/workspace-patterns.js"
56
52
  },
57
53
  {
58
- "hash": "1467293d",
54
+ "hash": "191f7c24",
59
55
  "kind": "module",
60
56
  "path": "../../nmr-core/dist/esm/index.js"
61
57
  },
@@ -64,11 +60,6 @@
64
60
  "kind": "module",
65
61
  "path": "../../nmr-core/dist/esm/parseArgs.js"
66
62
  },
67
- {
68
- "hash": "a7efc38f",
69
- "kind": "module",
70
- "path": "../../nmr-core/dist/esm/readPackageVersion.js"
71
- },
72
63
  {
73
64
  "hash": "14547f78",
74
65
  "kind": "module",
@@ -95,7 +86,7 @@
95
86
  "path": "kits/default.ts"
96
87
  },
97
88
  {
98
- "hash": "747d941f",
89
+ "hash": "22efcac6",
99
90
  "kind": "inline",
100
91
  "path": "../package.json",
101
92
  "paths": [
@@ -103,7 +94,7 @@
103
94
  ]
104
95
  },
105
96
  {
106
- "hash": "b23f5a52",
97
+ "hash": "7e066d18",
107
98
  "kind": "module",
108
99
  "path": "../src/config.ts"
109
100
  },
@@ -122,6 +113,11 @@
122
113
  "kind": "module",
123
114
  "path": "../src/helpers/config-path.ts"
124
115
  },
116
+ {
117
+ "hash": "a663548a",
118
+ "kind": "module",
119
+ "path": "../src/helpers/escapeControlCharacters.ts"
120
+ },
125
121
  {
126
122
  "hash": "47249ca4",
127
123
  "kind": "module",
@@ -153,17 +149,17 @@
153
149
  "path": "../src/resolve-scripts.ts"
154
150
  },
155
151
  {
156
- "hash": "6ae507a5",
152
+ "hash": "15247f0e",
157
153
  "kind": "module",
158
154
  "path": "../src/resolver.ts"
159
155
  },
160
156
  {
161
- "hash": "c4b31275",
157
+ "hash": "ed585ab6",
162
158
  "kind": "module",
163
159
  "path": "../src/steps.ts"
164
160
  },
165
161
  {
166
- "hash": "19dec003",
162
+ "hash": "d1e8e302",
167
163
  "kind": "module",
168
164
  "path": "../src/tiers.ts"
169
165
  },
@@ -193,7 +189,7 @@
193
189
  "readyupVersion": "0.40.0",
194
190
  "source": "kits/default.ts",
195
191
  "sourceHash": "aaeb1a84",
196
- "targetHash": "25b7edfb"
192
+ "targetHash": "a8b07f51"
197
193
  }
198
194
  ]
199
195
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
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
+
5
29
  ## 0.44.0 — 2026-10-11
6
30
 
7
31
  ### 🎉 Features
package/README.md CHANGED
@@ -5,28 +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.44.0 (2026-10-11)
8
+ ## Release notes — v0.45.0 (2026-10-12)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - 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)
13
- - 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)
14
- - 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)
15
- - 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)
16
-
17
- ### 🪦 Removed
18
-
19
- - 🚨 **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)
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)
20
13
 
21
14
  ### 🐛 Bug fixes
22
15
 
23
- - 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)
24
- - 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)
25
-
26
- ### ⚡ Performance
27
-
28
- - 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)
29
- - 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)
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)
30
23
  <!-- /section:release-notes -->
31
24
 
32
25
  ## Installation
@@ -124,7 +117,7 @@ Scripts resolve through three tiers. Higher tiers override lower ones:
124
117
  2. **Repo-wide config**: Additions and overrides in `.config/nmr.config.ts`
125
118
  3. **Per-package overrides**: Scripts in a package's `package.json`
126
119
 
127
- [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.
128
121
 
129
122
  ## Pre and post hooks
130
123
 
@@ -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.
@@ -1,5 +1,7 @@
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. */
@@ -42,8 +44,8 @@ export declare function runFmt(argv: string[], cwd?: string): Promise<number>;
42
44
  * `pathspecs` narrow the selection and are passed to git verbatim: They have git pathspec semantics rather
43
45
  * than shell glob semantics.
44
46
  *
45
- * A listed path that this process is denied access to is set aside in `unreadableFiles`; any other stat failure
46
- * 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.
47
49
  */
48
50
  export declare function resolveFormatTargets(cwd: string, pathspecs?: string[]): ResolveTargetsResult;
49
51
  /** @internal */
@@ -33,15 +33,20 @@ export async function runFmt(argv, cwd = process.cwd()) {
33
33
  reportError(`nmr-fmt: ${resolvedTargets.error}`);
34
34
  return 1;
35
35
  }
36
- const { files, ignorePaths, repositoryRoot, unreadableFiles } = resolvedTargets.targets;
36
+ const { excludedFiles, files, ignorePaths, repositoryRoot, unreadableFiles } = resolvedTargets.targets;
37
37
  for (const { file, code } of unreadableFiles) {
38
38
  process.stderr.write(`nmr-fmt: skipped ${file}, which cannot be read (${code})\n`);
39
39
  }
40
40
  if (files.length === 0) {
41
- if (parsedArgs.pathspecs.length > 0) {
42
- 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(', ')}`);
43
45
  return 1;
44
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
+ }
45
50
  return 0;
46
51
  }
47
52
  const cli = resolvePrettierCli(cwd);
@@ -84,6 +89,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
84
89
  const ignoreFiles = runGit([...LIST_FILES_ARGS, '--', IGNORE_FILE_PATHSPEC], repositoryRoot);
85
90
  if (!ignoreFiles.ok)
86
91
  return ignoreFiles;
92
+ const excludedFiles = [];
87
93
  const files = [];
88
94
  const unreadableFiles = [];
89
95
  const listedPaths = dedupe(splitNulSeparated(listedFiles.stdout));
@@ -91,6 +97,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
91
97
  const status = classifyListedFile(cwd, file);
92
98
  switch (status.kind) {
93
99
  case 'excluded':
100
+ excludedFiles.push(file);
94
101
  break;
95
102
  case 'failed':
96
103
  return { ok: false, error: status.error };
@@ -108,6 +115,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
108
115
  return {
109
116
  ok: true,
110
117
  targets: {
118
+ excludedFiles: excludedFiles.toSorted(),
111
119
  files: files.toSorted(),
112
120
  ignorePaths: dedupe([path.join(repositoryRoot, IGNORE_FILENAME), ...discoveredIgnorePaths]),
113
121
  repositoryRoot,
@@ -9,11 +9,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync } from 'node:fs';
10
10
  import { pathToFileURL } from 'node:url';
11
11
  import { resolveConfigPath } from "./helpers/config-path.js";
12
+ import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
12
13
  import { findClosestName } from "./helpers/findClosestName.js";
13
14
  import { isHookName } from "./helpers/hook-name.js";
14
15
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
15
- import { buildRootRegistry, buildWorkspaceRegistry, readPackageJsonScripts } from "./resolver.js";
16
- import { findUnexpressibleToken } from "./steps.js";
16
+ import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
17
+ import { buildRootRegistry, buildWorkspaceRegistry, describeScript, readPackageJsonScripts } from "./resolver.js";
18
+ import { findUnexpressibleToken, readSelfReference, readStepListSelfReference, } from "./steps.js";
17
19
  import { UserError } from "./UserError.js";
18
20
  import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
19
21
  import { getWorkspacePackageDirs, isMonorepoRoot } from "./workspace.js";
@@ -35,6 +37,10 @@ const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommand
35
37
  const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
36
38
  const RECOGNIZED_SELF_HOSTED_RUNNER_KEYS = ['exempt'];
37
39
  const RECOGNIZED_STEP_KEYS = ['run', 'shouldDeclineArguments', 'shouldRunInBackground'];
40
+ const SCRIPT_FIELDS = [
41
+ { field: 'rootScripts', scope: 'root' },
42
+ { field: 'workspaceScripts', scope: 'workspaceRegistry' },
43
+ ];
38
44
  const RETIRED_STEP_KEYS = new Map([['declinesArgs', 'shouldDeclineArguments']]);
39
45
  function isScriptRecord(value) {
40
46
  if (!isObject(value))
@@ -286,7 +292,9 @@ export async function loadConfig(baseDir) {
286
292
  }
287
293
  export async function loadRootConfig(monorepoRoot) {
288
294
  const config = await loadConfig(monorepoRoot);
289
- assertTierKeys(config, CONFIG_TIERS.root, resolveConfigPath(monorepoRoot));
295
+ const configPath = resolveConfigPath(monorepoRoot);
296
+ assertTierKeys(config, CONFIG_TIERS.root, configPath);
297
+ assertNoSelfReferences(config, configPath, monorepoRoot);
290
298
  return config;
291
299
  }
292
300
  export async function loadWorkspaceConfig(packageDir) {
@@ -302,6 +310,30 @@ function assertNoRetiredKeys(value, retiredKeys, configPath, prefix) {
302
310
  throw new UserError(`Invalid nmr config at ${configPath}: \`${prefix}${key}\` was renamed to \`${prefix}${replacement}\`.`);
303
311
  }
304
312
  }
313
+ function assertNoSelfReferences(config, configPath, monorepoRoot) {
314
+ for (const { field, scope } of SCRIPT_FIELDS) {
315
+ const scripts = config[field] ?? {};
316
+ for (const [key, script] of Object.entries(scripts)) {
317
+ const selfReference = typeof script === 'string'
318
+ ? readSelfReference({ commandName: key, scope, script })
319
+ : readStepListSelfReference({
320
+ commandName: key,
321
+ elements: script.map((element) => (typeof element === 'string' ? element : element.run)),
322
+ scope,
323
+ });
324
+ if (selfReference === undefined)
325
+ continue;
326
+ const defaults = field === 'rootScripts' ? getDefaultRootScripts(monorepoRoot) : getDefaultWorkspaceScripts();
327
+ const remedy = formatSelfReferenceRemedy({
328
+ isDefault: !isHookName(key) && Object.hasOwn(defaults, key),
329
+ key,
330
+ selfReference,
331
+ });
332
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${field}.${key}\` re-invokes \`${key}\` ` +
333
+ `(\`${escapeControlCharacters(describeScript(script))}\`), which would re-enter nmr without bound. ${remedy}`);
334
+ }
335
+ }
336
+ }
305
337
  function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = '') {
306
338
  const unrecognizedKeys = Object.keys(value).filter((key) => !recognizedKeys.includes(key));
307
339
  if (unrecognizedKeys.length === 0) {
@@ -324,3 +356,14 @@ function formatKeyList(keys, prefix) {
324
356
  .map((key) => `\`${prefix}${key}\``)
325
357
  .join(', ');
326
358
  }
359
+ function formatSelfReferenceRemedy(options) {
360
+ const { isDefault, key, selfReference } = options;
361
+ if (isDefault) {
362
+ return selfReference === 'sole'
363
+ ? `Delete the entry: nmr's own \`${key}\` then runs.`
364
+ : `Delete the entry and move the steps that it adds to a \`${key}:pre\` or \`${key}:post\` script.`;
365
+ }
366
+ return selfReference === 'sole'
367
+ ? 'Delete the entry.'
368
+ : `Delete the re-invocation: \`${key}\` runs the steps beside it.`;
369
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Carries the entries that a run is inside, from the outermost inward, down to every process that it spawns. An
3
+ * entry that finds itself already on the chain would re-enter itself without bound.
4
+ */
5
+ export declare const ENTRY_CHAIN_ENV_VAR = "NMR_ENTRY_CHAIN";
6
+ /** A command that an invocation resolved, and the directory whose registry it resolved the command from. */
7
+ export interface ChainEntry {
8
+ command: string;
9
+ dir: string;
10
+ }
11
+ /**
12
+ * Admits an invocation onto the chain that it inherited, returning the environment that its steps inherit in turn.
13
+ * Throws a `UserError` when an ancestor in the run is already running the same command from the same directory, or
14
+ * when the inherited chain cannot be read.
15
+ *
16
+ * Records the directory's real path, so that a symlinked checkout or a `/private` prefix seen by one process and not
17
+ * another cannot hide a repeat.
18
+ */
19
+ export declare function enterChain(options: {
20
+ anchorDir: string;
21
+ command: string;
22
+ env: NodeJS.ProcessEnv;
23
+ monorepoRoot: string;
24
+ }): NodeJS.ProcessEnv;
@@ -0,0 +1,57 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isObject } from "./helpers/type-guards.js";
4
+ import { UserError } from "./UserError.js";
5
+ export const ENTRY_CHAIN_ENV_VAR = 'NMR_ENTRY_CHAIN';
6
+ export function enterChain(options) {
7
+ const { anchorDir, command, env, monorepoRoot } = options;
8
+ const chain = readEntryChain(env[ENTRY_CHAIN_ENV_VAR]);
9
+ if (chain === undefined) {
10
+ throw new UserError(`${ENTRY_CHAIN_ENV_VAR} is not a chain that nmr wrote; unset it. nmr passes it to the processes that it starts.`);
11
+ }
12
+ const entry = { command, dir: realpathSync(anchorDir) };
13
+ const extendedChain = [...chain, entry];
14
+ if (chain.some((ancestor) => ancestor.command === entry.command && ancestor.dir === entry.dir)) {
15
+ throw new UserError(formatReentryError(extendedChain, realpathSync(monorepoRoot)));
16
+ }
17
+ return { ...env, [ENTRY_CHAIN_ENV_VAR]: JSON.stringify(extendedChain) };
18
+ }
19
+ function describeDir(dir, monorepoRoot) {
20
+ const relativeDir = path.relative(monorepoRoot, dir);
21
+ if (relativeDir === '') {
22
+ return 'root';
23
+ }
24
+ return relativeDir.startsWith('..') || path.isAbsolute(relativeDir) ? dir : relativeDir;
25
+ }
26
+ function formatReentryError(chain, monorepoRoot) {
27
+ const repeat = chain.at(-1);
28
+ if (repeat === undefined) {
29
+ throw new Error('A re-entry error needs the entry that repeats.');
30
+ }
31
+ const repeatDir = describeDir(repeat.dir, monorepoRoot);
32
+ const lines = chain.map((entry) => {
33
+ const isRepeat = entry.command === repeat.command && entry.dir === repeat.dir;
34
+ return ` ${entry.command} in ${describeDir(entry.dir, monorepoRoot)}${isRepeat ? ' (repeat)' : ''}`;
35
+ });
36
+ return [
37
+ `nmr re-entered \`${repeat.command}\` in ${repeatDir}, which this run is already running there, ` +
38
+ 'so the run would never end. The entries that this run has entered, outermost first:',
39
+ ...lines,
40
+ ].join('\n');
41
+ }
42
+ function isChainEntry(value) {
43
+ return isObject(value) && typeof value['command'] === 'string' && typeof value['dir'] === 'string';
44
+ }
45
+ function readEntryChain(value) {
46
+ if (value === undefined || value === '') {
47
+ return [];
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(value);
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ return Array.isArray(parsed) && parsed.every(isChainEntry) ? parsed : undefined;
57
+ }
@@ -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" | "package" | "catalog" | "noop" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
8
+ export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "package" | "clean" | "catalog" | "noop" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
9
9
  export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Renders a declaration's text as the file stores it, so that a value written across lines is quoted on one line.
3
+ *
4
+ * A diagnostic names a declaration that the reader has to edit, and a JSON string contains an escape sequence where
5
+ * a shell would read a control character. The rendered escape keeps the diagnostic on one line, and it is the text
6
+ * for which the reader will search the file.
7
+ *
8
+ * Apply it only where the text is quoted: A comparison against the declaration reads the unescaped text.
9
+ */
10
+ export declare function escapeControlCharacters(text: string): string;
@@ -0,0 +1,12 @@
1
+ const NAMED_ESCAPES = new Map([
2
+ ['\n', String.raw `\n`],
3
+ ['\r', String.raw `\r`],
4
+ ['\t', String.raw `\t`],
5
+ ]);
6
+ export function escapeControlCharacters(text) {
7
+ let escapedText = text;
8
+ for (const [char, escape] of NAMED_ESCAPES) {
9
+ escapedText = escapedText.split(char).join(escape);
10
+ }
11
+ return escapedText;
12
+ }
@@ -108,12 +108,12 @@ export function findChainedSelfReference(packageDir, commandName) {
108
108
  if (script === undefined) {
109
109
  return undefined;
110
110
  }
111
- return readSelfReference({ anchoredAtRoot: isMonorepoRoot(packageDir), commandName, script }) === 'chained'
111
+ return readSelfReference({ commandName, scope: readPackageScope(packageDir), script }) === 'chained'
112
112
  ? script
113
113
  : undefined;
114
114
  }
115
115
  export function isSelfReferential(script, commandName, packageDir) {
116
- return readSelfReference({ anchoredAtRoot: isMonorepoRoot(packageDir), commandName, script }) !== undefined;
116
+ return readSelfReference({ commandName, scope: readPackageScope(packageDir), script }) !== undefined;
117
117
  }
118
118
  export function resolveScript(commandName, registry, packageDir, isWorkspaceRoot) {
119
119
  if (packageDir) {
@@ -137,3 +137,6 @@ export function resolveScript(commandName, registry, packageDir, isWorkspaceRoot
137
137
  }
138
138
  return { origin: { tier: 'registry', key: commandName }, steps: expandScript(registryEntry, isWorkspaceRoot) };
139
139
  }
140
+ function readPackageScope(packageDir) {
141
+ return isMonorepoRoot(packageDir) ? 'root' : 'package';
142
+ }