@williamthorsen/nmr 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /** @noformat — @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.27.0";
3
+ export const __readyupVersion = "0.28.0";
4
4
 
5
5
 
6
6
  // .readyup/kits/default.ts
@@ -179,6 +179,13 @@ var default_default = defineRdyKit({
179
179
  check: toolVersionsHasNoPnpm,
180
180
  fix: "Remove pnpm from .tool-versions \u2014 manage via packageManager field and corepack"
181
181
  },
182
+ {
183
+ name: "no package.json declares a pnpm field",
184
+ severity: "error",
185
+ quiet: true,
186
+ check: () => noPnpmFieldInPackageJson(),
187
+ fix: "Move these settings into pnpm-workspace.yaml, quoting each version under `overrides`, or run `pnpx codemod run pnpm-v10-to-v11`. pnpm 11 reads no key from the `pnpm` field, so an override left there pins nothing while an upgrade run with `--write` goes on rewriting it"
188
+ },
182
189
  {
183
190
  name: ".config/nmr.config.ts uses defineConfig",
184
191
  severity: "recommend",
@@ -387,7 +394,7 @@ function formatPaths(paths) {
387
394
  ${paths.map((path2) => ` ${path2}`).join("\n")}`;
388
395
  }
389
396
  function getMinVersion() {
390
- const picked = { "version": "0.31.0" };
397
+ const picked = { "version": "0.32.0" };
391
398
  if (typeof picked["version"] !== "string") {
392
399
  throw new TypeError("nmr/package.json: 'version' must be a string");
393
400
  }
@@ -413,6 +420,15 @@ function isReExportOnly(content) {
413
420
  const statements = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
414
421
  return statements.length > 0 && statements.every((line) => RE_EXPORT_LINE_PATTERN.test(line));
415
422
  }
423
+ function noPnpmFieldInPackageJson(cwd = process.cwd()) {
424
+ const declaring = findFiles(["**/package.json"], cwd).flatMap((relativePath) => {
425
+ const keys = readPnpmFieldKeys(readFileIn(cwd, relativePath));
426
+ if (keys === void 0) return [];
427
+ return [keys.length > 0 ? `${relativePath} (${keys.join(", ")})` : relativePath];
428
+ });
429
+ if (declaring.length === 0) return true;
430
+ return { ok: false, detail: formatPaths(declaring) };
431
+ }
416
432
  function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
417
433
  const nonRootConfigs = findFiles([`**/vitest.config.${CONFIG_EXTENSIONS}`], cwd).filter((path2) => path2.includes("/"));
418
434
  const reExports = nonRootConfigs.filter((path2) => isReExportOnly(readFileIn(cwd, path2)));
@@ -464,6 +480,18 @@ function readFileIn(cwd, relativePath) {
464
480
  return void 0;
465
481
  }
466
482
  }
483
+ function readPnpmFieldKeys(content) {
484
+ if (content === void 0) return void 0;
485
+ let parsed;
486
+ try {
487
+ parsed = JSON.parse(content);
488
+ } catch {
489
+ return void 0;
490
+ }
491
+ if (!isRecord(parsed)) return void 0;
492
+ const pnpm = parsed["pnpm"];
493
+ return isRecord(pnpm) ? Object.keys(pnpm).toSorted() : void 0;
494
+ }
467
495
  function resolvesVersionViaWorkspace(range) {
468
496
  return WORKSPACE_VERSION_MARKERS.some((marker) => range.startsWith(marker));
469
497
  }
@@ -483,6 +511,7 @@ export {
483
511
  everyTestFileNamesItsTier,
484
512
  hasSupportedEslintVersion,
485
513
  hasSupportedStrictLintVersion,
514
+ noPnpmFieldInPackageJson,
486
515
  noReExportOnlyVitestConfigs,
487
516
  noRetiredVitestConfigs,
488
517
  prettierConfigBuildsOnSharedConfig,
@@ -5,12 +5,43 @@
5
5
  "checklists": [
6
6
  "nmr"
7
7
  ],
8
+ "esbuildVersion": "0.28.1",
9
+ "inputs": [
10
+ {
11
+ "hash": "67afdcb4",
12
+ "kind": "module",
13
+ "path": "kits/default.ts"
14
+ },
15
+ {
16
+ "hash": "2bd89ec9",
17
+ "kind": "inline",
18
+ "path": "../package.json",
19
+ "paths": [
20
+ "version"
21
+ ]
22
+ },
23
+ {
24
+ "hash": "3dfc2ee7",
25
+ "kind": "module",
26
+ "path": "../src/default-scripts.ts"
27
+ },
28
+ {
29
+ "hash": "b42c7523",
30
+ "kind": "module",
31
+ "path": "../src/resolve-scripts.ts"
32
+ },
33
+ {
34
+ "hash": "c2d21075",
35
+ "kind": "module",
36
+ "path": "../src/tiers.ts"
37
+ }
38
+ ],
8
39
  "name": "default",
9
40
  "path": "kits/default.js",
10
- "readyupVersion": "0.27.0",
41
+ "readyupVersion": "0.28.0",
11
42
  "source": "kits/default.ts",
12
- "sourceHash": "7b2a223f",
13
- "targetHash": "aaf561e4"
43
+ "sourceHash": "67afdcb4",
44
+ "targetHash": "b923a4b4"
14
45
  }
