@williamthorsen/nmr 0.52.0 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,80 @@
1
1
  [
2
+ {
3
+ "version": "0.53.1",
4
+ "date": "2026-10-28",
5
+ "sections": [
6
+ {
7
+ "title": "♻️ Refactoring",
8
+ "audience": "dev",
9
+ "items": [
10
+ {
11
+ "description": "Lead release-kit's function names with a transitive verb (#1137)",
12
+ "body": "Renames the functions in `release-kit`'s source and tests so that each name starts with a transitive verb that states its action, such as `releasePrepareTargets` to `prepareReleaseTargets`, `releaseWorkflow` to `renderReleaseWorkflow`, and `topologicalSort` to `sortTopologically`. The exports of `.` and `./config` stay the same.",
13
+ "hash": "a84d9ce0652754a417b85c3f9cd14a6e400c7b51"
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ },
19
+ {
20
+ "version": "0.53.0",
21
+ "date": "2026-10-27",
22
+ "sections": [
23
+ {
24
+ "title": "🎉 Features",
25
+ "audience": "all",
26
+ "items": [
27
+ {
28
+ "description": "Adds the time saved to the line that `nmr-compile` prints when it skips an unchanged package, as in `Skipping build, saved ~2.1s.`, when the skipped emit took at least a second. (#1112)",
29
+ "hash": "ead0549c50d886ef371cb03d979883e027b051c2",
30
+ "entry": 1
31
+ },
32
+ {
33
+ "description": "Adds `build skipped as unchanged` to the verdict line of a compile that hits the build cache, followed by the saved time when the recorded emit took at least a second, which nmr prints in a quiet run as well. (#1113)",
34
+ "hash": "02cbf9688061958d23230901120fc374e56ddfe7",
35
+ "entry": 1
36
+ },
37
+ {
38
+ "description": "Adds a `stated` field to the `--json` record of a passing command that stated its own outcome, such as `{\"kind\":\"build-unchanged\",\"savedMs\":2100}`, which records the saving whatever its size. (#1113)",
39
+ "hash": "02cbf9688061958d23230901120fc374e56ddfe7",
40
+ "entry": 2
41
+ },
42
+ {
43
+ "description": "Removes the 7-day `maturityPeriod` from the shared policy that `defineConfig` in `@williamthorsen/nmr/taze` supplies, so that `nmr upgrade` waits for the `minimumReleaseAge` set in `pnpm-workspace.yaml` (or pnpm's default) and no longer withholds releases that pnpm already resolves for other packages. (#1129)",
44
+ "hash": "e471e998babeb45ef0ca4db64a02fd0fd490702d",
45
+ "entry": 1
46
+ }
47
+ ]
48
+ },
49
+ {
50
+ "title": "🐛 Bug fixes",
51
+ "audience": "all",
52
+ "items": [
53
+ {
54
+ "description": "Fixes the issue that nmr reported a short or negative duration for a command's run, a delegated run, or a remote lock wait when the system clock was adjusted during that span. (#1112)",
55
+ "hash": "ead0549c50d886ef371cb03d979883e027b051c2",
56
+ "entry": 2
57
+ },
58
+ {
59
+ "description": "Fixes the issue that `nmr-fmt` never formatted a file whose name starts with `-` and rejected `--` as an unknown option, by making `nmr-fmt` accept `--` before its pathspecs and pass `--` to Prettier before the file list. (#1130)",
60
+ "hash": "d7cd93b2309c0668b7bcb82bf52bad5fc0a5b742",
61
+ "entry": 1
62
+ }
63
+ ]
64
+ },
65
+ {
66
+ "title": "♻️ Refactoring",
67
+ "audience": "dev",
68
+ "items": [
69
+ {
70
+ "description": "Apply the naming conventions to nmr-core's identifiers (#1125)",
71
+ "body": "Renames the identifiers in nmr-core to follow the naming conventions, including the exported option keys of `updateManagedFile` and `writeFileWithCheck`, which change from `dryRun` and `overwrite` to `isDryRun` and `shouldOverwrite`, and the constant `GIT_OUTPUT_LIMIT`, which becomes `GIT_OUTPUT_LIMIT_BYTES`. Updates the call sites in nmr, release-kit, and v11y-check to the new names.",
72
+ "hash": "2cb60f628512ed6ec2273a219a7b6c71aa35b1bf"
73
+ }
74
+ ]
75
+ }
76
+ ]
77
+ },
2
78
  {
3
79
  "version": "0.52.0",
4
80
  "date": "2026-10-24",
@@ -1,6 +1,6 @@
1
1
  /** @noformat -- @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.43.0";
3
+ export const __readyupVersion = "0.44.0";
4
4
  import { createRequire as __rdyCreateRequire } from 'node:module';
5
5
  const require = __rdyCreateRequire(import.meta.url);
6
6
 
@@ -7382,12 +7382,12 @@ import { isRecord, listTrackedFiles } from "readyup/check-utils";
7382
7382
  import { globSync } from "node:fs";
7383
7383
  import { basename, join, sep } from "node:path";
7384
7384
 
7385
- // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.4/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
7385
+ // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.9.0/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
7386
7386
  function isError(error) {
7387
7387
  return error instanceof Error || Object.prototype.toString.call(error) === "[object Error]";
7388
7388
  }
7389
7389
 
7390
- // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.4/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
7390
+ // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.9.0/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
7391
7391
  function describeError(error) {
7392
7392
  try {
7393
7393
  if (isError(error) && typeof error.message === "string" && error.message !== "") {
@@ -7732,12 +7732,12 @@ var import_yaml = __toESM(require_dist(), 1);
7732
7732
  // ../nmr-core/dist/esm/helpers/workspace-patterns.js
7733
7733
  import { globSync as globSync2 } from "node:fs";
7734
7734
  import path from "node:path";
7735
- var MANIFEST = "package.json";
7736
- var ALWAYS_EXCLUDED = ["**/node_modules/**"];
7735
+ var MANIFEST_FILENAME = "package.json";
7736
+ var ALWAYS_EXCLUDED_PATTERNS = ["**/node_modules/**"];
7737
7737
  function matchPackageDirs(monorepoRoot, includedPatterns, excludedPatterns) {
7738
7738
  const options = {
7739
7739
  cwd: monorepoRoot,
7740
- exclude: [...excludedPatterns, ...ALWAYS_EXCLUDED],
7740
+ exclude: [...excludedPatterns, ...ALWAYS_EXCLUDED_PATTERNS],
7741
7741
  followSymlinks: true
7742
7742
  };
7743
7743
  const matches = globSync2([...includedPatterns], options);
@@ -7758,34 +7758,34 @@ function splitWorkspacePatterns(patterns) {
7758
7758
  if (pattern.trim() === "")
7759
7759
  continue;
7760
7760
  const isNegated = pattern.startsWith("!");
7761
- const target = isNegated ? excludedPatterns : includedPatterns;
7762
- target.push(buildManifestPattern(isNegated ? pattern.slice(1) : pattern));
7761
+ const targetPatterns = isNegated ? excludedPatterns : includedPatterns;
7762
+ targetPatterns.push(buildManifestPattern(isNegated ? pattern.slice(1) : pattern));
7763
7763
  }
7764
7764
  return { excludedPatterns, includedPatterns };
7765
7765
  }
7766
7766
  function buildManifestPattern(pattern) {
7767
- return `${pattern.replace(/\/?$/, "")}/${MANIFEST}`;
7767
+ return `${pattern.replace(/\/?$/, "")}/${MANIFEST_FILENAME}`;
7768
7768
  }
7769
7769
 
7770
7770
  // ../nmr-core/dist/esm/workspace.js
7771
- var WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
7771
+ var WORKSPACE_MANIFEST_FILENAME = "pnpm-workspace.yaml";
7772
7772
  function isMonorepoRoot(dir) {
7773
- return existsSync(path2.join(dir, WORKSPACE_MANIFEST));
7773
+ return existsSync(path2.join(dir, WORKSPACE_MANIFEST_FILENAME));
7774
7774
  }
7775
7775
  function resolveWorkspace(monorepoRoot) {
7776
- const manifestRead = readWorkspaceManifest(monorepoRoot);
7777
- if (manifestRead.kind === "absent") {
7776
+ const manifestReadResult = readWorkspaceManifest(monorepoRoot);
7777
+ if (manifestReadResult.kind === "absent") {
7778
7778
  return { kind: "not-a-workspace" };
7779
7779
  }
7780
- if (manifestRead.kind === "unreadable") {
7780
+ if (manifestReadResult.kind === "unreadable") {
7781
7781
  return { cause: "unreadable-manifest", kind: "empty", patterns: [] };
7782
7782
  }
7783
- const packagesRead = readDeclaredPackages(manifestRead.value);
7784
- if (packagesRead.kind !== "patterns") {
7785
- const cause = packagesRead.kind === "absent" ? "no-packages-list" : "unreadable-packages";
7783
+ const packagesReadResult = readDeclaredPackages(manifestReadResult.value);
7784
+ if (packagesReadResult.kind !== "patterns") {
7785
+ const cause = packagesReadResult.kind === "absent" ? "no-packages-list" : "unreadable-packages";
7786
7786
  return { cause, kind: "empty", patterns: [] };
7787
7787
  }
7788
- const { patterns } = packagesRead;
7788
+ const { patterns } = packagesReadResult;
7789
7789
  const packageDirs = resolvePackageDirs(monorepoRoot, patterns);
7790
7790
  if (packageDirs.length > 0) {
7791
7791
  return { kind: "packages", packageDirs, patterns };
@@ -7818,12 +7818,12 @@ function isObject(value) {
7818
7818
  return typeof value === "object" && value !== null && !Array.isArray(value);
7819
7819
  }
7820
7820
  function readWorkspaceManifest(monorepoRoot) {
7821
- const workspaceFile = path2.join(monorepoRoot, WORKSPACE_MANIFEST);
7822
- if (!existsSync(workspaceFile)) {
7821
+ const workspaceManifestPath = path2.join(monorepoRoot, WORKSPACE_MANIFEST_FILENAME);
7822
+ if (!existsSync(workspaceManifestPath)) {
7823
7823
  return { kind: "absent" };
7824
7824
  }
7825
7825
  try {
7826
- return { kind: "parsed", value: (0, import_yaml.parse)(readFileSync(workspaceFile, "utf8")) };
7826
+ return { kind: "parsed", value: (0, import_yaml.parse)(readFileSync(workspaceManifestPath, "utf8")) };
7827
7827
  } catch {
7828
7828
  return { kind: "unreadable" };
7829
7829
  }
@@ -7838,11 +7838,11 @@ var UserError = class extends Error {
7838
7838
  };
7839
7839
 
7840
7840
  // src/workspace.ts
7841
- var WORKSPACE_MANIFEST2 = "pnpm-workspace.yaml";
7841
+ var WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
7842
7842
  function getWorkspacePackageDirs(monorepoRoot) {
7843
7843
  const resolution = resolveWorkspace(monorepoRoot);
7844
7844
  if (resolution.kind === "not-a-workspace") {
7845
- throw new UserError(`Not a monorepo root: no ${WORKSPACE_MANIFEST2} in ${monorepoRoot}`);
7845
+ throw new UserError(`Not a monorepo root: no ${WORKSPACE_MANIFEST} in ${monorepoRoot}`);
7846
7846
  }
7847
7847
  return resolution.kind === "packages" ? resolution.packageDirs : [];
7848
7848
  }
@@ -9190,7 +9190,7 @@ function hasSupportedStrictLintVersion() {
9190
9190
  });
9191
9191
  }
9192
9192
  function getMinVersion() {
9193
- const pickedFields = { "version": "0.52.0" };
9193
+ const pickedFields = { "version": "0.53.1" };
9194
9194
  if (typeof pickedFields["version"] !== "string") {
9195
9195
  throw new TypeError("nmr/package.json: 'version' must be a string");
9196
9196
  }
@@ -3,10 +3,13 @@
3
3
  "kits": [
4
4
  {
5
5
  "bundledDependencies": {
6
- "@williamthorsen/toolbelt.errors": "0.8.4",
7
- "@williamthorsen/toolbelt.filesystem": "0.9.4",
8
- "@williamthorsen/toolbelt.packaging": "0.6.4",
9
- "@williamthorsen/toolbelt.terminal": "0.2.1",
6
+ "@williamthorsen/toolbelt.datetime": "4.1.0",
7
+ "@williamthorsen/toolbelt.errors": "0.9.0",
8
+ "@williamthorsen/toolbelt.filesystem": "0.10.0",
9
+ "@williamthorsen/toolbelt.numbers": "7.3.0",
10
+ "@williamthorsen/toolbelt.objects": "4.4.0",
11
+ "@williamthorsen/toolbelt.packaging": "0.7.0",
12
+ "@williamthorsen/toolbelt.terminal": "0.3.0",
10
13
  "ansi-regex": "6.4.0",
11
14
  "ansi-styles": "6.2.3",
12
15
  "cli-truncate": "6.1.1",
@@ -24,7 +27,7 @@
24
27
  "checklist": "nmr",
25
28
  "checks": [
26
29
  "@williamthorsen/nmr in devDependencies",
27
- "@williamthorsen/nmr >= 0.52.0",
30
+ "@williamthorsen/nmr >= 0.53.1",
28
31
  "pnpm-workspace.yaml exists",
29
32
  "catalogued dependencies use catalog:",
30
33
  "package.json has packageManager field",
@@ -71,22 +74,22 @@
71
74
  "esbuildVersion": "0.28.2",
72
75
  "inputs": [
73
76
  {
74
- "hash": "aed52073",
77
+ "hash": "bf29a9a3",
75
78
  "kind": "module",
76
79
  "path": "../../nmr-core/dist/esm/cache-store.js"
77
80
  },
78
81
  {
79
- "hash": "81f1406b",
82
+ "hash": "bc0baa4a",
80
83
  "kind": "module",
81
84
  "path": "../../nmr-core/dist/esm/collectChangedPaths.js"
82
85
  },
83
86
  {
84
- "hash": "200f69f1",
87
+ "hash": "70d519e7",
85
88
  "kind": "module",
86
89
  "path": "../../nmr-core/dist/esm/gitObjects.js"
87
90
  },
88
91
  {
89
- "hash": "54384dca",
92
+ "hash": "14216f5f",
90
93
  "kind": "module",
91
94
  "path": "../../nmr-core/dist/esm/gitOutputLimit.js"
92
95
  },
@@ -96,47 +99,52 @@
96
99
  "path": "../../nmr-core/dist/esm/hasErrnoCode.js"
97
100
  },
98
101
  {
99
- "hash": "1eb2b90c",
102
+ "hash": "0d7b3f7c",
100
103
  "kind": "module",
101
104
  "path": "../../nmr-core/dist/esm/hashWorkingTree.js"
102
105
  },
103
106
  {
104
- "hash": "41232d10",
107
+ "hash": "c1eae24e",
105
108
  "kind": "module",
106
109
  "path": "../../nmr-core/dist/esm/helpers/workspace-patterns.js"
107
110
  },
108
111
  {
109
- "hash": "191f7c24",
112
+ "hash": "dcd24627",
110
113
  "kind": "module",
111
114
  "path": "../../nmr-core/dist/esm/index.js"
112
115
  },
113
116
  {
114
- "hash": "89d3e3b4",
117
+ "hash": "a2053951",
115
118
  "kind": "module",
116
119
  "path": "../../nmr-core/dist/esm/parseArgs.js"
117
120
  },
118
121
  {
119
- "hash": "7915ba49",
122
+ "hash": "9104fb90",
120
123
  "kind": "module",
121
124
  "path": "../../nmr-core/dist/esm/runGit.js"
122
125
  },
123
126
  {
124
- "hash": "14547f78",
127
+ "hash": "46b44e18",
125
128
  "kind": "module",
126
129
  "path": "../../nmr-core/dist/esm/terminal.js"
127
130
  },
128
131
  {
129
- "hash": "3c395f31",
132
+ "hash": "85afaf3e",
133
+ "kind": "module",
134
+ "path": "../../nmr-core/dist/esm/timer.js"
135
+ },
136
+ {
137
+ "hash": "5ae3e980",
130
138
  "kind": "module",
131
139
  "path": "../../nmr-core/dist/esm/updateManagedFile.js"
132
140
  },
133
141
  {
134
- "hash": "2224ec3a",
142
+ "hash": "00d00fae",
135
143
  "kind": "module",
136
144
  "path": "../../nmr-core/dist/esm/workspace.js"
137
145
  },
138
146
  {
139
- "hash": "24bde686",
147
+ "hash": "d377c37c",
140
148
  "kind": "module",
141
149
  "path": "../../nmr-core/dist/esm/writeFileWithCheck.js"
142
150
  },
@@ -186,7 +194,7 @@
186
194
  "path": "lib/setup.ts"
187
195
  },
188
196
  {
189
- "hash": "400757e6",
197
+ "hash": "64995a53",
190
198
  "kind": "module",
191
199
  "path": "lib/shared-configs.ts"
192
200
  },
@@ -201,7 +209,7 @@
201
209
  "path": "lib/workspace-build.ts"
202
210
  },
203
211
  {
204
- "hash": "0d9124f7",
212
+ "hash": "b54c684e",
205
213
  "kind": "inline",
206
214
  "path": "../package.json",
207
215
  "paths": [
@@ -316,10 +324,10 @@
316
324
  ],
317
325
  "name": "default",
318
326
  "path": "kits/default.js",
319
- "readyupVersion": "0.43.0",
327
+ "readyupVersion": "0.44.0",
320
328
  "source": "kits/default.ts",
321
329
  "sourceHash": "9fd61bf4",
322
- "targetHash": "547201fd"
330
+ "targetHash": "6ad36b20"
323
331
  }
324
332
  ]
325
333
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.53.1 — 2026-10-28
6
+
7
+ ### ♻️ Refactoring
8
+
9
+ - Lead release-kit's function names with a transitive verb (#1137)
10
+
11
+ Renames the functions in `release-kit`'s source and tests so that each name starts with a transitive verb that states its action, such as `releasePrepareTargets` to `prepareReleaseTargets`, `releaseWorkflow` to `renderReleaseWorkflow`, and `topologicalSort` to `sortTopologically`. The exports of `.` and `./config` stay the same.
12
+
13
+ ## 0.53.0 — 2026-10-27
14
+
15
+ ### 🎉 Features
16
+
17
+ - Adds the time saved to the line that `nmr-compile` prints when it skips an unchanged package, as in `Skipping build, saved ~2.1s.`, when the skipped emit took at least a second. (#1112)
18
+ - Adds `build skipped as unchanged` to the verdict line of a compile that hits the build cache, followed by the saved time when the recorded emit took at least a second, which nmr prints in a quiet run as well. (#1113)
19
+ - Adds a `stated` field to the `--json` record of a passing command that stated its own outcome, such as `{"kind":"build-unchanged","savedMs":2100}`, which records the saving whatever its size. (#1113)
20
+ - Removes the 7-day `maturityPeriod` from the shared policy that `defineConfig` in `@williamthorsen/nmr/taze` supplies, so that `nmr upgrade` waits for the `minimumReleaseAge` set in `pnpm-workspace.yaml` (or pnpm's default) and no longer withholds releases that pnpm already resolves for other packages. (#1129)
21
+
22
+ ### 🐛 Bug fixes
23
+
24
+ - Fixes the issue that nmr reported a short or negative duration for a command's run, a delegated run, or a remote lock wait when the system clock was adjusted during that span. (#1112)
25
+ - Fixes the issue that `nmr-fmt` never formatted a file whose name starts with `-` and rejected `--` as an unknown option, by making `nmr-fmt` accept `--` before its pathspecs and pass `--` to Prettier before the file list. (#1130)
26
+
27
+ ### ♻️ Refactoring
28
+
29
+ - Apply the naming conventions to nmr-core's identifiers (#1125)
30
+
31
+ Renames the identifiers in nmr-core to follow the naming conventions, including the exported option keys of `updateManagedFile` and `writeFileWithCheck`, which change from `dryRun` and `overwrite` to `isDryRun` and `shouldOverwrite`, and the constant `GIT_OUTPUT_LIMIT`, which becomes `GIT_OUTPUT_LIMIT_BYTES`. Updates the call sites in nmr, release-kit, and v11y-check to the new names.
32
+
5
33
  ## 0.52.0 — 2026-10-24
6
34
 
7
35
  ### 🎉 Features
package/README.md CHANGED
@@ -4,19 +4,7 @@
4
4
 
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
- <!-- section:release-notes -->
8
- ## Release notes — v0.52.0 (2026-10-24)
9
-
10
- ### 🎉 Features
11
-
12
- - Adds a 📡 glyph before the host name in the rich-output `nmr` verdicts that name a host, for a passed, failed, or recalled run, which leaves plain output and verdicts reading `on this tree` unchanged. (#1105)
13
- - Adds the root command `lint:workflows` and its `nmr-actionlint` bin, which lint `.github/workflows/` with actionlint 1.7.12, including its shellcheck pass over each `run:` block with shellcheck 0.11.0, using binaries that nmr downloads on first use, checks against pinned SHA-256 checksums, and caches under `node_modules/.cache/nmr-tools/`. (#1107)
14
- - Adds `lint:workflows` to the root `check`, `check:strict`, and `root:check` composites, which makes those composites and `nmr ci` fail on any actionlint finding in a repo that has workflows, need access to `github.com` on their first run, and fail on any platform other than macOS or Linux on x64 or arm64. (#1107)
15
-
16
- ### 🐛 Bug fixes
17
-
18
- - Fixes the issue that a delegated `nmr` command could not find a bin that the branch added to a workspace package, because the host's `pnpm install --frozen-lockfile` did not link new bins when the lockfile was unchanged. (#1108)
19
- <!-- /section:release-notes -->
7
+ <!-- section:release-notes --><!-- /section:release-notes -->
20
8
 
21
9
  ## Installation
22
10
 
@@ -1,5 +1,5 @@
1
1
  import type { Writable } from 'node:stream';
2
- import type { OutputStyle } from '@williamthorsen/nmr-core';
2
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
3
3
  import { type TreeSnapshot } from './check-cache.js';
4
4
  import { type RunPlan } from './invocation.js';
5
5
  import type { NmrConfig } from './types.js';
@@ -1,3 +1,4 @@
1
+ import { startTimer } from '@williamthorsen/nmr-core';
1
2
  import { formatMisplacedNoCacheWarning, isCacheableCommand, resolveTreeSnapshot, writeDebugNote, } from "./check-cache.js";
2
3
  import { findStaleBuildOutput, readBuildOutputState } from "./check-cache-build-output.js";
3
4
  import { recordDelegatedPass } from "./check-cache-delegation.js";
@@ -10,6 +11,7 @@ import { VERSION } from "./invocation.js";
10
11
  import { resolveChannel } from "./output-channel.js";
11
12
  import { writePassManifestEntry } from "./pass-manifest.js";
12
13
  import { runSteps } from "./runner.js";
14
+ import { splitSegments } from "./shell-syntax.js";
13
15
  export function openGate(options) {
14
16
  const { command, config, env, monorepoRoot, passthrough, stderr, style } = options;
15
17
  if (!isCacheableCommand(config.checkCache, command)) {
@@ -83,9 +85,9 @@ export async function runGated(plan) {
83
85
  stdout.write(plan.overrideNotice);
84
86
  }
85
87
  const runOptions = { ...plan.runOptions, env: addLockEnv(plan.runOptions.env ?? env, lock) };
86
- const startedAtMs = Date.now();
87
- const { exitCode, retainedOutput } = await runSteps(plan.steps, anchorDir, runOptions);
88
- const durationMs = Date.now() - startedAtMs;
88
+ const readElapsedMs = startTimer();
89
+ const { exitCode, retainedOutput, statedOutcomes } = await runSteps(plan.steps, anchorDir, runOptions);
90
+ const durationMs = readElapsedMs();
89
91
  if (exitCode === 0 && gate !== undefined) {
90
92
  await recordPass({
91
93
  anchorDir,
@@ -105,9 +107,12 @@ export async function runGated(plan) {
105
107
  stderr,
106
108
  });
107
109
  }
110
+ const stated = readOwnStatement(plan.ownSteps, statedOutcomes);
108
111
  return {
109
112
  exitCode,
110
- outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
113
+ outcome: exitCode === 0
114
+ ? { outcome: 'passed', durationMs, ...(stated !== undefined && { stated }) }
115
+ : { outcome: 'failed', durationMs, exitCode },
111
116
  };
112
117
  }
113
118
  finally {
@@ -168,6 +173,13 @@ async function readGate(plan) {
168
173
  snapshot,
169
174
  };
170
175
  }
176
+ function readOwnStatement(ownSteps, statedOutcomes) {
177
+ const [ownStep] = ownSteps;
178
+ if (ownSteps.length !== 1 || ownStep?.kind !== 'opaque' || splitSegments(ownStep.command).length !== 1) {
179
+ return undefined;
180
+ }
181
+ return statedOutcomes?.get(ownStep);
182
+ }
171
183
  async function recallPass(plan, gate) {
172
184
  const { anchorDir, command, context, env, stderr } = plan.invocation;
173
185
  const { monorepoRoot } = context;
@@ -44,6 +44,11 @@ export declare function hasExpectedBuildOutput(packageDir: string, outdir: strin
44
44
  * current one.
45
45
  */
46
46
  export declare function readBuildDigest(packageDir: string): Promise<string | undefined>;
47
+ /**
48
+ * Returns how long the emit took, in milliseconds, for the build that recorded `digest`, or `undefined` when no
49
+ * duration was recorded for that digest.
50
+ */
51
+ export declare function readEmitDuration(packageDir: string, digest: string): Promise<number | undefined>;
47
52
  /**
48
53
  * Resolves the absolute path of a package's build-cache file, one entry in the shared store. The package
49
54
  * directory is the entry's scope, so the store keys the file to it and packages sharing a hoisted
@@ -54,6 +59,12 @@ export declare function readBuildDigest(packageDir: string): Promise<string | un
54
59
  * to the digest. Changing any of them leaves every entry on disk unreachable.
55
60
  */
56
61
  export declare function resolveBuildCachePath(packageDir: string): string;
62
+ /**
63
+ * Resolves the absolute path of the entry that records how long a package's last emit took. It is a separate file in
64
+ * the build-cache entry's scope, so that a value varying from run to run never enters the digest that
65
+ * `readBuildDigest` returns.
66
+ */
67
+ export declare function resolveEmitDurationPath(packageDir: string): string;
57
68
  /**
58
69
  * Resolves the two scratch directories through which a build publishes: `staging`, which the emit is written to,
59
70
  * and `previous`, which the outgoing output is renamed aside to. Both are siblings of the emit directory, so a
@@ -80,3 +91,5 @@ export declare function resolveScratchDirs(emitDir: string): ScratchDirs;
80
91
  * the pre-rebuild binary keeps the old fingerprint and rebuilds on the next run.
81
92
  */
82
93
  export declare function resolveToolchainFingerprint(packageDir: string, selfDir?: string): Promise<string>;
94
+ /** Records how long the emit took for the build whose digest is `digest`. */
95
+ export declare function writeEmitDuration(packageDir: string, digest: string, emitMs: number): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { findPackageRoot, readCacheEntry, resolveCacheEntryPath } from '@williamthorsen/nmr-core';
3
+ import { findPackageRoot, readCacheEntry, readGuardedCacheEntry, resolveCacheEntryPath, writeGuardedCacheEntry, } from '@williamthorsen/nmr-core';
4
4
  import { glob } from 'glob';
5
5
  import { readPackageJson } from "../helpers/package-json.js";
6
6
  export const DEFAULT_ENTRY_GLOBS = ['src/**/*.{ts,tsx}'];
@@ -27,6 +27,10 @@ export function hasExpectedBuildOutput(packageDir, outdir, entryPoints) {
27
27
  export async function readBuildDigest(packageDir) {
28
28
  return readCacheEntry(resolveBuildCachePath(packageDir));
29
29
  }
30
+ export async function readEmitDuration(packageDir, digest) {
31
+ const entry = await readGuardedCacheEntry(resolveEmitDurationPath(packageDir), digest, isEmitDurationEntry);
32
+ return entry?.emitMs;
33
+ }
30
34
  export function resolveBuildCachePath(packageDir) {
31
35
  const absolutePackageDir = path.resolve(packageDir);
32
36
  return resolveCacheEntryPath({
@@ -36,6 +40,15 @@ export function resolveBuildCachePath(packageDir) {
36
40
  extension: '.hash',
37
41
  });
38
42
  }
43
+ export function resolveEmitDurationPath(packageDir) {
44
+ const absolutePackageDir = path.resolve(packageDir);
45
+ return resolveCacheEntryPath({
46
+ tool: BUILD_CACHE_TOOL,
47
+ scopeDir: absolutePackageDir,
48
+ slug: path.basename(absolutePackageDir),
49
+ extension: '.duration.json',
50
+ });
51
+ }
39
52
  export function resolveScratchDirs(emitDir) {
40
53
  const parent = path.dirname(emitDir);
41
54
  const name = path.basename(emitDir);
@@ -53,6 +66,22 @@ export async function resolveToolchainFingerprint(packageDir, selfDir = SELF_DIR
53
66
  }
54
67
  return readPackageJson(selfDir).version ?? '';
55
68
  }
69
+ export async function writeEmitDuration(packageDir, digest, emitMs) {
70
+ const entry = { emitMs };
71
+ try {
72
+ await writeGuardedCacheEntry(resolveEmitDurationPath(packageDir), digest, entry);
73
+ }
74
+ catch {
75
+ }
76
+ }
77
+ function isEmitDurationEntry(value) {
78
+ return (typeof value === 'object' &&
79
+ value !== null &&
80
+ 'emitMs' in value &&
81
+ typeof value.emitMs === 'number' &&
82
+ Number.isFinite(value.emitMs) &&
83
+ value.emitMs >= 0);
84
+ }
56
85
  function isSameDir(left, right) {
57
86
  return resolveRealPath(left) === resolveRealPath(right);
58
87
  }
@@ -2,11 +2,13 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readFile, rename, rm } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { formatGlyphLine, readCacheEntry, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
5
+ import { formatGlyphLine, readCacheEntry, startTimer, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
6
  import { glob } from 'glob';
7
7
  import { NMR_GLYPHS } from "../glyphs.js";
8
8
  import { resolveConfigPath } from "../helpers/config-path.js";
9
- import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, } from "./build-output.js";
9
+ import { formatSaving } from "../helpers/duration.js";
10
+ import { writeStatedOutcome } from "../stated-outcome.js";
11
+ import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, readEmitDuration, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, writeEmitDuration, } from "./build-output.js";
10
12
  import { assertSupportedTypeScript, formatDiagnostics, getCompiler, loadCompiler } from "./compiler.js";
11
13
  import { isWithin, rewriteSpecifiers } from "./rewrite-specifiers.js";
12
14
  import { resolveTsconfigChain } from "./tsconfig-chain.js";
@@ -37,8 +39,11 @@ export async function buildPackage(packageDir, options) {
37
39
  await discardScratchDirs(resolveScratchDirs(emitDir));
38
40
  return;
39
41
  }
42
+ const readEmitMs = startTimer();
40
43
  const emittedFileCount = await emitPackage(packageDir, entryPoints, outdir);
44
+ const emitMs = readEmitMs();
41
45
  await writeCacheEntry(cachePath, currentHash);
46
+ await writeEmitDuration(packageDir, currentHash, emitMs);
42
47
  console.info(formatGlyphLine(NMR_GLYPHS, options.style, 'package', `${path.basename(packageDir)}: ${describeEmit(emittedFileCount, outdir)}`));
43
48
  }
44
49
  export async function computeBuildHash(packageDir, files, emitConfig, toolchain) {
@@ -184,7 +189,11 @@ async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cach
184
189
  const currentHash = await computeBuildHash(packageDir, files, emitConfig, toolchain);
185
190
  if (previousHash === currentHash) {
186
191
  if (isOutputPresent) {
187
- console.info(`${STATUS_GLYPHS[style].skipped.text} ${packageName}: No changes detected. Skipping build.`);
192
+ const savedMs = await readEmitDuration(packageDir, currentHash);
193
+ await writeStatedOutcome(process.env, { kind: 'build-unchanged', ...(savedMs !== undefined && { savedMs }) });
194
+ const saving = formatSaving(savedMs ?? 0);
195
+ const savingClause = saving === undefined ? '' : `, ${saving}`;
196
+ console.info(`${STATUS_GLYPHS[style].skipped.text} ${packageName}: No changes detected. Skipping build${savingClause}.`);
188
197
  return { changed: false, currentHash };
189
198
  }
190
199
  console.info(formatGlyphLine(NMR_GLYPHS, style, 'package', `${packageName}: Build output is missing. Rebuilding.`));
@@ -1,6 +1,6 @@
1
1
  import { type OutputStyle } from '@williamthorsen/nmr-core';
2
2
  /**
3
- * Removes a package's build output and the build-cache entry that describes it, so the package does not keep
3
+ * Removes a package's build output and the build-cache entries that describe it, so the package does not keep
4
4
  * any state that would make the next build skip. Removal is idempotent: An already-clean package is a silent
5
5
  * no-op.
6
6
  */
@@ -11,13 +11,14 @@ import { applyDevBin, buildWorkspaceRegistry, resolveScript } from "../resolver.
11
11
  import { runCommand } from "../runner.js";
12
12
  import { renderChain } from "../steps.js";
13
13
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
14
- import { resolveBuildCachePath } from "./build-output.js";
14
+ import { resolveBuildCachePath, resolveEmitDurationPath } from "./build-output.js";
15
15
  const CLEAN_COMMAND = 'clean';
16
16
  const BUILT_IN_CLEAN = 'nmr-clean';
17
17
  const OUTPUT_ROOT = 'dist';
18
18
  export async function cleanPackage(packageDir, style) {
19
19
  await rm(path.resolve(packageDir, OUTPUT_ROOT), { recursive: true, force: true });
20
20
  await rm(resolveBuildCachePath(packageDir), { force: true });
21
+ await rm(resolveEmitDurationPath(packageDir), { force: true });
21
22
  console.info(formatGlyphLine(NMR_GLYPHS, style, 'clean', `${path.basename(packageDir)}: Removed build output and cache.`));
22
23
  }
23
24
  export async function runClean(cwd, style) {
@@ -6,7 +6,8 @@ export type FormatMode = 'check' | 'write';
6
6
  *
7
7
  * Trailing arguments are git pathspecs, not Prettier flags; an unrecognized option is rejected rather
8
8
  * than passed along, since git would read it as a pathspec matching nothing and the run would report
9
- * success over an empty selection.
9
+ * success over an empty selection. Every argument after `--` is a pathspec, even one that starts
10
+ * with `-`.
10
11
  */
11
12
  export declare function runFmt(argv: string[], cwd?: string): Promise<number>;
12
13
  /** @internal */
@@ -13,7 +13,7 @@ import { resolveFormatTargets } from "./format-targets.js";
13
13
  const PRETTIER_PACKAGE = 'prettier';
14
14
  const DEFAULT_CONFIG_PATH = fileURLToPath(new URL(`../prettier-default.config${path.extname(fileURLToPath(import.meta.url))}`, import.meta.url));
15
15
  const ARGUMENT_BUDGET_BYTES = 100_000;
16
- const USAGE = 'Usage: nmr-fmt (--check | --write) [--no-cache] [pathspec...]';
16
+ const USAGE = 'Usage: nmr-fmt (--check | --write) [--no-cache] [--] [pathspec...]';
17
17
  const MODE_ARGS = {
18
18
  check: ['--check'],
19
19
  write: ['--list-different', '--write'],
@@ -79,7 +79,11 @@ function parseFmtArgs(argv) {
79
79
  let mode;
80
80
  let shouldBypassCache = false;
81
81
  const pathspecs = [];
82
- for (const arg of argv) {
82
+ for (const [index, arg] of argv.entries()) {
83
+ if (arg === '--') {
84
+ pathspecs.push(...argv.slice(index + 1));
85
+ break;
86
+ }
83
87
  if (arg === '--no-cache') {
84
88
  shouldBypassCache = true;
85
89
  continue;
@@ -147,6 +151,7 @@ export function runPrettier(options) {
147
151
  ? []
148
152
  : ['--cache', '--cache-strategy', 'content', '--cache-location', cacheLocation]),
149
153
  ...MODE_ARGS[mode],
154
+ '--',
150
155
  ];
151
156
  let firstFailureStatus = 0;
152
157
  for (const batch of batchWithinBudget(files, budgetBytes)) {
@@ -7,6 +7,7 @@ import { reportError } from '@williamthorsen/nmr-core';
7
7
  import { findMonorepoRoot as findMonorepoRootOrNothing } from '@williamthorsen/nmr-core/workspace';
8
8
  import { describeSignalExitCode } from "../child-process.js";
9
9
  import { isObject } from "../helpers/type-guards.js";
10
+ import { omitOutcomeFile } from "../stated-outcome.js";
10
11
  const VITEST_PACKAGE = 'vitest';
11
12
  const SUPPORTED_VITEST_RANGE = '>=4.0.0 <6';
12
13
  const CONFIG_EXTENSIONS = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'];
@@ -18,6 +19,7 @@ export function runNmrVitest(argv, cwd = process.cwd()) {
18
19
  }
19
20
  const result = spawnSync(process.execPath, [cli.cliPath, ...chooseVitestConfigArgs(argv, cwd)], {
20
21
  cwd,
22
+ env: omitOutcomeFile(process.env),
21
23
  stdio: 'inherit',
22
24
  });
23
25
  if (result.error) {
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import { Writable } from 'node:stream';
3
- import { reportError } from '@williamthorsen/nmr-core';
3
+ import { reportError, startTimer } from '@williamthorsen/nmr-core';
4
4
  import { NO_CACHE_ENV_VAR, resolveRunId, writeDebugNote } from "./check-cache.js";
5
5
  import { buildChildEnv, buildRemoteEnv } from "./child-env.js";
6
6
  import { resolveDelegationTarget } from "./delegation-target.js";
@@ -111,7 +111,7 @@ async function delegate(options) {
111
111
  const { invocation, remoteEnv, target } = options;
112
112
  const { command, context, cwd, env, parsedArgs, stderr, stdout } = invocation;
113
113
  const listedHosts = target.hosts.join(', ');
114
- const startedAtMs = Date.now();
114
+ const readElapsedMs = startTimer();
115
115
  const snapshot = createSnapshot(cwd, env);
116
116
  if (!snapshot.ok && snapshot.isUnsupported) {
117
117
  if (target.source === 'flag') {
@@ -144,7 +144,7 @@ async function delegate(options) {
144
144
  stdout: createTee(stdout, retained.stdout),
145
145
  })
146
146
  : { ok: false, message: 'could not snapshot the working tree', output: snapshot.reason, waitedMs: 0 };
147
- const durationMs = Date.now() - startedAtMs - result.waitedMs;
147
+ const durationMs = readElapsedMs() - result.waitedMs;
148
148
  const ran = {
149
149
  kind: 'ran',
150
150
  durationMs,
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
- import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
6
+ import { startTimer, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
7
7
  import { PASS_MANIFEST_DIR_ENV_VAR, writeDebugNote } from "./check-cache.js";
8
8
  import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
9
9
  import { quoteForShell } from "./helpers/quoteForShell.js";
@@ -74,18 +74,18 @@ export async function runRemotely(options) {
74
74
  const marker = `__NMR_DELEGATE_${nonce}__`;
75
75
  const statusFile = `.git/nmr-run-status-${nonce}`;
76
76
  const passes = [];
77
- let waitStartedAtMs;
77
+ let readWaitedMs;
78
78
  let waitedMs = 0;
79
79
  let failure;
80
80
  const demultiplexer = createSessionDemultiplexer({
81
81
  marker,
82
82
  onRecord(record) {
83
83
  if (record.kind === 'waiting') {
84
- waitStartedAtMs ??= Date.now();
84
+ readWaitedMs ??= startTimer();
85
85
  stderr.write(`${formatDelegationWaitingLine(record.holder, host)}\n`);
86
86
  }
87
- else if (record.kind === 'locked' && waitStartedAtMs !== undefined) {
88
- waitedMs = Date.now() - waitStartedAtMs;
87
+ else if (record.kind === 'locked' && readWaitedMs !== undefined) {
88
+ waitedMs = readWaitedMs();
89
89
  }
90
90
  else if (record.kind === 'failed') {
91
91
  failure = record;
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { GIT_OUTPUT_LIMIT } from '@williamthorsen/nmr-core';
5
+ import { GIT_OUTPUT_LIMIT_BYTES } from '@williamthorsen/nmr-core';
6
6
  export function createSnapshot(cwd, env) {
7
7
  const toplevelResult = runGit(['rev-parse', '--show-toplevel'], cwd, env);
8
8
  if (!toplevelResult.ok) {
@@ -69,7 +69,7 @@ function copyIndex(source, destination) {
69
69
  fs.utimesSync(destination, atime, mtime);
70
70
  }
71
71
  function runGit(args, cwd, env) {
72
- const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT });
72
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT_BYTES });
73
73
  if (result.error !== undefined) {
74
74
  return { ok: false, error: result.error.message };
75
75
  }
@@ -1,4 +1,5 @@
1
1
  import { type RunCommandOptions, type RunCommandResult } from './child-process.js';
2
+ import { type StatedOutcome } from './stated-outcome.js';
2
3
  import type { Step } from './steps.js';
3
4
  /** What a step list needs to run. Each step's channels follow its kind, so the caller chooses none of them. */
4
5
  export type RunStepsOptions = Omit<RunCommandOptions, 'channels'>;
@@ -14,6 +15,8 @@ export interface RunStepsResult {
14
15
  * at least one to capture.
15
16
  */
16
17
  retainedOutput?: RetainedOutput;
18
+ /** The outcome that each foreground opaque step stated, absent unless a step that exited 0 stated one. */
19
+ statedOutcomes?: Map<Step, StatedOutcome>;
17
20
  }
18
21
  /**
19
22
  * Runs a step list in order and resolves once one fails or all have run, implementing the `&&` semantics that
@@ -33,6 +36,10 @@ export interface RunStepsResult {
33
36
  * Returns a bounded copy of what the list's own commands wrote, so that a caller can retain it. Only opaque
34
37
  * steps contribute: A structural step is another nmr process reporting for the subtree beneath it, and its
35
38
  * output is that subtree's to account for.
39
+ *
40
+ * Each foreground opaque step is handed a file of its own in `NMR_OUTCOME_FILE`, through which an nmr bin states its
41
+ * outcome. Every other step runs without the variable, as does a step whose value would have been inherited from an
42
+ * ancestor, so that a statement reaches only the list that handed out its file.
36
43
  */
37
44
  export declare function runSteps(steps: readonly Step[], cwd: string | undefined, options: RunStepsOptions): Promise<RunStepsResult>;
38
45
  /**
@@ -1,10 +1,16 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
1
4
  import process from 'node:process';
5
+ import { writeDebugNote } from "./check-cache.js";
2
6
  import { describeSignalExitCode, startSpawned, } from "./child-process.js";
3
7
  import { createOutputRecorder } from "./helpers/createOutputRecorder.js";
4
8
  import { isTerminal, resolveChannel, resolveInheritedChannel } from "./output-channel.js";
9
+ import { omitOutcomeFile, OUTCOME_FILE_ENV_VAR, readStatedOutcome } from "./stated-outcome.js";
5
10
  export async function runSteps(steps, cwd, options) {
6
11
  const resolvedOptions = {
7
12
  ...options,
13
+ env: omitOutcomeFile(options.env ?? process.env),
8
14
  quiet: options.quiet === true,
9
15
  stdout: options.stdout ?? process.stdout,
10
16
  stderr: options.stderr ?? process.stderr,
@@ -15,6 +21,8 @@ export async function runSteps(steps, cwd, options) {
15
21
  const runningChildren = new Set();
16
22
  let receivedSignal;
17
23
  let foregroundExitCode = 0;
24
+ const statedOutcomes = new Map();
25
+ let outcomeDir;
18
26
  function forwardSignal(signal) {
19
27
  receivedSignal ??= signal;
20
28
  for (const child of runningChildren)
@@ -26,6 +34,24 @@ export async function runSteps(steps, cwd, options) {
26
34
  void started.result.finally(() => runningChildren.delete(started.child));
27
35
  return started;
28
36
  }
37
+ async function runForeground(step) {
38
+ if (step.kind !== 'opaque') {
39
+ return { result: await launch(step, resolvedOptions).result };
40
+ }
41
+ outcomeDir ??= await mkdtemp(path.join(os.tmpdir(), 'nmr-outcome-'));
42
+ const outcomeFile = path.join(outcomeDir, `${steps.indexOf(step)}.json`);
43
+ const env = { ...resolvedOptions.env, [OUTCOME_FILE_ENV_VAR]: outcomeFile };
44
+ const started = launch(step, { ...resolvedOptions, env });
45
+ const result = await started.result;
46
+ if (result.exitCode !== 0) {
47
+ return { result };
48
+ }
49
+ const parentPids = [process.pid, ...(started.child.pid === undefined ? [] : [started.child.pid])];
50
+ const statedOutcome = await readStatedOutcome(outcomeFile, parentPids, (reason) => {
51
+ writeDebugNote(`ignoring the outcome that a step stated: ${reason}`, resolvedOptions.env, resolvedOptions.stderr);
52
+ });
53
+ return { result, ...(statedOutcome !== undefined && { statedOutcome }) };
54
+ }
29
55
  function flushEndedBackgroundRuns() {
30
56
  for (const run of backgroundRuns) {
31
57
  if (run.result === undefined || run.isFlushed)
@@ -48,8 +74,10 @@ export async function runSteps(steps, cwd, options) {
48
74
  backgroundRuns.push(startBackgroundRun(step, resolvedOptions, launch));
49
75
  continue;
50
76
  }
51
- const result = await launch(step, resolvedOptions).result;
77
+ const { result, statedOutcome } = await runForeground(step);
52
78
  flushEndedBackgroundRuns();
79
+ if (statedOutcome !== undefined)
80
+ statedOutcomes.set(step, statedOutcome);
53
81
  if (step.kind === 'opaque') {
54
82
  if (result.stderr === undefined || result.stdout === undefined) {
55
83
  isCaptureWhole = false;
@@ -75,9 +103,15 @@ export async function runSteps(steps, cwd, options) {
75
103
  finally {
76
104
  process.off('SIGINT', forwardSignal);
77
105
  process.off('SIGTERM', forwardSignal);
106
+ if (outcomeDir !== undefined)
107
+ await rm(outcomeDir, { force: true, recursive: true });
78
108
  }
79
109
  const exitCode = selectExitCode(foregroundExitCode, receivedSignal, backgroundRuns);
80
- return { exitCode, ...composeRetainedOutput(parts, isCaptureWhole) };
110
+ return {
111
+ exitCode,
112
+ ...composeRetainedOutput(parts, isCaptureWhole),
113
+ ...(statedOutcomes.size > 0 && { statedOutcomes }),
114
+ };
81
115
  }
82
116
  export async function runCommand(command, cwd, options) {
83
117
  return runSpawned({ args: [], file: command, input: 'inherit', shouldUseShell: true }, cwd, options);
@@ -113,7 +147,7 @@ function composeRetainedOutput(parts, isCaptureWhole) {
113
147
  };
114
148
  }
115
149
  function buildBackgroundEnv(options) {
116
- const env = options.env ?? process.env;
150
+ const { env } = options;
117
151
  const shouldForceColor = isTerminal(options.stdout) && (env['NO_COLOR'] ?? '') === '' && env['FORCE_COLOR'] === undefined;
118
152
  return shouldForceColor ? { ...env, FORCE_COLOR: '1' } : env;
119
153
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Names the file in which an nmr bin states its own outcome to the nmr process that runs it. nmr sets it for each
3
+ * opaque step and removes it from every other process that it starts.
4
+ */
5
+ export declare const OUTCOME_FILE_ENV_VAR = "NMR_OUTCOME_FILE";
6
+ /**
7
+ * An outcome that a bin states about its own run, which a passing verdict reports in its detail slot.
8
+ *
9
+ * `build-unchanged` is a build-cache hit. Its `savedMs` is the emit time recorded for the current inputs, whatever
10
+ * its size, and is absent when none was recorded; whether a saving is worth naming belongs to rendering.
11
+ */
12
+ export interface StatedOutcome {
13
+ kind: 'build-unchanged';
14
+ savedMs?: number;
15
+ }
16
+ /**
17
+ * Returns the environment without `NMR_OUTCOME_FILE`, for a process that is not the one to which the file was handed,
18
+ * so that it cannot state an outcome for the step that runs it.
19
+ */
20
+ export declare function omitOutcomeFile(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
21
+ /**
22
+ * Reads the outcome that a step stated in `filePath`, or `undefined` when it stated none. A record that cannot be
23
+ * read as a stated outcome is reported to `onRejection` and treated as none, so that a bin from another nmr version
24
+ * cannot break the verdict.
25
+ *
26
+ * A record is accepted only from a process whose parent is one of `parentPids`: the process that handed out the file,
27
+ * or the shell that it spawned for the step. A shell running a single command either replaces itself with it or runs
28
+ * it as its child, so the step's own bin passes either way. Where the shell replaces itself, a process that the
29
+ * command starts passes too; a process two levels below the command does not.
30
+ */
31
+ export declare function readStatedOutcome(filePath: string, parentPids: readonly number[], onRejection: (reason: string) => void): Promise<StatedOutcome | undefined>;
32
+ /**
33
+ * Writes the outcome into the file that `NMR_OUTCOME_FILE` names, with this process's parent as the one that it is
34
+ * stated to, and does nothing when the variable is unset. The statement is advisory: A write that fails leaves the
35
+ * verdict reading as it would without one.
36
+ */
37
+ export declare function writeStatedOutcome(env: NodeJS.ProcessEnv, outcome: StatedOutcome): Promise<void>;
@@ -0,0 +1,60 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import process from 'node:process';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { isObject } from "./helpers/type-guards.js";
5
+ export const OUTCOME_FILE_ENV_VAR = 'NMR_OUTCOME_FILE';
6
+ export function omitOutcomeFile(env) {
7
+ return Object.fromEntries(Object.entries(env).filter(([name]) => name !== OUTCOME_FILE_ENV_VAR));
8
+ }
9
+ export async function readStatedOutcome(filePath, parentPids, onRejection) {
10
+ let text;
11
+ try {
12
+ text = await readFile(filePath, 'utf8');
13
+ }
14
+ catch (error) {
15
+ if (isMissingFileError(error)) {
16
+ return undefined;
17
+ }
18
+ onRejection(`could not read ${filePath}: ${describeError(error)}`);
19
+ return undefined;
20
+ }
21
+ let value;
22
+ try {
23
+ value = JSON.parse(text);
24
+ }
25
+ catch {
26
+ onRejection(`${filePath} does not contain JSON`);
27
+ return undefined;
28
+ }
29
+ if (!isStatedOutcome(value)) {
30
+ onRejection(`${filePath} does not contain a stated outcome that this nmr knows`);
31
+ return undefined;
32
+ }
33
+ if (!parentPids.includes(value.parentPid)) {
34
+ onRejection(`${filePath} was written by a process whose parent is not one of ${parentPids.join(', ')}`);
35
+ return undefined;
36
+ }
37
+ return value.savedMs === undefined ? { kind: value.kind } : { kind: value.kind, savedMs: value.savedMs };
38
+ }
39
+ export async function writeStatedOutcome(env, outcome) {
40
+ const filePath = env[OUTCOME_FILE_ENV_VAR];
41
+ if (filePath === undefined || filePath === '') {
42
+ return;
43
+ }
44
+ try {
45
+ const record = { ...outcome, parentPid: process.ppid };
46
+ await writeFile(filePath, JSON.stringify(record));
47
+ }
48
+ catch {
49
+ }
50
+ }
51
+ function isMissingFileError(error) {
52
+ return isObject(error) && error['code'] === 'ENOENT';
53
+ }
54
+ function isStatedOutcome(value) {
55
+ if (!isObject(value) || value['kind'] !== 'build-unchanged' || !Number.isSafeInteger(value['parentPid'])) {
56
+ return false;
57
+ }
58
+ const savedMs = value['savedMs'];
59
+ return savedMs === undefined || (typeof savedMs === 'number' && Number.isFinite(savedMs) && savedMs >= 0);
60
+ }
@@ -3,8 +3,8 @@ import type { CheckOptions } from 'taze';
3
3
  * A taze configuration.
4
4
  *
5
5
  * Every property admits `undefined` explicitly, unlike `Partial<CheckOptions>`, which rejects it under
6
- * `exactOptionalPropertyTypes`. Passing `undefined` is how a consumer clears `maturityPeriod`, so the type
7
- * has to allow it.
6
+ * `exactOptionalPropertyTypes`. Passing `undefined` is how a consumer clears `mode`, so the type has to allow
7
+ * it.
8
8
  */
9
9
  export type TazeConfig = {
10
10
  [K in keyof CheckOptions]?: CheckOptions[K] | undefined;
@@ -12,7 +12,7 @@ export type TazeConfig = {
12
12
  /**
13
13
  * Maturity period in days for each package pattern, read by `nmr-taze` rather than by taze. A key is a package
14
14
  * name in which `*` matches any run of characters, and the first key that matches a package decides its period.
15
- * Every other dependency keeps `maturityPeriod`.
15
+ * Every other dependency keeps `maturityPeriod` or, while that is unset, pnpm's `minimumReleaseAge`.
16
16
  */
17
17
  packageMaturityPeriod?: Record<string, number> | undefined;
18
18
  };
@@ -20,10 +20,12 @@ export type TazeConfig = {
20
20
  * Builds a taze configuration from nmr's shared upgrade policy and a repo's own settings.
21
21
  *
22
22
  * Any property that the caller supplies wins, including `0` and an explicit `undefined`, which clears nmr's
23
- * default and hands the setting back to taze. Clearing `maturityPeriod` restores taze's inheritance of pnpm's
24
- * `minimumReleaseAge`, which only applies while it is unset. Clearing `mode` yields taze's own `'default'`, which
25
- * follows each declared range; passed through as `undefined`, it would overwrite taze's default and leave no
26
- * valid range to search.
23
+ * default and hands the setting back to taze. Clearing `mode` yields taze's own `'default'`, which follows each
24
+ * declared range; passed through as `undefined`, it would overwrite taze's default and leave no valid range to
25
+ * search.
26
+ *
27
+ * The policy leaves `maturityPeriod` unset, because taze inherits pnpm's `minimumReleaseAge` only while it is
28
+ * unset. A repo that sets it replaces pnpm's period.
27
29
  *
28
30
  * The merge is shallow, which suits a policy of scalar defaults. A nested default would clobber rather
29
31
  * than merge, so adding one means revisiting this.
package/dist/esm/taze.js CHANGED
@@ -1,6 +1,5 @@
1
1
  const SHARED_POLICY = {
2
2
  includeLocked: true,
3
- maturityPeriod: 7,
4
3
  mode: 'minor',
5
4
  };
6
5
  export function defineConfig(config) {
@@ -2,6 +2,7 @@ import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
3
  import type { ReplayLine } from './check-cache-entries.js';
4
4
  import type { ReportFormat } from './report-format.js';
5
+ import type { StatedOutcome } from './stated-outcome.js';
5
6
  /**
6
7
  * The ceiling to which a rendered verdict, its newline included, is held.
7
8
  *
@@ -46,6 +47,7 @@ export type VerdictOutcome = {
46
47
  } & ({
47
48
  outcome: 'passed';
48
49
  durationMs: number;
50
+ stated?: StatedOutcome;
49
51
  } | {
50
52
  outcome: 'failed';
51
53
  durationMs: number;
@@ -5,7 +5,7 @@ import { formatDuration, formatSaving } from "./helpers/duration.js";
5
5
  export const VERDICT_LINE_LIMIT_BYTES = 512;
6
6
  export function renderVerdict(verdict, style) {
7
7
  const { marker, phrase } = describeOutcome(verdict, style);
8
- const detail = flattenDetail(verdict.detail ?? renderReplay(verdict) ?? '');
8
+ const detail = flattenDetail(verdict.detail ?? renderReplay(verdict) ?? renderStated(verdict) ?? '');
9
9
  const detailClause = detail === '' ? '' : ` — ${detail}`;
10
10
  return clampToBytes(`${marker} ${verdict.scope}: ${verdict.command}: ${phrase}${detailClause}`, LINE_BUDGET_BYTES);
11
11
  }
@@ -74,6 +74,13 @@ function renderReplay(verdict) {
74
74
  const lines = verdict.replay.map((line) => `${line.scope}: ${line.command}: ${line.excerpt}`);
75
75
  return `${REPLAY_MARKER} ${lines.join('; ')}`;
76
76
  }
77
+ function renderStated(verdict) {
78
+ if (verdict.outcome !== 'passed' || verdict.stated === undefined) {
79
+ return undefined;
80
+ }
81
+ const saving = formatSaving(verdict.stated.savedMs ?? 0);
82
+ return saving === undefined ? 'build skipped as unchanged' : `build skipped as unchanged, ${saving}`;
83
+ }
77
84
  function flattenDetail(detail) {
78
85
  return detail.replaceAll(/[\r\n]+/gu, ' ').trim();
79
86
  }
package/docs/reporting.md CHANGED
@@ -6,6 +6,8 @@ The verdict line and its four outcomes are shown in the [README](../README.md#wh
6
6
 
7
7
  A recalled pass also includes an excerpt of the run that it recalls, if that run left one; see [Replaying a skipped run's output](check-cache.md#replaying-a-skipped-runs-output).
8
8
 
9
+ A pass also includes what the command stated about its own run, if the command is an nmr bin that states one, such as `nmr-compile` on a build-cache hit: `PASS nmr-core: compile: passed in 0.3s — build skipped as unchanged, saved ~2.1s`. The line names the saving under the same rule as a recalled pass. Because the bin states it through [`NMR_OUTCOME_FILE`](#reserved-environment-variables) rather than through its output, a quiet run reports it too.
10
+
9
11
  The scope is the directory to which the command's registry belongs: a package's own directory, or the monorepo root. Durations and ages truncate and show at most two units, so a check that passed 119.9 seconds ago reports `1m 59s ago` and never `2m ago`.
10
12
 
11
13
  Verdicts nest. A composite reports, and so does every command into which it expands; a `:pre` or `:post` hook reports nothing of its own, since the command that it wraps already reports for the chain, though a hook that delegates to a named command reports under that name. A `-R` or `-F` invocation reports nothing either; every scope to which it fans out reports instead. A cold repo-wide `nmr ci` in this package's own monorepo uses 26 verdict lines to report a run that prints roughly 250.
@@ -38,7 +40,7 @@ Every object contains `command`, `scope`, and `outcome`, and then the fields tha
38
40
 
39
41
  | `outcome` | Also contains |
40
42
  | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
41
- | `passed` | `durationMs` |
43
+ | `passed` | `durationMs`, and `stated` when the command stated its own outcome, such as `{"kind":"build-unchanged","savedMs":2100}` |
42
44
  | `failed` | `durationMs`, `exitCode` |
43
45
  | `recalled` | `ageMs`, `savedMs`, and `replay` when the skip has excerpts to [replay](check-cache.md#replaying-a-skipped-runs-output) |
44
46
  | `no-op` | `reason`, one of `empty-override`, `empty-workspace`, or `noop-override` |
@@ -72,6 +74,8 @@ Because the format is nmr's own reporting, and nmr does not tell the commands th
72
74
 
73
75
  `NMR_ENTRY_CHAIN` is nmr's own as well. It lists the commands that the current run has entered and the directories in which it entered them, and nmr passes it to every process that it starts, so that a process re-entering a command already running in its directory fails instead of repeating without end; see [self-referential entries](scripts.md#three-tier-override-system). Do not set it: nmr reports a value that it cannot read and exits 1.
74
76
 
77
+ `NMR_OUTCOME_FILE` is nmr's own as well. nmr sets it for each command that it runs through the shell, naming a file of that step's own, in which an nmr bin states its outcome; nmr reads the file once the step exits 0 and removes it afterwards. nmr accepts the statement only from a command whose own script is a single shell command, and only from a process that names as its parent, in the record, nmr or the shell that nmr spawned for the step. A bin therefore cannot speak for a chain or for a hook around it. Whether a process that the step's command starts can state one depends on the shell: Where the shell runs the command as its child, as Debian's and Ubuntu's /bin/sh does, only the command itself can state an outcome; where the shell replaces itself with the command, as bash does, a process that the command starts can state one too, so a wrapper script that runs a bin as its child speaks for its step. `nmr-vitest` withholds the variable from the suite that it runs, so that a test exercising a bin cannot speak for the `test` step. nmr also removes the variable from every other process that it starts. Do not set it: nmr replaces an inherited value, and ignores a record that it cannot read.
78
+
75
79
  ### Output style
76
80
 
77
81
  `--output-style <auto|plain|rich>` sets the style for one invocation, and `NMR_OUTPUT_STYLE` sets it for a shell: `rich` marks each verdict with an emoji, such as ✅ or ❌, and `plain` prints a word in its place, such as `PASS` or `FAIL`, which a reader of a log can search for. `auto`, the default, defers to detection. Any other value is a usage error, and nmr exits with code 1, whichever source named it. The flag outranks the variable, and both accept `auto` as well as a style, so a shell exporting one style is overridable on one invocation in either direction.
package/docs/upgrades.md CHANGED
@@ -40,11 +40,13 @@ export default defineConfig({
40
40
  });
41
41
  ```
42
42
 
43
- `defineConfig` supplies nmr's shared upgrade policy, so a repo's config contains only what is specific to that repo. The policy is a seven-day quarantine on brand-new releases and the pair of settings that report a dependency pinned to an exact version: Locked dependencies are included, and the range searched is `minor`. Any setting that a repo's config declares wins over nmr's default. Passing `undefined` clears a default rather than falling back to it; that is how a config hands the quarantine policy back to `pnpm-workspace.yaml`'s `minimumReleaseAge`. Clearing `mode` hands the range back to the upgrade tool's own default, which follows the range that each dependency declares.
43
+ `defineConfig` supplies nmr's shared upgrade policy, so a repo's config contains only what is specific to that repo. The policy is the pair of settings that report a dependency pinned to an exact version: Locked dependencies are included, and the range searched is `minor`. Any setting that a repo's config declares wins over nmr's default. Passing `undefined` clears a default rather than falling back to it. Clearing `mode` hands the range back to the upgrade tool's own default, which follows the range that each dependency declares.
44
+
45
+ The policy does not set `maturityPeriod`, so the quarantine on brand-new releases is pnpm's: The upgrade tool applies the `minimumReleaseAge` of `pnpm-workspace.yaml`, or pnpm 11's default when the file does not set one (under pnpm 10 or earlier, a workspace that does not set it is not quarantined), and the packages that its `minimumReleaseAgeExclude` lists skip the quarantine. A `maturityPeriod` in a repo's config replaces pnpm's period.
44
46
 
45
47
  Everything the [taze configuration](https://github.com/antfu-collective/taze#config-file) accepts is accepted here, with five exceptions that the upgrade tool does not read from any config file at all: `concurrency`, `githubActions`, `ignoreOtherWorkspaces`, `nodeVersion`, and `requestTimeout`. The tool's CLI writes a default for each of them over whatever the config declares ([taze#317](https://github.com/antfu-collective/taze/issues/317)), so the command line is the only place they can be set. nmr's ReadyUp kit warns when a repo's config declares one, and [request timeout](#request-timeout) below shows how to set it instead.
46
48
 
47
- > **Note:** Only this file activates nmr's upgrade policy. A repo without one gets neither the release quarantine nor the settings that report a dependency pinned to an exact version, and `nmr upgrade` reports nothing in a repo using pnpm's `savePrefix: ''`. nmr's ReadyUp kit warns when the file is missing.
49
+ > **Note:** Only this file activates nmr's upgrade policy. A repo without one does not get the settings that report a dependency pinned to an exact version, and `nmr upgrade` reports nothing in a repo using pnpm's `savePrefix: ''`. nmr's ReadyUp kit warns when the file is missing.
48
50
 
49
51
  Two consequences of the `minor` range mode declared by the policy:
50
52
 
@@ -53,13 +55,13 @@ Two consequences of the `minor` range mode declared by the policy:
53
55
 
54
56
  ### Per-package maturity periods
55
57
 
56
- `packageMaturityPeriod` gives the packages that match a pattern a quarantine of their own, in days. Every other dependency keeps `maturityPeriod`. taze applies one maturity period to a whole run, so `nmr upgrade` runs it once for each entry and once more for every other dependency, each run headed on stderr by the patterns and the period that it covers. The command exits with the first non-zero exit code among the runs.
58
+ `packageMaturityPeriod` gives the packages that match a pattern a quarantine of their own, in days. Every other dependency keeps `maturityPeriod` or, while that is unset, pnpm's `minimumReleaseAge`. taze applies one maturity period to a whole run, so `nmr upgrade` runs it once for each entry and once more for every other dependency, each run headed on stderr by the patterns and the period that it covers. The command exits with the first non-zero exit code among the runs.
57
59
 
58
60
  ```ts
59
61
  import { defineConfig } from '@williamthorsen/nmr/taze';
60
62
 
61
63
  export default defineConfig({
62
- // Propose first-party releases after one day, and third-party ones after the policy's seven.
64
+ // Propose first-party releases after one day, and third-party ones after pnpm's `minimumReleaseAge`.
63
65
  packageMaturityPeriod: {
64
66
  '@williamthorsen/*': 1,
65
67
  readyup: 1,
package/docs/utilities.md CHANGED
@@ -12,7 +12,7 @@ nmr-actionlint
12
12
 
13
13
  ## `nmr-clean`
14
14
 
15
- Remove a package's build output (`dist`) and its `nmr-compile` cache entry, so that the next build cannot skip on leftover state. Run from a package directory, it cleans that package; run from the monorepo root, it sweeps every workspace package in a single pass, running each package's resolved `clean`, so a package that overrides `clean`, in `.config/nmr.config.ts` or in its own `package.json`, gets its own command rather than the sweep. Removal is idempotent: Cleaning an unbuilt package is a silent no-op. This is the default `clean` script at both levels.
15
+ Remove a package's build output (`dist`) and its `nmr-compile` cache entries, the key and the recorded emit duration, so that the next build cannot skip on leftover state. Run from a package directory, it cleans that package; run from the monorepo root, it sweeps every workspace package in a single pass, running each package's resolved `clean`, so a package that overrides `clean`, in `.config/nmr.config.ts` or in its own `package.json`, gets its own command rather than the sweep. Removal is idempotent: Cleaning an unbuilt package is a silent no-op. This is the default `clean` script at both levels.
16
16
 
17
17
  ```bash
18
18
  nmr-clean
@@ -32,6 +32,8 @@ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler
32
32
 
33
33
  **What busts the cache.** The key folds each input's content and path, the emit options, the resolved TypeScript version, and a fingerprint of the nmr running the build -- its own build digest in a workspace checkout, its package version otherwise. Upgrading nmr in a consuming repo therefore rebuilds every package that nmr compiles, and an edit to nmr's own build sources rebuilds each sibling once nmr itself has been rebuilt. A build of nmr's own package folds the version rather than the digest, which is the cache entry that this build is about to write.
34
34
 
35
+ **What a skip reports.** Each build records how long its emit took, in a second entry beside the key (`.duration.json`), held to the key under which it was recorded. A skip names that time as the saving, `No changes detected. Skipping build, saved ~2.1s.`, when the recorded emit took at least a second and was recorded for the current inputs; otherwise the line ends at `Skipping build.` The time covers compiling and publishing the output, the work that a skip avoids, and not the hashing that a skip also does. The duration is a separate entry, so it never changes the key. Run through nmr, a skip also appears on the compile verdict line, `passed in 0.3s — build skipped as unchanged, saved ~2.1s`, which a quiet run still prints; see [what nmr reports](reporting.md#what-nmr-reports).
36
+
35
37
  `typescript` is an optional peer dependency (`>=5.7.0 <7`), needed only by `nmr-compile`; the consuming repo provides it, and `nmr-compile` run without it fails with an error naming the peer and the version that it requires. The floor is what `rewriteRelativeImportExtensions` requires; the ceiling exists because TypeScript 7 does not include a compiler API: Its root export is a version constant, so `nmr-compile` cannot run on it. Relative imports in source must use explicit `.ts` or `.tsx` extensions for them to be rewritten.
36
38
 
37
39
  ```bash
@@ -49,13 +51,14 @@ A repo's own copy must be Prettier 3.0.0 or later. The design requires `--ignore
49
51
  ```bash
50
52
  nmr-fmt --check
51
53
  nmr-fmt --write packages/nmr
54
+ nmr-fmt --check -- -lead.js
52
55
  ```
53
56
 
54
57
  ### File selection
55
58
 
56
59
  `fmt` and `fmt:check` format the files that git reports: tracked files, plus untracked files that git does not ignore. Prettier reads `.gitignore` and `.prettierignore` from the working directory alone, so a pattern in `packages/<pkg>/.gitignore` never applies to a run started at the monorepo root. Because git knows the whole hierarchy, including `.git/info/exclude` and `core.excludesFile`, and every `.prettierignore` in the tree is discovered from the repository root, package-level exclusions apply from any directory as well.
57
60
 
58
- A file that git ignores is never formatted, even when named directly. Because trailing arguments are git pathspecs rather than Prettier flags, `nmr fmt:check packages/nmr` narrows the run, while an unrecognized option and a pathspec for which git lists no file both fail it. Running outside a git repository fails rather than reporting a clean run.
61
+ A file that git ignores is never formatted, even when named directly. Because trailing arguments are git pathspecs rather than Prettier flags, `nmr fmt:check packages/nmr` narrows the run, while an unrecognized option and a pathspec for which git lists no file both fail it. Every argument after `--` is a pathspec, even one that starts with `-`, so an unrecognized option fails the run only before `--`. Running outside a git repository fails rather than reporting a clean run.
59
62
 
60
63
  `nmr-fmt` drops paths that the index names but the filesystem does not have (a file deleted but not yet staged), submodule gitlinks, and symlinks whatever they point at, before calling Prettier, which rejects a symlink named explicitly. It also skips a path that it is denied access to, such as a `.envrc` that a sandbox shields, and names each one on stderr, so that a check that passes does not hide a file that it never read. A pathspec whose every match is skipped still counts as matched: The run passes and names the skipped paths on stderr, so that a pre-commit hook passing only staged symlinks or a submodule bump does not fail. Any other failure to stat a listed path fails the run, with an error that names the path.
61
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.52.0",
3
+ "version": "0.53.1",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -79,23 +79,23 @@
79
79
  "!docs/*.v*.md"
80
80
  ],
81
81
  "dependencies": {
82
- "@williamthorsen/nmr-core": "0.13.6",
83
- "@williamthorsen/toolbelt.errors": "0.8.4",
84
- "@williamthorsen/toolbelt.filesystem": "0.9.4",
85
- "glob": "13.0.6",
82
+ "@williamthorsen/nmr-core": "0.14.0",
83
+ "@williamthorsen/toolbelt.errors": "0.9.0",
84
+ "@williamthorsen/toolbelt.filesystem": "0.10.0",
85
+ "glob": "14.0.2",
86
86
  "prettier": "3.9.10",
87
87
  "prettier-plugin-sh": "0.20.2",
88
88
  "taze": "21.3.0",
89
89
  "unconfig": "7.5.0"
90
90
  },
91
91
  "devDependencies": {
92
- "@williamthorsen/toolbelt.testing": "0.7.0",
93
- "@williamthorsen/toolbelt.vitest": "0.12.0",
92
+ "@williamthorsen/toolbelt.testing": "0.8.0",
93
+ "@williamthorsen/toolbelt.vitest": "0.13.0",
94
94
  "esbuild": "0.28.2",
95
- "readyup": "0.43.0",
95
+ "readyup": "0.44.0",
96
96
  "tinyglobby": "0.2.17",
97
97
  "typescript": "6.0.3",
98
- "vite": "8.3.4",
98
+ "vite": "8.3.5",
99
99
  "vitest": "5.0.5",
100
100
  "yaml": "2.9.1"
101
101
  },