15
46
  ]
16
47
  }
package/README.md CHANGED
@@ -3,69 +3,31 @@
3
3
  Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
4
4
 
5
5
  <!-- section:release-notes -->
6
- ## Release notes — v0.31.0 (2026-08-13)
6
+ ## Release notes — v0.32.0 (2026-08-17)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - Report a verdict for every command nmr runs (#659)
10
+ - Carry the test-tier convention in the ambient rulebook (#689)
11
11
 
12
- `nmr` now reports one line for every command it runs, naming the scope, command, outcome, and timing. The lines print in every verbosity, which makes `-q` the low-noise way to run rather than a silent one: a passing quiet run reports its verdicts and nothing else, so a pass, a cached skip, and a no-op override stay distinguishable.
13
-
14
- Verdicts nest. A composite reports alongside every command it expands into, while a `:pre`/`:post` hook leaf and a `--filter`/`--recursive` delegate report nothing, since each is already covered by the levels around it.
15
-
16
- - Resolve a run's verbosity from repo config and agent-harness detection (#660)
17
-
18
- A verbosity preference for `nmr` can now be set in the repo's root config, and `nmr` now automatically switches to quiet mode when it detects a known agent harness. This makes four ways to set verbosity: the `-q` option, the `NMR_COMMAND_VERBOSITY` env var, config, and harness detection, consulted in that order and defaulting to `full` when none applies.
19
-
20
- - 🚨 **Breaking:** Refine nmr upgrade to support catalogs (#661)
21
-
22
- Adds `nmr-report-catalog`, which names the dependencies a package takes from a pnpm catalog, their `catalog:` specifiers, and the monorepo root a covering pass runs from. The workspace `upgrade` default chains it ahead of the upgrade tool, and `nmr report-catalog` runs it alone. Because taze reads `pnpm-workspace.yaml` only when that file sits at its working directory, changes to the catalog are not attempted during a package-scoped upgrade.
23
-
24
- Separately, `nmr-report-overrides` now reports the `overrides` block in `pnpm-workspace.yaml` and fails on a `pnpm.overrides` block in the root `package.json`. Both `upgrade` and `root:upgrade` run the reporter ahead of taze. This avoids a scenario where taze would meaninglessly write to the latter block.
25
-
26
- Migration: Move the block to `overrides` in `pnpm-workspace.yaml`, quoting each version, or run `pnpx codemod run pnpm-v10-to-v11`. `engines` declares `pnpm: ">=11"`.
27
-
28
- - Replay an excerpt of the run a skip recalls (#667)
29
-
30
- `nmr` can now show the result of the previous check when a check is skipped. When `nmr` runs a command, it stores the result, keyed by presentation environment. The result is deemed to be the last blank-line-delimited block written by the command. If the command is run again in the same presentation environment, the stored result is shown, labeled "replayed:" for clarity.
31
-
32
- - Assemble a skipped composite's replay from its constituents' excerpts (#668)
33
-
34
- A skipped composite now replays what its constituents recorded, with each line attributed to the scope and command that produced it:
35
-
36
- ```console
37
- ⏭️ nmr: check: passed 35.7s ago on this tree, saved ~10s — replayed: nmr: fmt:check: All matched files use Prettier code style!; nmr: test: Test Files 52 passed (52) Tests 987 passed (987)
38
- ```
39
-
40
- Nesting collapses. A constituent that fans out across packages contributes a line from every package that recorded one; a constituent that recorded nothing is omitted.
41
-
42
- A new reserved variable, `NMR_RUN_ID`, carries a run's identity to every process below it. Every recorded excerpt names the run that last certified it. A composite summary includes saved results with the same status as a fresh result.
43
-
44
- - Print a retained run with nmr --log (#669)
45
-
46
- Adds `nmr --log <command>`, which prints what the last recorded pass wrote instead of running the command again. The whole transcript, up to a limit of 256 KiB, is now saved for any passing command that is not writing straight to a terminal; `--log` prints it under a header giving the instant, the duration, and the command chain that succeeded. The log of a composite is the sum of its constituents' excerpts. If a change disqualifies the recording, `--log` instead reports the change.
47
-
48
- - Emit one JSON object per command for machine consumers (#672)
49
-
50
- Adds `--json` to `nmr`, allowing the caller to request output in JSON format instead of prose. An env var carries the choice of `json` or `text` down a spawned chain. When the output is JSON, the consumer receives one JSON object per command; each object carries the command, scope, and outcome, and a recalled pass carries the recorded excerpts.
12
+ Adds the test-tier convention to `nmr.md`, the ambient rulebook `codeassembly sync` deploys to each consuming repo's agent guidance. The guidance describes the `<subject>[.<aspect>].<tier>.test.ts` grammar and the four tiers, and warns that tests outside a `__tests__` directory are ignored. Tests not explicitly attributed to a tier are flagged by the ReadyUp kit bundled with `nmr`.
13
+
14
+ The previous guidance is removed from `AGENTS.md`.
51
15
 
52
- The mode implies quiet. Stdout carries the objects alone (with no prose verdict line and no override notice); a failing command still displays its output on stderr.
16
+ - Close each bin's output with a statement of what the run came to (#691)
53
17
 
54
- - 🚨 **Breaking:** Bind trailing arguments to every step that accepts them (#675)
18
+ Five nmr bins now end their output with a statement of the run's final result, rather than on whichever line the run happened to reach last, so the last line a reader sees carries the finding.
55
19
 
56
- Routes an argument passed to an `nmr` command to every step of that command that accepts it, in place of the last step alone. Thus, `nmr test <file>` therefore runs only that file's tests; previously, the root suite ran in full alongside the narrowed package runs. Steps by default accept passed arguments, but can be configured to ignore them. If no step of a command accepts the argument, `nmr` names the command and runs nothing.
20
+ Separately, `ensure-prepublish-hooks` prints its whole report, including failure messages, on stdout, to avoid splitting the report across two streams. Failure still results in a non-zero exit code.
57
21
 
58
- A command carrying an argument also reads the check-result cache nowhere in its chain, so it no longer skips part of its work from a pass recorded for an unnarrowed run.
22
+ - Reject a dead pnpm field in nmr's ReadyUp kit (#692)
59
23
 
60
- Migration: Existing configs need no change, since a bare string element keeps its present meaning. Two behaviors differ: A composite passes its arguments to every accepting step rather than to its final step alone; and `nmr typecheck <file>` at the repo root is refused, where it previously checked that file under default compiler options at the root and hunted for it in every package.
24
+ nmr's ReadyUp kit now checks for the presence of a `pnpm` field in any `package.json`. pnpm 11 no longer reads any key from that field, but taze (the engine underlying `nmr upgrade`) reads `pnpm.overrides`. To avoid confusion, the kit flags this as an error.
61
25
 
62
26
  ### 🐛 Bug fixes
63
27
 
64
- - Derive the shelled-nmr remedy from the declaration site and reject a malformed package.json (#658)
28
+ - Fold the running nmr's fingerprint into the build cache key (#693)
65
29
 
66
- Fixes a shelled-nmr warning that named `.config/nmr.config.ts` as the remedy for every step it reported, including the `package.json` entries that tier cannot resolve. The line now names the relevant declaration and remedy. Shelled nmr can now be detected not only in a step's first token but anywhere in the step.
67
-
68
- Separately, a configuration mistake now reports as a plain message rather than a Node stack trace.
30
+ Fixes the issue that build output could be stale, but not reported to be stale, after an edit to `nmr-compile`'s own emit logic. The build cache key now folds a fingerprint of the `nmr` running the build alongside the TypeScript version it already carried. Any build keys generated against an `nmr` with a different fingerprint will be invalidated. Repos consuming `nmr` get the same behavior.
69
31
  <!-- /section:release-notes -->
70
32
 
71
33
  ## Installation
@@ -883,7 +845,7 @@ nmr-clean
883
845
 
884
846
  ### `nmr-compile`
885
847
 
886
- Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (a content-and-path hash is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means — `nmr clean`, `rm -rf dist`, `git clean` — therefore forces a rebuild rather than a skip. This is the default `compile` script — run it from a package directory.
848
+ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (the key is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means — `nmr clean`, `rm -rf dist`, `git clean` — therefore forces a rebuild rather than a skip. This is the default `compile` script — run it from a package directory.
887
849
 
888
850
  **What it compiles.** Entry points are `src/**/*.ts` less the directories that hold test scaffolding rather than shipped code: `__fixtures__/`, `__mocks__/`, `__tests__/`, and `test-utils/`. Add to that list per package with [`build.extraIgnorePatterns`](#package-level-configuration). Ignoring a directory drops it as an _entry point_, not from the emit: the compiler still emits whatever the surviving entry points import, so a helper that production code uses is still compiled and its importer never emits a dangling specifier. This list is deliberately not the Vitest [coverage exclusions](#what-the-config-excludes) — helpers live in `test-utils/` precisely so they stay covered.
889
851
 
@@ -891,6 +853,8 @@ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler
891
853
 
892
854
  **The replacement is atomic.** The emit is buffered in memory, written to a staging directory beside the output directory, and swapped into place by rename. A build that fails for any reason -- a malformed tsconfig, an unresolvable alias, a full disk -- therefore leaves the previous output exactly as it was. `dist/esm` is never observed mid-write: it holds the previous build or the new one, and is absent only between the two renames. This matters most when the package being rebuilt supplies a binary that other packages in the same recursive build invoke.
893
855
 
856
+ **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 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 build is about to write.
857
+
894
858
  `typescript` is a peer dependency (`>=5.7.0 <7`); the consuming repo provides it. The floor is what `rewriteRelativeImportExtensions` requires; the ceiling is because TypeScript 7 ships no compiler API — its root export is a version constant, so `nmr-compile` cannot run on it. Relative imports in source must carry explicit `.ts` extensions for them to be rewritten.
895
859
 
896
860
  ```bash
@@ -940,7 +904,7 @@ ensure-prepublish-hooks
940
904
 
941
905
  ## Conformance checks
942
906
 
943
- nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry, and the [test-tier convention](#test-tiers). The kit ships inside the package, so it checks against the nmr version installed rather than whatever a repository ref happens to point at, and a tier added or renamed in nmr reaches the repo on upgrade.
907
+ nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry, the [test-tier convention](#test-tiers), and that no `package.json` in the tree declares a `pnpm` field, which pnpm 11 reads no key from. Those settings belong in `pnpm-workspace.yaml`, and `pnpx codemod run pnpm-v10-to-v11` moves them. The kit ships inside the package, so it checks against the nmr version installed rather than whatever a repository ref happens to point at, and a tier added or renamed in nmr reaches the repo on upgrade.
944
908
 
945
909
  Add `readyup` as a devDependency, then name nmr in its config:
946
910
 
@@ -13,4 +13,5 @@ delivery: ambient
13
13
  - A check that already passed on the current working tree skips, printing `⏭️ <scope>: <command>: passed …` and, where the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it, so read the replayed excerpt rather than paying for the answer again: never pass `--no-cache` to get a "real" run, which costs 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 where there are too many to fit. A skip carrying no 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 -- so output you need in full is a recording to read rather than a run to pay for again. It runs nothing, and it is held to the tree rather than to the terminal: a recording a piped run made prints for a reader at one. A composite prints its constituents' excerpts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. Nothing to show is said on one line and exits non-zero, naming which it is.
15
15
  - Every command nmr runs reports one line -- `✅ <scope>: <command>: passed in 12.4s`, `❌ … failed in 1.2s (exit 1)`, `⏭️ … passed 4m ago on this tree`, or `⛔ … skipped, the override is empty` -- and a composite reports alongside every command it expands into. The lines print in every verbosity, so `-q` is the low-noise way to run rather than a silent one: it withholds the output of the commands nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. 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
- - The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), the `nmr-compile` build (there is no repo-local build script), and the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
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 the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. Neither a file outside `__tests__` nor one whose tier segment is missing or misspelt fails anything: the first is collected by nothing and the second runs under `unit`, and no test run distinguishes either from a conformant file. `rdy run --packages` reports the misspelt tier, and nothing reports the misplaced file.
17
+ - The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), the `nmr-compile` build (there is no repo-local build script), the test tiers' timeout budgets and the `tiers` seam that raises one, which script reaches which tier, and the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
@@ -1,6 +1,6 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
- import { DEFAULT_HOOK, ensurePrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
3
+ import { DEFAULT_HOOK, ensurePrepublishHooks, reportPrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
4
4
  import { findMonorepoRoot } from "./workspace.js";
5
5
  const flagSchema = {
6
6
  fix: { long: '--fix', type: 'boolean' },
@@ -10,31 +10,10 @@ const flagSchema = {
10
10
  const { fix, dryRun, command } = parseArgsOrExit(process.argv.slice(2), flagSchema).flags;
11
11
  try {
12
12
  const monorepoRoot = findMonorepoRoot();
13
- const hookCommand = command ?? DEFAULT_HOOK;
14
13
  const options = command ? { fix, dryRun, command } : { fix, dryRun };
15
14
  const result = ensurePrepublishHooks(monorepoRoot, options);
16
- for (const pkg of result.packages) {
17
- if (pkg.isPrivate) {
18
- continue;
19
- }
20
- switch (pkg.action) {
21
- case 'ok':
22
- console.info(`✓ ${pkg.packageName}: prepublishOnly = "${pkg.prepublishOnly}"`);
23
- break;
24
- case 'missing':
25
- console.warn(`✗ ${pkg.packageName}: missing prepublishOnly`);
26
- break;
27
- case 'fixed':
28
- console.info(`✓ ${pkg.packageName}: added prepublishOnly = "${hookCommand}"`);
29
- break;
30
- case 'would-fix':
31
- console.info(`~ ${pkg.packageName}: would add prepublishOnly = "${hookCommand}"`);
32
- break;
33
- }
34
- }
15
+ reportPrepublishHooks(result, command ?? DEFAULT_HOOK);
35
16
  if (result.hasFailures) {
36
- const missing = result.packages.filter((p) => p.action === 'missing').length;
37
- process.stderr.write(`\n${missing} package(s) missing prepublishOnly. Use --fix to add it.\n`);
38
17
  process.exitCode = 1;
39
18
  }
40
19
  }
@@ -16,3 +16,4 @@ export declare function hasExpectedBuildOutput(packageDir: string, outdir: strin
16
16
  export declare function readBuildDigest(packageDir: string): Promise<string | undefined>;
17
17
  export declare function resolveBuildCachePath(packageDir: string): string;
18
18
  export declare function resolveScratchDirs(emitDir: string): ScratchDirs;
19
+ export declare function resolveToolchainFingerprint(packageDir: string, selfDir?: string): Promise<string>;
@@ -1,11 +1,13 @@
1
- import { existsSync, readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { readCacheEntry, resolveCacheEntryPath } from '@williamthorsen/nmr-core';
3
+ import { findPackageRoot, readCacheEntry, resolveCacheEntryPath } from '@williamthorsen/nmr-core';
4
4
  import { glob } from 'glob';
5
+ import { readPackageJson } from "../helpers/package-json.js";
5
6
  export const DEFAULT_ENTRY_GLOBS = ['src/**/*.ts'];
6
7
  export const DEFAULT_IGNORE_PATTERNS = ['**/__fixtures__/**', '**/__mocks__/**', '**/__tests__/**', '**/test-utils/**'];
7
8
  export const DEFAULT_OUTDIR = 'dist/esm/';
8
9
  const BUILD_CACHE_TOOL = 'nmr-compile';
10
+ const SELF_DIR = findPackageRoot(import.meta.url);
9
11
  export async function hasBuildOutput(packageDir, options = {}) {
10
12
  const outdir = options.outdir ?? DEFAULT_OUTDIR;
11
13
  const entryPoints = await glob(options.entryGlobs ?? DEFAULT_ENTRY_GLOBS, {
@@ -42,3 +44,24 @@ export function resolveScratchDirs(emitDir) {
42
44
  staging: path.join(parent, `.${name}.staging`),
43
45
  };
44
46
  }
47
+ export async function resolveToolchainFingerprint(packageDir, selfDir = SELF_DIR) {
48
+ if (!isSameDir(packageDir, selfDir)) {
49
+ const digest = await readBuildDigest(selfDir);
50
+ if (digest !== undefined) {
51
+ return digest;
52
+ }
53
+ }
54
+ return readPackageJson(selfDir).version ?? '';
55
+ }
56
+ function isSameDir(left, right) {
57
+ return toRealPath(left) === toRealPath(right);
58
+ }
59
+ function toRealPath(dir) {
60
+ const absolute = path.resolve(dir);
61
+ try {
62
+ return realpathSync(absolute);
63
+ }
64
+ catch {
65
+ return absolute;
66
+ }
67
+ }
@@ -1,4 +1,8 @@
1
1
  import type { BuildOptions } from './build-output.js';
2
+ export interface BuildToolchain {
3
+ compilerVersion: string;
4
+ fingerprint: string;
5
+ }
2
6
  export declare function buildPackage(packageDir: string, options?: BuildOptions): Promise<void>;
3
- export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object, compilerVersion: string): Promise<string>;
7
+ export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object, toolchain: BuildToolchain): Promise<string>;
4
8
  export declare function resolveTsconfigChain(packageDir: string, configFileName?: string): string[];
@@ -6,7 +6,7 @@ import { readCacheEntry, writeCacheEntry } from '@williamthorsen/nmr-core';
6
6
  import { glob } from 'glob';
7
7
  import * as ts from 'typescript';
8
8
  import { resolveConfigPath } from "../config.js";
9
- import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, } from "./build-output.js";
9
+ import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, } from "./build-output.js";
10
10
  const PACKAGE_ICON = '📦';
11
11
  const SKIPPED_ICON = '⏭️';
12
12
  const SOURCE_ROOT = 'src';
@@ -30,15 +30,20 @@ export async function buildPackage(packageDir, options = {}) {
30
30
  ...(existsSync(configPath) ? [path.relative(packageDir, configPath)] : []),
31
31
  ...resolveTsconfigChain(packageDir),
32
32
  ];
33
- const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, ts.version, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
33
+ const toolchain = {
34
+ compilerVersion: ts.version,
35
+ fingerprint: await resolveToolchainFingerprint(packageDir),
36
+ };
37
+ const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, toolchain, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
34
38
  if (!changed) {
35
39
  await discardScratchDirs(resolveScratchDirs(emitDir));
36
40
  return;
37
41
  }
38
- await emitPackage(packageDir, entryPoints, outdir);
42
+ const emittedFileCount = await emitPackage(packageDir, entryPoints, outdir);
39
43
  await writeCacheEntry(cachePath, currentHash);
44
+ console.info(`${PACKAGE_ICON} ${path.basename(packageDir)}: ${describeEmit(emittedFileCount, outdir)}`);
40
45
  }
41
- export async function computeBuildHash(packageDir, files, emitConfig, compilerVersion) {
46
+ export async function computeBuildHash(packageDir, files, emitConfig, toolchain) {
42
47
  const hash = createHash('sha256');
43
48
  for (const file of files.toSorted()) {
44
49
  hash.update(file);
@@ -47,7 +52,9 @@ export async function computeBuildHash(packageDir, files, emitConfig, compilerVe
47
52
  }
48
53
  hash.update(JSON.stringify(emitConfig));
49
54
  hash.update('\0');
50
- hash.update(compilerVersion);
55
+ hash.update(toolchain.compilerVersion);
56
+ hash.update('\0');
57
+ hash.update(toolchain.fingerprint);
51
58
  return hash.digest('hex');
52
59
  }
53
60
  export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json') {
@@ -92,10 +99,11 @@ async function emitPackage(packageDir, entryPoints, outdir) {
92
99
  }
93
100
  if (staged.size === 0) {
94
101
  await rm(emitDir, { force: true, recursive: true });
95
- return;
102
+ return 0;
96
103
  }
97
104
  writeStagedOutput(staged, emitDir, scratchDirs.staging);
98
105
  await swapIntoPlace(emitDir, scratchDirs);
106
+ return staged.size;
99
107
  }
100
108
  async function discardScratchDirs(scratchDirs) {
101
109
  try {
@@ -247,10 +255,10 @@ function getModuleSpecifier(node) {
247
255
  }
248
256
  return undefined;
249
257
  }
250
- async function detectBuildChanges(packageDir, files, emitConfig, compilerVersion, cachePath, outputPresent) {
258
+ async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath, outputPresent) {
251
259
  const packageName = path.basename(packageDir);
252
260
  const previousHash = await readCacheEntry(cachePath);
253
- const currentHash = await computeBuildHash(packageDir, files, emitConfig, compilerVersion);
261
+ const currentHash = await computeBuildHash(packageDir, files, emitConfig, toolchain);
254
262
  if (previousHash === currentHash) {
255
263
  if (outputPresent) {
256
264
  console.info(`${SKIPPED_ICON} ${packageName}: No changes detected. Skipping build.`);
@@ -278,6 +286,12 @@ function collectAliasPrefixes(compilerOptions) {
278
286
  }
279
287
  return Object.keys(compilerOptions.paths).map((pattern) => pattern.replace(/\*$/, ''));
280
288
  }
289
+ function describeEmit(fileCount, outdir) {
290
+ if (fileCount === 0) {
291
+ return 'Emitted no output.';
292
+ }
293
+ return `Compiled ${fileCount} files to ${outdir.replace(/\/+$/u, '')}.`;
294
+ }
281
295
  function formatDiagnostics(diagnostics) {
282
296
  return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
283
297
  getCurrentDirectory: () => process.cwd(),
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { removeCheckCache } from "../check-cache.js";
4
4
  import { loadRootConfig } from "../config.js";
5
5
  import { findContainingPackageDir } from "../context.js";
6
+ import { reportClosing } from "../helpers/reportClosing.js";
6
7
  import { applyDevBin, buildWorkspaceRegistry, resolveScript } from "../resolver.js";
7
8
  import { resolveChannel, runCommand } from "../runner.js";
8
9
  import { renderChain } from "../steps.js";
@@ -40,17 +41,26 @@ async function clearCheckCache(scopeDir) {
40
41
  await removeCheckCache(scopeDir);
41
42
  console.info(`${CLEAN_ICON} Removed all recorded check results.`);
42
43
  }
44
+ function describeSweep(cleaned, skipped) {
45
+ const packages = cleaned === 1 ? '1 package' : `${cleaned} packages`;
46
+ const skipClause = skipped === 0 ? '' : `, skipping ${skipped} with an empty clean override`;
47
+ return `Cleaned ${packages}${skipClause}.`;
48
+ }
43
49
  async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
44
50
  const config = await loadRootConfig(monorepoRoot);
45
51
  const registry = buildWorkspaceRegistry(config);
52
+ let cleaned = 0;
53
+ let skipped = 0;
46
54
  for (const packageDir of workspacePackageDirs) {
47
55
  const resolved = resolveScript(CLEAN_COMMAND, registry, packageDir, false);
48
56
  const resolvedCommand = resolved === undefined ? undefined : renderChain(resolved.steps);
49
57
  if (resolvedCommand === undefined || resolvedCommand === '') {
58
+ skipped++;
50
59
  continue;
51
60
  }
52
61
  if (resolvedCommand === BUILT_IN_CLEAN) {
53
62
  await cleanPackage(packageDir);
63
+ cleaned++;
54
64
  continue;
55
65
  }
56
66
  const command = applyDevBin(resolvedCommand, config.devBin, monorepoRoot);
@@ -60,5 +70,7 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
60
70
  if (exitCode !== 0) {
61
71
  throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
62
72
  }
73
+ cleaned++;
63
74
  }
75
+ reportClosing(`${CLEAN_ICON} ${describeSweep(cleaned, skipped)}`);
64
76
  }
@@ -15,3 +15,4 @@ export declare function ensurePrepublishHooks(monorepoRoot: string, options: {
15
15
  dryRun: boolean;
16
16
  command?: string;
17
17
  }): EnsurePrepublishHooksResult;
18
+ export declare function reportPrepublishHooks(result: EnsurePrepublishHooksResult, hookCommand: string): void;
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readPackageJson } from "../helpers/package-json.js";
4
+ import { reportClosing } from "../helpers/reportClosing.js";
4
5
  import { isObject } from "../helpers/type-guards.js";
5
6
  import { getWorkspacePackageDirs } from "../workspace.js";
6
7
  export const DEFAULT_HOOK = 'npm run build';
@@ -59,6 +60,17 @@ export function ensurePrepublishHooks(monorepoRoot, options) {
59
60
  const hasFailures = packages.some((p) => p.action === 'missing');
60
61
  return { packages, hasFailures };
61
62
  }
63
+ export function reportPrepublishHooks(result, hookCommand) {
64
+ const publishable = result.packages.filter((pkg) => !pkg.isPrivate);
65
+ if (publishable.length === 0) {
66
+ console.info('No publishable packages found.');
67
+ return;
68
+ }
69
+ for (const pkg of publishable) {
70
+ console.info(renderHookStatus(pkg, hookCommand));
71
+ }
72
+ reportClosing(describeHookRun(publishable));
73
+ }
62
74
  function addPrepublishOnly(packageDir, command) {
63
75
  const filePath = path.join(packageDir, 'package.json');
64
76
  const raw = readFileSync(filePath, 'utf8');
@@ -71,3 +83,34 @@ function addPrepublishOnly(packageDir, command) {
71
83
  parsed['scripts'] = scripts;
72
84
  writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
73
85
  }
86
+ function countAction(publishable, action) {
87
+ return publishable.filter((pkg) => pkg.action === action).length;
88
+ }
89
+ function describeHookRun(publishable) {
90
+ const packages = publishable.length === 1 ? '1 publishable package' : `${publishable.length} publishable packages`;
91
+ const missing = countAction(publishable, 'missing');
92
+ if (missing > 0) {
93
+ return `${missing} of ${packages} ${missing === 1 ? 'is' : 'are'} missing prepublishOnly. Run with --fix to add it.`;
94
+ }
95
+ const fixed = countAction(publishable, 'fixed');
96
+ if (fixed > 0) {
97
+ return `Added prepublishOnly to ${fixed} of ${packages}.`;
98
+ }
99
+ const wouldFix = countAction(publishable, 'would-fix');
100
+ if (wouldFix > 0) {
101
+ return `Would add prepublishOnly to ${wouldFix} of ${packages}.`;
102
+ }
103
+ return `${packages} ${publishable.length === 1 ? 'has' : 'have'} prepublishOnly.`;
104
+ }
105
+ function renderHookStatus(pkg, hookCommand) {
106
+ switch (pkg.action) {
107
+ case 'ok':
108
+ return `✓ ${pkg.packageName}: prepublishOnly = "${pkg.prepublishOnly}"`;
109
+ case 'missing':
110
+ return `✗ ${pkg.packageName}: missing prepublishOnly`;
111
+ case 'fixed':
112
+ return `✓ ${pkg.packageName}: added prepublishOnly = "${hookCommand}"`;
113
+ case 'would-fix':
114
+ return `~ ${pkg.packageName}: would add prepublishOnly = "${hookCommand}"`;
115
+ }
116
+ }
@@ -1,6 +1,8 @@
1
1
  import { findContainingPackageDir } from "../context.js";
2
2
  import { DEPENDENCY_FIELDS, readPackageJson } from "../helpers/package-json.js";
3
+ import { reportClosing } from "../helpers/reportClosing.js";
3
4
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
5
+ const CATALOG_ICON = '📚';
4
6
  const CATALOG_PROTOCOL = 'catalog:';
5
7
  export function reportCatalog(cwd) {
6
8
  const monorepoRoot = findMonorepoRoot(cwd);
@@ -12,12 +14,16 @@ export function reportCatalog(cwd) {
12
14
  if (dependencies.length === 0) {
13
15
  return;
14
16
  }
15
- const subject = dependencies.length === 1 ? '1 dependency comes' : `${dependencies.length} dependencies come`;
16
- console.warn(`📚 WARN: ${subject} from a catalog, which a package-scoped upgrade does not read:`);
17
+ console.warn(`${CATALOG_ICON} WARN: A package-scoped upgrade does not read the catalogs these come from:`);
17
18
  for (const { name, specifier } of dependencies) {
18
19
  console.warn(`- ${name} → ${specifier}`);
19
20
  }
20
- console.warn(`Run \`nmr upgrade\` from ${monorepoRoot} to include them.`);
21
+ reportClosing(`${CATALOG_ICON} ${describeCatalog(dependencies.length, monorepoRoot)}`, console.warn);
22
+ }
23
+ function describeCatalog(count, monorepoRoot) {
24
+ const subject = count === 1 ? '1 catalogued dependency went' : `${count} catalogued dependencies went`;
25
+ const object = count === 1 ? 'it' : 'them';
26
+ return `${subject} unread. Run \`nmr upgrade\` from ${monorepoRoot} to include ${object}.`;
21
27
  }
22
28
  function findCataloguedDependencies(packageDir) {
23
29
  const pkg = readPackageJson(packageDir);
@@ -1,16 +1,24 @@
1
1
  import { getPnpmOverrides, readPackageJson } from "../helpers/package-json.js";
2
+ import { reportClosing } from "../helpers/reportClosing.js";
2
3
  import { UserError } from "../UserError.js";
3
4
  import { readWorkspaceOverrides } from "../workspace.js";
5
+ const OVERRIDES_ICON = '🔒';
4
6
  export function reportOverrides(monorepoRoot) {
5
7
  const declared = listEntries(readWorkspaceOverrides(monorepoRoot));
6
8
  if (declared.length > 0) {
7
- console.warn('🔒 WARN: pnpm overrides are active! Check whether these are still needed:');
9
+ console.warn(`${OVERRIDES_ICON} WARN: pnpm overrides are active:`);
8
10
  for (const [name, version] of declared) {
9
11
  console.warn(`- ${name} → ${version}`);
10
12
  }
13
+ reportClosing(`${OVERRIDES_ICON} ${describeOverrides(declared.length)}`, console.warn);
11
14
  }
12
15
  rejectLegacyOverrides(monorepoRoot);
13
16
  }
17
+ function describeOverrides(count) {
18
+ return count === 1
19
+ ? '1 override is active. Check whether it is still needed.'
20
+ : `${count} overrides are active. Check whether they are still needed.`;
21
+ }
14
22
  function listEntries(overrides) {
15
23
  return Object.entries(overrides ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
16
24
  }
@@ -0,0 +1 @@
1
+ export declare function reportClosing(message: string, log?: (line: string) => void): void;
@@ -0,0 +1,3 @@
1
+ export function reportClosing(message, log = console.info) {
2
+ log(`\n${message}`);
3
+ }
@@ -1,11 +1,11 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
- import { describeError } from '@williamthorsen/toolbelt.errors';
2
+ import { describeError, isError } from '@williamthorsen/toolbelt.errors';
3
3
  import { UserError } from "./UserError.js";
4
4
  export function reportCliFailure(error, stderr) {
5
5
  if (error instanceof UserError) {
6
6
  reportError(error.message, stderr);
7
7
  return;
8
8
  }
9
- const detail = error instanceof Error && error.stack !== undefined ? error.stack : describeError(error);
9
+ const detail = isError(error) && error.stack !== undefined ? error.stack : describeError(error);
10
10
  stderr.write(`${detail}\n`);
11
11
  }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { hasErrnoCode } from '@williamthorsen/nmr-core';
3
4
  import { parsePackageJson, readScriptRecord, resolvePackageJsonPath } from "./helpers/package-json.js";
4
5
  import { isObject } from "./helpers/type-guards.js";
5
6
  import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
@@ -62,7 +63,7 @@ export function readPackageJsonScripts(packageDir) {
62
63
  raw = readFileSync(file, 'utf8');
63
64
  }
64
65
  catch (error) {
65
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
66
+ if (hasErrnoCode(error, 'ENOENT')) {
66
67
  return undefined;
67
68
  }
68
69
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -68,16 +68,18 @@
68
68
  "dist"
69
69
  ],
70
70
  "dependencies": {
71
- "@williamthorsen/toolbelt.errors": "0.4.0",
71
+ "@williamthorsen/toolbelt.errors": "0.5.0",
72
72
  "glob": "13.0.6",
73
73
  "prettier-plugin-sh": "0.19.0",
74
- "taze": "20.0.0",
74
+ "taze": "20.0.1",
75
75
  "yaml": "2.9.0",
76
- "@williamthorsen/nmr-core": "0.10.0"
76
+ "@williamthorsen/nmr-core": "0.10.1"
77
77
  },
78
78
  "devDependencies": {
79
+ "@williamthorsen/toolbelt.testing": "0.3.0",
80
+ "@williamthorsen/toolbelt.vitest": "0.4.0",
79
81
  "prettier": "3.9.6",
80
- "readyup": "0.27.0",
82
+ "readyup": "0.28.0",
81
83
  "tinyglobby": "0.2.17",
82
84
  "typescript": "6.0.3",
83
85
  "vitest": "4.1.10"