@williamthorsen/nmr 0.38.0 โ†’ 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.meta/changelog.json +84 -0
  2. package/.readyup/kits/default.js +84 -79
  3. package/.readyup/manifest.json +5 -5
  4. package/CHANGELOG.md +70 -0
  5. package/README.md +37 -11
  6. package/dist/esm/check-cache.d.ts +2 -2
  7. package/dist/esm/check-cache.js +24 -24
  8. package/dist/esm/cli-ensure-prepublish-hooks.js +4 -4
  9. package/dist/esm/commands/build-output.d.ts +2 -2
  10. package/dist/esm/commands/build-output.js +9 -9
  11. package/dist/esm/commands/build.js +65 -63
  12. package/dist/esm/commands/clean.js +11 -11
  13. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +2 -2
  14. package/dist/esm/commands/ensure-prepublish-hooks.js +41 -41
  15. package/dist/esm/commands/fmt.js +33 -33
  16. package/dist/esm/commands/report-catalog.js +9 -7
  17. package/dist/esm/commands/report-overrides.js +7 -7
  18. package/dist/esm/config.js +25 -25
  19. package/dist/esm/context.js +8 -8
  20. package/dist/esm/help.d.ts +2 -2
  21. package/dist/esm/help.js +21 -21
  22. package/dist/esm/helpers/clampToBytes.js +3 -3
  23. package/dist/esm/helpers/createBoundedBuffer.d.ts +1 -1
  24. package/dist/esm/helpers/createBoundedBuffer.js +2 -2
  25. package/dist/esm/helpers/duration.d.ts +2 -2
  26. package/dist/esm/helpers/duration.js +7 -7
  27. package/dist/esm/helpers/filter-selection.js +12 -12
  28. package/dist/esm/helpers/findClosestName.js +15 -15
  29. package/dist/esm/helpers/package-json.d.ts +2 -2
  30. package/dist/esm/helpers/package-json.js +26 -25
  31. package/dist/esm/helpers/transcript.d.ts +2 -2
  32. package/dist/esm/helpers/transcript.js +14 -14
  33. package/dist/esm/prettier.js +5 -5
  34. package/dist/esm/recording.d.ts +5 -5
  35. package/dist/esm/recording.js +21 -14
  36. package/dist/esm/report-format.d.ts +1 -1
  37. package/dist/esm/report-format.js +7 -7
  38. package/dist/esm/resolveBinStyles.js +3 -3
  39. package/dist/esm/resolver.d.ts +2 -2
  40. package/dist/esm/resolver.js +15 -15
  41. package/dist/esm/runCli.js +252 -186
  42. package/dist/esm/runner.d.ts +1 -1
  43. package/dist/esm/runner.js +18 -18
  44. package/dist/esm/steps.d.ts +13 -1
  45. package/dist/esm/steps.js +32 -27
  46. package/dist/esm/tests.d.ts +7 -7
  47. package/dist/esm/tests.js +20 -8
  48. package/dist/esm/tiers.d.ts +1 -1
  49. package/dist/esm/tiers.js +8 -8
  50. package/dist/esm/verbosity.d.ts +1 -1
  51. package/dist/esm/verbosity.js +7 -7
  52. package/dist/esm/verdict.d.ts +9 -2
  53. package/dist/esm/verdict.js +63 -50
  54. package/dist/esm/vitest-source-resolution.js +48 -46
  55. package/dist/esm/vitest.d.ts +2 -2
  56. package/dist/esm/vitest.js +74 -25
  57. package/dist/esm/workspace.d.ts +2 -18
  58. package/dist/esm/workspace.js +9 -67
  59. package/docs/reporting.md +6 -2
  60. package/docs/vitest.md +6 -6
  61. package/docs/workspace.md +24 -1
  62. package/package.json +3 -4
  63. package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
  64. package/dist/esm/helpers/workspace-patterns.js +0 -29
@@ -1,4 +1,88 @@
1
1
  [
2
+ {
3
+ "version": "0.39.0",
4
+ "date": "2026-09-22",
5
+ "sections": [
6
+ {
7
+ "title": "๐ŸŽ‰ Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Report why a workspace selects no package, and no-op nmr's own -R steps (#841)",
12
+ "body": "- Drops a `-R` step that nmr composed into a script where the workspace holds no package, so `typecheck` and the five `test` scripts run there instead of failing the run.\n- Adds a no-op verdict for a command that the drop leaves with no step at all.\n- Replaces the single refusal for a workspace that selects no package with one message per cause.\n\nMigration: In a repo that declares a workspace package pattern and deliberately holds no package, delete the `.config/nmr.config.ts` entries added to work around the `-R` refusal, the `build: ':'` override included.",
13
+ "migration": "In a repo that declares a workspace package pattern and deliberately holds no package, delete the `.config/nmr.config.ts` entries added to work around the `-R` refusal, the `build: ':'` override included.",
14
+ "hash": "0b21f4b29a40aab5c56f10026789e8c0e72161ab"
15
+ },
16
+ {
17
+ "description": "Prefix the booleans and reject unrecognized Vitest option keys (#849)",
18
+ "body": "- Renames two boolean options of `defineVitestConfig` and `defineRootVitestConfig`, the factories that build a repo's Vitest config: `isolateGit` becomes `shouldIsolateGit`, and `resolveFromSource` becomes `shouldResolveFromSource`.\n- Makes both factories reject an option key outside the recognized set, naming the offending keys and the recognized ones, rather than ignoring a misspelled or retired key and letting its option fall back to the default with nothing in the run reporting it.\n- Adds a retired-key check that runs ahead of the unrecognized-key check, reporting the new name for a config that still sets `isolateGit` or `resolveFromSource`.\n\nMigration: In each options object passed to `defineVitestConfig` or `defineRootVitestConfig`, rename `isolateGit` to `shouldIsolateGit` and `resolveFromSource` to `shouldResolveFromSource`, and correct any other key that the factory now rejects. `defineRootVitestConfig` recognizes `monorepoRoot` on the last layer alone, so a config that sets it on an earlier layer now throws where the old code ignored it.",
19
+ "breaking": true,
20
+ "migration": "In each options object passed to `defineVitestConfig` or `defineRootVitestConfig`, rename `isolateGit` to `shouldIsolateGit` and `resolveFromSource` to `shouldResolveFromSource`, and correct any other key that the factory now rejects. `defineRootVitestConfig` recognizes `monorepoRoot` on the last layer alone, so a config that sets it on an earlier layer now throws where the old code ignored it.",
21
+ "hash": "ed2d1ba9a505c3a6d9fd5455c23bd110b9b8a7c5"
22
+ },
23
+ {
24
+ "description": "End nmr's source value names with the kind of the value (#850)",
25
+ "body": "- Renames two fields of `TestFileConventionsReport`, `misplaced` and `untiered`, to `misplacedFiles` and `untieredFiles`, and renames `TestFileConventionsOptions`'s `exclude` to `excludedBasenames`, in the `@williamthorsen/nmr/tests` entry point.\n- Adds a guard to `checkTestFileConventions` and `reportTestFileConventions` that throws a `TypeError` naming `excludedBasenames` when a call still sets `exclude`.\n\nMigration: Read `report.misplacedFiles` and `report.untieredFiles` in place of `report.misplaced` and `report.untiered`, and pass `excludedBasenames` in place of `exclude`. A call that still sets `exclude` throws rather than running with no exclusions, so the failure surfaces where Vitest loads the call site.",
26
+ "breaking": true,
27
+ "migration": "Read `report.misplacedFiles` and `report.untieredFiles` in place of `report.misplaced` and `report.untiered`, and pass `excludedBasenames` in place of `exclude`. A call that still sets `exclude` throws rather than running with no exclusions, so the failure surfaces where Vitest loads the call site.",
28
+ "hash": "059c249e2857bed87cefcce02e894ce3a9a65970"
29
+ },
30
+ {
31
+ "description": "Hoist the pnpm-workspace resolver into nmr-core as a total API (#864)",
32
+ "body": "- Adds a `./workspace` subpath export to `@williamthorsen/nmr-core`, whose `resolveWorkspace` returns a pnpm workspace's package directories, or the cause of an empty result, as a value rather than as a thrown error.\n- Reports a `pnpm-workspace.yaml` that the YAML parser rejects under a cause of its own, `unreadable-manifest`, so `nmr -R build` names the syntax error and its remedy in place of failing with the parser's own error.\n- Removes `diagnoseEmptyWorkspace` and `EmptyWorkspaceDiagnosis` from `@williamthorsen/nmr/workspace`, since `resolveWorkspace` reports the same cause in one call.\n\nMigration: Replace each `diagnoseEmptyWorkspace` call with `resolveWorkspace`, which reports the cause on the empty result it already returns, and rename the `no-manifest` cause to `no-package`. `EmptyWorkspaceCause` also gains `unreadable-manifest`, so an exhaustive switch over it stops compiling until it handles the new member; every other export of `@williamthorsen/nmr/workspace` keeps its signature and its `UserError` throws.",
33
+ "breaking": true,
34
+ "migration": "Replace each `diagnoseEmptyWorkspace` call with `resolveWorkspace`, which reports the cause on the empty result it already returns, and rename the `no-manifest` cause to `no-package`. `EmptyWorkspaceCause` also gains `unreadable-manifest`, so an exhaustive switch over it stops compiling until it handles the new member; every other export of `@williamthorsen/nmr/workspace` keeps its signature and its `UserError` throws.",
35
+ "hash": "3196b84c9d5f037ead0e98ee8547fe71e80fb5c6"
36
+ },
37
+ {
38
+ "description": "Adopt the shared workspace resolver in release-kit, honoring exclusions (#865)",
39
+ "body": "- ๐Ÿšจ **Breaking:** Narrows `no-pattern` in `@williamthorsen/nmr-core`'s `EmptyWorkspaceCause`, moving an absent or empty `packages` list onto `no-packages-list` and a value that is not a list of strings onto `unreadable-packages`.\n- Stops `release-kit` from tagging, publishing, and label-syncing a package that a `!` entry in `pnpm-workspace.yaml` excludes, which its previous `glob`-based resolution could not remove from the match.\n- Replaces the release of the root package alone with a failure naming the condition that emptied the workspace, when `pnpm-workspace.yaml` does not hold valid YAML or declares a workspace that does not resolve to a package.\n- Replaces the `no-pattern` remedy that `nmr` printed for a `pnpm-workspace.yaml` declaring no `packages` list, and for one declaring a `packages` value that is not a list of strings.\n- Raises `engines.node` to `>=24.16.0` in `@williamthorsen/nmr-core` and `@williamthorsen/release-kit`, because Node 24.0 through 24.15 ignores the `followSymlinks` option that the workspace matcher passes to `globSync` and drops a symlinked package directory from the resolution without reporting it.\n\nMigration: Add a `no-packages-list` arm and an `unreadable-packages` arm to any `switch` that covers `EmptyWorkspaceCause` exhaustively. `no-pattern` now names only a list that the resolver read in full and whose entries all failed to become a positive pattern, so move the handling of an absent `packages` key, an empty list, and a value that is not a list of strings out of the `no-pattern` arm into the two new ones. Route `no-packages-list` to single-package behavior rather than to a failure path: pnpm resolves such a manifest to the root package alone.",
40
+ "breaking": true,
41
+ "migration": "Add a `no-packages-list` arm and an `unreadable-packages` arm to any `switch` that covers `EmptyWorkspaceCause` exhaustively. `no-pattern` now names only a list that the resolver read in full and whose entries all failed to become a positive pattern, so move the handling of an absent `packages` key, an empty list, and a value that is not a list of strings out of the `no-pattern` arm into the two new ones. Route `no-packages-list` to single-package behavior rather than to a failure path: pnpm resolves such a manifest to the root package alone.",
42
+ "hash": "b1126866159402b8f027a9b82f2a19591a6e9e1b"
43
+ }
44
+ ]
45
+ },
46
+ {
47
+ "title": "โ™ป๏ธ Refactoring",
48
+ "audience": "dev",
49
+ "items": [
50
+ {
51
+ "description": "Align nmr's function names with naming conventions (#852)",
52
+ "body": "- Renames the functions in `packages/nmr` to align with naming conventions.",
53
+ "hash": "8a41a206ebe0374c844b20dca0eded7da6240133"
54
+ },
55
+ {
56
+ "description": "Suffix nmr's unit-measuring numbers and expand its abbreviations (#854)",
57
+ "body": "- Renames every unit-measuring number and abbreviated identifier under `packages/nmr/src`, adding `Ms`, `Bytes`, and `Offset` suffixes to the quantities and expanding `pkg`, `Len`, and `Idx` to `package`, `Length`, and `Index`. The renames are not part of any public API.",
58
+ "hash": "f8292228095a475b9a187ba64d700cb222f80305"
59
+ }
60
+ ]
61
+ },
62
+ {
63
+ "title": "๐Ÿงช Tests",
64
+ "audience": "dev",
65
+ "items": [
66
+ {
67
+ "description": "End nmr's test value names with the kind of the value (#851)",
68
+ "body": "- Aligns the name of every non-boolean value in `packages/nmr/src/**/__tests__` with naming conventions.\n- Renames `inline` to `inlineBody` in the repo-root `__tests__/reusable-workflow-runtime.app.unit.test.ts`.",
69
+ "hash": "d12494b2be31f0e73cda1afb7113e7508e9969ee"
70
+ }
71
+ ]
72
+ },
73
+ {
74
+ "title": "๐Ÿ‘ท CI",
75
+ "audience": "dev",
76
+ "items": [
77
+ {
78
+ "description": "Make the release workflow Corepack-agnostic (#842)",
79
+ "body": "- Removes the `Disable Corepack` step from `release.reusable.yaml`, which Node 25's removal of Corepack from the distribution would make fail with `command not found` once `actions/setup-node` moves past Node 24, and keeps `COREPACK_ENABLE_STRICT: '0'`, which guards the source-build path where the pnpm `packageManager` pin survives.\n- Adds a case to `reusable-workflow-runtime.app.unit.test.ts` that fails the test run on any reusable workflow whose `run:` commands invoke `corepack`, enumerated from `.github/workflows` so that a workflow added later is covered without editing a list.\n- Drops the Corepack recommendation from the README's pnpm prerequisite, which now points at pnpm's installation page, and from the fix text of the `.tool-versions does not list pnpm` check in nmr's readyup kit, which now names the `packageManager` field.",
80
+ "hash": "dd46b629c3b4448b701ded3301f6794bdea7af6b"
81
+ }
82
+ ]
83
+ }
84
+ ]
85
+ },
2
86
  {
3
87
  "version": "0.38.0",
4
88
  "date": "2026-09-18",
@@ -144,26 +144,26 @@ function hasTierInfix(filePath) {
144
144
  }
145
145
  var TEST_COLLECTION_EXCLUDE = [".git", "coverage", "dist", "node_modules"];
146
146
  var TIER_NAMES = ["unit", "tool", "localhost", "remote"];
147
- function collectTestFiles(dir, relativeDir, inTestDir, context) {
147
+ function collectTestFiles(dir, relativeDir, isInTestDir, context) {
148
148
  const entries = readdirSync(dir, { withFileTypes: true });
149
149
  for (const entry of entries) {
150
150
  const relativePath = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
151
151
  if (entry.isDirectory()) {
152
152
  if (context.pruned.has(entry.name)) continue;
153
- collectTestFiles(path.join(dir, entry.name), relativePath, inTestDir || entry.name === TEST_DIR, context);
154
- } else if (inTestDir !== context.misplaced && TEST_FILE_PATTERN.test(entry.name)) {
155
- context.found.push(relativePath);
153
+ collectTestFiles(path.join(dir, entry.name), relativePath, isInTestDir || entry.name === TEST_DIR, context);
154
+ } else if (isInTestDir !== context.misplaced && TEST_FILE_PATTERN.test(entry.name)) {
155
+ context.foundPaths.push(relativePath);
156
156
  }
157
157
  }
158
158
  }
159
- function walkTestFiles(rootDir, { exclude = [] }, misplaced) {
159
+ function walkTestFiles(rootDir, { excludedBasenames = [] }, misplaced) {
160
160
  const context = {
161
- found: [],
161
+ foundPaths: [],
162
162
  misplaced,
163
- pruned: /* @__PURE__ */ new Set([...TEST_COLLECTION_EXCLUDE, ...exclude])
163
+ pruned: /* @__PURE__ */ new Set([...TEST_COLLECTION_EXCLUDE, ...excludedBasenames])
164
164
  };
165
165
  collectTestFiles(rootDir, "", false, context);
166
- return context.found.toSorted();
166
+ return context.foundPaths.toSorted();
167
167
  }
168
168
 
169
169
  // .readyup/kits/default.ts
@@ -210,7 +210,7 @@ var default_default = defineRdyKit({
210
210
  severity: "warn",
211
211
  quiet: true,
212
212
  check: toolVersionsHasNoPnpm,
213
- fix: "Remove pnpm from .tool-versions \u2014 manage via packageManager field and corepack"
213
+ fix: "Remove pnpm from .tool-versions \u2014 manage via the packageManager field"
214
214
  },
215
215
  {
216
216
  name: "no package.json declares a pnpm field",
@@ -316,7 +316,7 @@ var default_default = defineRdyKit({
316
316
  name: "the test suite gates the test-file conventions",
317
317
  severity: "warn",
318
318
  check: () => testSuiteGatesTestFileConventions(),
319
- fix: "Add a test of the repo's own under a __tests__ directory that declares the check: import { checkTestFileConventions } from '@williamthorsen/nmr/tests'; checkTestFileConventions(); -- passing `exclude` the directory names that the repo passes to `testCollectionExclude`. Without it no test run reports an untiered or misplaced test file, and this kit reports them in its place against nmr's built-in exclusions alone"
319
+ fix: "Add a test of the repo's own under a __tests__ directory that declares the check: import { checkTestFileConventions } from '@williamthorsen/nmr/tests'; checkTestFileConventions(); -- passing `excludedBasenames` the directory names that the repo passes to `testCollectionExclude`. Without it no test run reports an untiered or misplaced test file, and this kit reports them in its place against nmr's built-in exclusions alone"
320
320
  },
321
321
  {
322
322
  name: "every test file names its isolation tier",
@@ -330,7 +330,7 @@ var default_default = defineRdyKit({
330
330
  severity: "error",
331
331
  skip: () => describeConventionsGuardSkip(),
332
332
  check: () => everyTestFileSitsUnderTestsDir(),
333
- fix: "Move each into a __tests__ directory, the only place from which the shared Vitest config collects. No project collects a file outside one, so it runs nowhere and reports nothing. For a file that is not a test, declare checkTestFileConventions with its directory in `exclude` instead, which this check then defers to"
333
+ fix: "Move each into a __tests__ directory, the only place from which the shared Vitest config collects. No project collects a file outside one, so it runs nowhere and reports nothing. For a file that is not a test, declare checkTestFileConventions with its directory in `excludedBasenames` instead, which this check then defers to"
334
334
  },
335
335
  {
336
336
  name: "no package re-exports the ancestor Vitest config",
@@ -424,7 +424,7 @@ function allWorkspacePackagesCanBuild() {
424
424
  const packagesDir = join(process.cwd(), "packages");
425
425
  if (!existsSync(packagesDir)) return true;
426
426
  const entries = readdirSync2(packagesDir, { withFileTypes: true });
427
- const failing = [];
427
+ const failingPackages = [];
428
428
  for (const entry of entries) {
429
429
  if (!entry.isDirectory()) continue;
430
430
  const pkgPath = `packages/${entry.name}/package.json`;
@@ -433,30 +433,33 @@ function allWorkspacePackagesCanBuild() {
433
433
  const hasBuildOverride = /"build"\s*:/.test(content);
434
434
  const hasDefaultBuildInputs = fileExists(`packages/${entry.name}/tsconfig.json`) && existsSync(join(packagesDir, entry.name, "src"));
435
435
  if (!hasBuildOverride && !hasDefaultBuildInputs) {
436
- failing.push(entry.name);
436
+ failingPackages.push(entry.name);
437
437
  }
438
438
  }
439
- if (failing.length === 0) return true;
439
+ if (failingPackages.length === 0) return true;
440
440
  return {
441
441
  ok: false,
442
- detail: `missing build override or tsconfig.json + src/: ${failing.join(", ")}`
442
+ detail: `missing build override or tsconfig.json + src/: ${failingPackages.join(", ")}`
443
443
  };
444
444
  }
445
445
  function checkNoMatchingFiles(patterns, cwd) {
446
- const found = findFiles(patterns, cwd);
447
- if (found.length === 0) return true;
448
- return { ok: false, detail: formatPaths(found) };
446
+ const foundFiles = findFiles(patterns, cwd);
447
+ if (foundFiles.length === 0) return true;
448
+ return { ok: false, detail: formatPaths(foundFiles) };
449
449
  }
450
450
  function checkRootVitestConfig(baseName, exportName, cwd) {
451
451
  const matches = findFiles([`${baseName}.${CONFIG_EXTENSIONS}`], cwd);
452
452
  if (matches.length === 0) {
453
453
  return { ok: false, detail: `${baseName}.ts is missing` };
454
454
  }
455
- const stale = matches.filter(
455
+ const staleConfigs = matches.filter(
456
456
  (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), exportName, SHARED_VITEST_MODULE)
457
457
  );
458
- if (stale.length === 0) return true;
459
- return { ok: false, detail: `does not import ${exportName} from ${SHARED_VITEST_MODULE}: ${stale.join(", ")}` };
458
+ if (staleConfigs.length === 0) return true;
459
+ return {
460
+ ok: false,
461
+ detail: `does not import ${exportName} from ${SHARED_VITEST_MODULE}: ${staleConfigs.join(", ")}`
462
+ };
460
463
  }
461
464
  function prettierConfigBuildsOnSharedConfig(cwd = process.cwd()) {
462
465
  const configs = findFiles(
@@ -467,18 +470,18 @@ function prettierConfigBuildsOnSharedConfig(cwd = process.cwd()) {
467
470
  if (configs.length === 0) {
468
471
  return { ok: false, detail: describeMissingPrettierConfig(cwd) };
469
472
  }
470
- const stale = configs.filter(
473
+ const staleConfigs = configs.filter(
471
474
  (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "definePrettierConfig", SHARED_PRETTIER_MODULE)
472
475
  );
473
- if (stale.length === 0) return true;
476
+ if (staleConfigs.length === 0) return true;
474
477
  return {
475
478
  ok: false,
476
- detail: `does not import definePrettierConfig from ${SHARED_PRETTIER_MODULE}: ${stale.join(", ")}`
479
+ detail: `does not import definePrettierConfig from ${SHARED_PRETTIER_MODULE}: ${staleConfigs.join(", ")}`
477
480
  };
478
481
  }
479
482
  function describeMissingPrettierConfig(cwd) {
480
- const inert = findFiles(INERT_PRETTIER_CONFIGS, cwd);
481
- if (inert.length > 0) return `holds no code to call the factory: ${inert.join(", ")}`;
483
+ const inertConfigs = findFiles(INERT_PRETTIER_CONFIGS, cwd);
484
+ if (inertConfigs.length > 0) return `holds no code to call the factory: ${inertConfigs.join(", ")}`;
482
485
  if (hasPrettierConfigKey(cwd)) {
483
486
  return 'holds no code to call the factory: the "prettier" key in package.json';
484
487
  }
@@ -488,8 +491,8 @@ function hasPrettierConfigKey(cwd) {
488
491
  const manifest = readFileIn(cwd, "package.json");
489
492
  if (manifest === void 0) return false;
490
493
  try {
491
- const parsed = JSON.parse(manifest);
492
- return isRecord(parsed) && parsed["prettier"] !== void 0;
494
+ const parsedManifest = JSON.parse(manifest);
495
+ return isRecord(parsedManifest) && parsedManifest["prettier"] !== void 0;
493
496
  } catch {
494
497
  return false;
495
498
  }
@@ -502,11 +505,11 @@ function discoverMemberWorkspaces() {
502
505
  }
503
506
  }
504
507
  async function everyBinTargetIsACommittedWrapper() {
505
- const discovery = discoverMemberWorkspaces();
506
- if (!discovery.ok) return discovery;
507
- const tracked = await listTrackedFiles();
508
- const trackedPaths = tracked === void 0 ? void 0 : new Set(tracked);
509
- const offenders = discovery.workspaces.flatMap(
508
+ const workspaceDiscovery = discoverMemberWorkspaces();
509
+ if (!workspaceDiscovery.ok) return workspaceDiscovery;
510
+ const trackedFiles = await listTrackedFiles();
511
+ const trackedPaths = trackedFiles === void 0 ? void 0 : new Set(trackedFiles);
512
+ const offenders = workspaceDiscovery.workspaces.flatMap(
510
513
  (workspace) => readBinEntries(workspace).flatMap((entry) => {
511
514
  const defect = describeBinTargetDefect(workspace, entry, trackedPaths);
512
515
  return defect === void 0 ? [] : [`${describeBinEntry(workspace, entry)} (${defect})`];
@@ -516,16 +519,18 @@ async function everyBinTargetIsACommittedWrapper() {
516
519
  return { ok: false, detail: formatPaths(offenders) };
517
520
  }
518
521
  function everyBinWrapperTargetIsCoveredByFiles() {
519
- const discovery = discoverMemberWorkspaces();
520
- if (!discovery.ok) return discovery;
522
+ const workspaceDiscovery = discoverMemberWorkspaces();
523
+ if (!workspaceDiscovery.ok) return workspaceDiscovery;
521
524
  const cwd = process.cwd();
522
- const offenders = discovery.workspaces.flatMap((workspace) => {
525
+ const offenders = workspaceDiscovery.workspaces.flatMap((workspace) => {
523
526
  const files = workspace.packageJson["files"];
524
527
  if (!Array.isArray(files)) return [];
525
- const published = new Set(files.flatMap((entry) => typeof entry === "string" ? [readFirstSegment(entry)] : []));
528
+ const publishedSegments = new Set(
529
+ files.flatMap((entry) => typeof entry === "string" ? [readFirstSegment(entry)] : [])
530
+ );
526
531
  return readBinEntries(workspace).flatMap((entry) => {
527
532
  const target = readWrapperTarget(cwd, workspace, entry);
528
- if (target === void 0 || published.has(readFirstSegment(target))) return [];
533
+ if (target === void 0 || publishedSegments.has(readFirstSegment(target))) return [];
529
534
  return [`${describeBinEntry(workspace, entry)} -> ${target}`];
530
535
  });
531
536
  });
@@ -566,25 +571,25 @@ function readWrapperTarget(cwd, workspace, entry) {
566
571
  return posix.normalize(posix.join(posix.dirname(entry.target), specifier));
567
572
  }
568
573
  function everyTestFileNamesItsTier(cwd = process.cwd()) {
569
- const untiered = findUntieredTestFiles(cwd);
570
- if (untiered.length === 0) return true;
571
- return { ok: false, detail: formatPaths(untiered) };
574
+ const untieredFiles = findUntieredTestFiles(cwd);
575
+ if (untieredFiles.length === 0) return true;
576
+ return { ok: false, detail: formatPaths(untieredFiles) };
572
577
  }
573
578
  function everyTestFileSitsUnderTestsDir(cwd = process.cwd()) {
574
- const misplaced = findMisplacedTestFiles(cwd);
575
- if (misplaced.length === 0) return true;
576
- return { ok: false, detail: formatPaths(misplaced) };
579
+ const misplacedFiles = findMisplacedTestFiles(cwd);
580
+ if (misplacedFiles.length === 0) return true;
581
+ return { ok: false, detail: formatPaths(misplacedFiles) };
577
582
  }
578
583
  function everyViteConfigHasVitestConfig() {
579
- const discovery = discoverMemberWorkspaces();
580
- if (!discovery.ok) return discovery;
581
- const unpaired = discovery.workspaces.flatMap((workspace) => {
584
+ const workspaceDiscovery = discoverMemberWorkspaces();
585
+ if (!workspaceDiscovery.ok) return workspaceDiscovery;
586
+ const unpairedConfigs = workspaceDiscovery.workspaces.flatMap((workspace) => {
582
587
  const viteConfigs = findWorkspaceConfigs(workspace, VITE_CONFIG_PATTERN);
583
588
  if (viteConfigs.length === 0) return [];
584
589
  return findWorkspaceConfigs(workspace, VITEST_CONFIG_PATTERN).length > 0 ? [] : viteConfigs;
585
590
  });
586
- if (unpaired.length === 0) return true;
587
- return { ok: false, detail: formatPaths(unpaired) };
591
+ if (unpairedConfigs.length === 0) return true;
592
+ return { ok: false, detail: formatPaths(unpairedConfigs) };
588
593
  }
589
594
  function findFiles(patterns, cwd) {
590
595
  return globSync(patterns, { cwd, exclude: (path2) => SCAN_EXCLUDE_DIRS.has(basename(path2)) }).map((path2) => path2.split(sep).join("/")).toSorted();
@@ -597,11 +602,11 @@ function formatPaths(paths) {
597
602
  ${paths.map((path2) => ` ${path2}`).join("\n")}`;
598
603
  }
599
604
  function getMinVersion() {
600
- const picked = { "version": "0.38.0" };
601
- if (typeof picked["version"] !== "string") {
605
+ const pickedFields = { "version": "0.39.0" };
606
+ if (typeof pickedFields["version"] !== "string") {
602
607
  throw new TypeError("nmr/package.json: 'version' must be a string");
603
608
  }
604
- return picked["version"];
609
+ return pickedFields["version"];
605
610
  }
606
611
  function hasSupportedEslintVersion() {
607
612
  return hasMinDevDependencyVersion("eslint", MIN_ESLINT_VERSION, {
@@ -630,13 +635,13 @@ function isReExportOnly(content) {
630
635
  return statements.length > 0 && statements.every((line) => RE_EXPORT_LINE_PATTERN.test(line));
631
636
  }
632
637
  function noPnpmFieldInPackageJson(cwd = process.cwd()) {
633
- const declaring = findFiles(["**/package.json"], cwd).flatMap((relativePath) => {
638
+ const declaringFiles = findFiles(["**/package.json"], cwd).flatMap((relativePath) => {
634
639
  const keys = readPnpmFieldKeys(readFileIn(cwd, relativePath));
635
640
  if (keys === void 0) return [];
636
641
  return [keys.length > 0 ? `${relativePath} (${keys.join(", ")})` : relativePath];
637
642
  });
638
- if (declaring.length === 0) return true;
639
- return { ok: false, detail: formatPaths(declaring) };
643
+ if (declaringFiles.length === 0) return true;
644
+ return { ok: false, detail: formatPaths(declaringFiles) };
640
645
  }
641
646
  function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
642
647
  const nonRootConfigs = findFiles([`**/${VITEST_CONFIG_PATTERN}`], cwd).filter((path2) => path2.includes("/"));
@@ -650,11 +655,11 @@ function noRedundantRootScripts() {
650
655
  const scripts = pkg["scripts"];
651
656
  if (!isRecord(scripts)) return true;
652
657
  const builtInNames = Object.keys(getDefaultRootScripts());
653
- const redundant = Object.keys(scripts).filter((name) => builtInNames.includes(name));
654
- if (redundant.length === 0) return true;
658
+ const redundantNames = Object.keys(scripts).filter((name) => builtInNames.includes(name));
659
+ if (redundantNames.length === 0) return true;
655
660
  return {
656
661
  ok: false,
657
- detail: `redundant: ${redundant.join(", ")}`
662
+ detail: `redundant: ${redundantNames.join(", ")}`
658
663
  };
659
664
  }
660
665
  function noRetiredVitestConfigs(cwd = process.cwd()) {
@@ -665,14 +670,14 @@ function noRetiredVitestConfigs(cwd = process.cwd()) {
665
670
  }
666
671
  function noUnguardedLefthookInstall(cwd = process.cwd()) {
667
672
  const scripts = readRootScripts(cwd);
668
- const unguarded = INSTALL_LIFECYCLE_SCRIPTS.flatMap((name) => {
673
+ const unguardedScripts = INSTALL_LIFECYCLE_SCRIPTS.flatMap((name) => {
669
674
  const command = scripts[name];
670
675
  if (typeof command !== "string") return [];
671
676
  const isUnguarded = LEFTHOOK_INSTALL_PATTERN.test(command) && !LEFTHOOK_CHECK_INSTALL_PATTERN.test(command);
672
677
  return isUnguarded ? [`${name}: ${command}`] : [];
673
678
  });
674
- if (unguarded.length === 0) return true;
675
- return { ok: false, detail: formatPaths(unguarded) };
679
+ if (unguardedScripts.length === 0) return true;
680
+ return { ok: false, detail: formatPaths(unguardedScripts) };
676
681
  }
677
682
  function noWorkspaceRunScriptReferences() {
678
683
  const packagesDir = join(process.cwd(), "packages");
@@ -698,27 +703,27 @@ function readFileIn(cwd, relativePath) {
698
703
  }
699
704
  function readPnpmFieldKeys(content) {
700
705
  if (content === void 0) return void 0;
701
- let parsed;
706
+ let parsedManifest;
702
707
  try {
703
- parsed = JSON.parse(content);
708
+ parsedManifest = JSON.parse(content);
704
709
  } catch {
705
710
  return void 0;
706
711
  }
707
- if (!isRecord(parsed)) return void 0;
708
- const pnpm = parsed["pnpm"];
712
+ if (!isRecord(parsedManifest)) return void 0;
713
+ const pnpm = parsedManifest["pnpm"];
709
714
  return isRecord(pnpm) ? Object.keys(pnpm).toSorted() : void 0;
710
715
  }
711
716
  function readRootScripts(cwd) {
712
717
  const content = readFileIn(cwd, "package.json");
713
718
  if (content === void 0) return {};
714
- let parsed;
719
+ let parsedManifest;
715
720
  try {
716
- parsed = JSON.parse(content);
721
+ parsedManifest = JSON.parse(content);
717
722
  } catch {
718
723
  return {};
719
724
  }
720
- if (!isRecord(parsed)) return {};
721
- const scripts = parsed["scripts"];
725
+ if (!isRecord(parsedManifest)) return {};
726
+ const scripts = parsedManifest["scripts"];
722
727
  return isRecord(scripts) ? scripts : {};
723
728
  }
724
729
  function resolvesVersionViaWorkspace(range) {
@@ -729,11 +734,11 @@ function tazeConfigBuildsOnSharedConfig(cwd = process.cwd()) {
729
734
  if (configs.length === 0) {
730
735
  return { ok: false, detail: describeMissingTazeConfig(cwd) };
731
736
  }
732
- const stale = configs.filter(
737
+ const staleConfigs = configs.filter(
733
738
  (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "defineConfig", SHARED_TAZE_MODULE)
734
739
  );
735
- if (stale.length === 0) return true;
736
- return { ok: false, detail: `does not import defineConfig from ${SHARED_TAZE_MODULE}: ${stale.join(", ")}` };
740
+ if (staleConfigs.length === 0) return true;
741
+ return { ok: false, detail: `does not import defineConfig from ${SHARED_TAZE_MODULE}: ${staleConfigs.join(", ")}` };
737
742
  }
738
743
  function tazeConfigAvoidsClobberedOptions(cwd = process.cwd()) {
739
744
  const configs = findFiles([`taze.config.${CONFIG_EXTENSIONS}`], cwd);
@@ -741,15 +746,15 @@ function tazeConfigAvoidsClobberedOptions(cwd = process.cwd()) {
741
746
  for (const relativePath of configs) {
742
747
  const content = readFileIn(cwd, relativePath);
743
748
  if (content === void 0) continue;
744
- const discarded = CLOBBERED_TAZE_OPTIONS.filter(({ pattern }) => pattern.test(content)).map(({ key }) => key);
745
- if (discarded.length > 0) findings.push(`${relativePath}: ${discarded.join(", ")}`);
749
+ const discardedKeys = CLOBBERED_TAZE_OPTIONS.filter(({ pattern }) => pattern.test(content)).map(({ key }) => key);
750
+ if (discardedKeys.length > 0) findings.push(`${relativePath}: ${discardedKeys.join(", ")}`);
746
751
  }
747
752
  if (findings.length === 0) return true;
748
753
  return { ok: false, detail: formatPaths(findings) };
749
754
  }
750
755
  function describeMissingTazeConfig(cwd) {
751
- const inert = findFiles(INERT_TAZE_CONFIGS, cwd);
752
- if (inert.length > 0) return `holds no code to call the factory: ${inert.join(", ")}`;
756
+ const inertConfigs = findFiles(INERT_TAZE_CONFIGS, cwd);
757
+ if (inertConfigs.length > 0) return `holds no code to call the factory: ${inertConfigs.join(", ")}`;
753
758
  return "taze.config.ts is missing";
754
759
  }
755
760
  function testSuiteGatesTestFileConventions(cwd = process.cwd()) {
@@ -777,9 +782,9 @@ function vitestConfigBuildsOnSharedConfig() {
777
782
  const cwd = process.cwd();
778
783
  const rootConfigs = findFiles([VITEST_CONFIG_PATTERN], cwd);
779
784
  if (rootConfigs.length === 0) return { ok: false, detail: "vitest.config.ts is missing" };
780
- const discovery = discoverMemberWorkspaces();
781
- if (!discovery.ok) return discovery;
782
- const workspaceConfigs = discovery.workspaces.flatMap((workspace) => findWorkspaceConfigs(workspace, VITEST_CONFIG_PATTERN)).filter((relativePath) => !isOwnedByReExportCheck(cwd, relativePath));
785
+ const workspaceDiscovery = discoverMemberWorkspaces();
786
+ if (!workspaceDiscovery.ok) return workspaceDiscovery;
787
+ const workspaceConfigs = workspaceDiscovery.workspaces.flatMap((workspace) => findWorkspaceConfigs(workspace, VITEST_CONFIG_PATTERN)).filter((relativePath) => !isOwnedByReExportCheck(cwd, relativePath));
783
788
  const stale = [...rootConfigs, ...workspaceConfigs].filter(
784
789
  (relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "defineVitestConfig", SHARED_VITEST_MODULE)
785
790
  );
@@ -11,12 +11,12 @@
11
11
  "esbuildVersion": "0.28.2",
12
12
  "inputs": [
13
13
  {
14
- "hash": "488459c0",
14
+ "hash": "0ae7745f",
15
15
  "kind": "module",
16
16
  "path": "kits/default.ts"
17
17
  },
18
18
  {
19
- "hash": "521b947a",
19
+ "hash": "30230373",
20
20
  "kind": "inline",
21
21
  "path": "../package.json",
22
22
  "paths": [
@@ -34,7 +34,7 @@
34
34
  "path": "../src/resolve-scripts.ts"
35
35
  },
36
36
  {
37
- "hash": "2dfde614",
37
+ "hash": "beb92e94",
38
38
  "kind": "module",
39
39
  "path": "../src/tiers.ts"
40
40
  }
@@ -43,8 +43,8 @@
43
43
  "path": "kits/default.js",
44
44
  "readyupVersion": "0.38.0",
45
45
  "source": "kits/default.ts",
46
- "sourceHash": "488459c0",
47
- "targetHash": "cbef780a"
46
+ "sourceHash": "0ae7745f",
47
+ "targetHash": "06f47835"
48
48
  }
49
49
  ]
50
50
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,76 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.39.0 โ€” 2026-09-22
6
+
7
+ ### ๐ŸŽ‰ Features
8
+
9
+ - Report why a workspace selects no package, and no-op nmr's own -R steps (#841)
10
+
11
+ - Drops a `-R` step that nmr composed into a script where the workspace holds no package, so `typecheck` and the five `test` scripts run there instead of failing the run.
12
+ - Adds a no-op verdict for a command that the drop leaves with no step at all.
13
+ - Replaces the single refusal for a workspace that selects no package with one message per cause.
14
+
15
+ Migration: In a repo that declares a workspace package pattern and deliberately holds no package, delete the `.config/nmr.config.ts` entries added to work around the `-R` refusal, the `build: ':'` override included.
16
+
17
+ - ๐Ÿšจ **Breaking:** Prefix the booleans and reject unrecognized Vitest option keys (#849)
18
+
19
+ - Renames two boolean options of `defineVitestConfig` and `defineRootVitestConfig`, the factories that build a repo's Vitest config: `isolateGit` becomes `shouldIsolateGit`, and `resolveFromSource` becomes `shouldResolveFromSource`.
20
+ - Makes both factories reject an option key outside the recognized set, naming the offending keys and the recognized ones, rather than ignoring a misspelled or retired key and letting its option fall back to the default with nothing in the run reporting it.
21
+ - Adds a retired-key check that runs ahead of the unrecognized-key check, reporting the new name for a config that still sets `isolateGit` or `resolveFromSource`.
22
+
23
+ Migration: In each options object passed to `defineVitestConfig` or `defineRootVitestConfig`, rename `isolateGit` to `shouldIsolateGit` and `resolveFromSource` to `shouldResolveFromSource`, and correct any other key that the factory now rejects. `defineRootVitestConfig` recognizes `monorepoRoot` on the last layer alone, so a config that sets it on an earlier layer now throws where the old code ignored it.
24
+
25
+ - ๐Ÿšจ **Breaking:** End nmr's source value names with the kind of the value (#850)
26
+
27
+ - Renames two fields of `TestFileConventionsReport`, `misplaced` and `untiered`, to `misplacedFiles` and `untieredFiles`, and renames `TestFileConventionsOptions`'s `exclude` to `excludedBasenames`, in the `@williamthorsen/nmr/tests` entry point.
28
+ - Adds a guard to `checkTestFileConventions` and `reportTestFileConventions` that throws a `TypeError` naming `excludedBasenames` when a call still sets `exclude`.
29
+
30
+ Migration: Read `report.misplacedFiles` and `report.untieredFiles` in place of `report.misplaced` and `report.untiered`, and pass `excludedBasenames` in place of `exclude`. A call that still sets `exclude` throws rather than running with no exclusions, so the failure surfaces where Vitest loads the call site.
31
+
32
+ - ๐Ÿšจ **Breaking:** Hoist the pnpm-workspace resolver into nmr-core as a total API (#864)
33
+
34
+ - Adds a `./workspace` subpath export to `@williamthorsen/nmr-core`, whose `resolveWorkspace` returns a pnpm workspace's package directories, or the cause of an empty result, as a value rather than as a thrown error.
35
+ - Reports a `pnpm-workspace.yaml` that the YAML parser rejects under a cause of its own, `unreadable-manifest`, so `nmr -R build` names the syntax error and its remedy in place of failing with the parser's own error.
36
+ - Removes `diagnoseEmptyWorkspace` and `EmptyWorkspaceDiagnosis` from `@williamthorsen/nmr/workspace`, since `resolveWorkspace` reports the same cause in one call.
37
+
38
+ Migration: Replace each `diagnoseEmptyWorkspace` call with `resolveWorkspace`, which reports the cause on the empty result it already returns, and rename the `no-manifest` cause to `no-package`. `EmptyWorkspaceCause` also gains `unreadable-manifest`, so an exhaustive switch over it stops compiling until it handles the new member; every other export of `@williamthorsen/nmr/workspace` keeps its signature and its `UserError` throws.
39
+
40
+ - ๐Ÿšจ **Breaking:** Adopt the shared workspace resolver in release-kit, honoring exclusions (#865)
41
+
42
+ - ๐Ÿšจ **Breaking:** Narrows `no-pattern` in `@williamthorsen/nmr-core`'s `EmptyWorkspaceCause`, moving an absent or empty `packages` list onto `no-packages-list` and a value that is not a list of strings onto `unreadable-packages`.
43
+ - Stops `release-kit` from tagging, publishing, and label-syncing a package that a `!` entry in `pnpm-workspace.yaml` excludes, which its previous `glob`-based resolution could not remove from the match.
44
+ - Replaces the release of the root package alone with a failure naming the condition that emptied the workspace, when `pnpm-workspace.yaml` does not hold valid YAML or declares a workspace that does not resolve to a package.
45
+ - Replaces the `no-pattern` remedy that `nmr` printed for a `pnpm-workspace.yaml` declaring no `packages` list, and for one declaring a `packages` value that is not a list of strings.
46
+ - Raises `engines.node` to `>=24.16.0` in `@williamthorsen/nmr-core` and `@williamthorsen/release-kit`, because Node 24.0 through 24.15 ignores the `followSymlinks` option that the workspace matcher passes to `globSync` and drops a symlinked package directory from the resolution without reporting it.
47
+
48
+ Migration: Add a `no-packages-list` arm and an `unreadable-packages` arm to any `switch` that covers `EmptyWorkspaceCause` exhaustively. `no-pattern` now names only a list that the resolver read in full and whose entries all failed to become a positive pattern, so move the handling of an absent `packages` key, an empty list, and a value that is not a list of strings out of the `no-pattern` arm into the two new ones. Route `no-packages-list` to single-package behavior rather than to a failure path: pnpm resolves such a manifest to the root package alone.
49
+
50
+ ### โ™ป๏ธ Refactoring
51
+
52
+ - Align nmr's function names with naming conventions (#852)
53
+
54
+ - Renames the functions in `packages/nmr` to align with naming conventions.
55
+
56
+ - Suffix nmr's unit-measuring numbers and expand its abbreviations (#854)
57
+
58
+ - Renames every unit-measuring number and abbreviated identifier under `packages/nmr/src`, adding `Ms`, `Bytes`, and `Offset` suffixes to the quantities and expanding `pkg`, `Len`, and `Idx` to `package`, `Length`, and `Index`. The renames are not part of any public API.
59
+
60
+ ### ๐Ÿงช Tests
61
+
62
+ - End nmr's test value names with the kind of the value (#851)
63
+
64
+ - Aligns the name of every non-boolean value in `packages/nmr/src/**/__tests__` with naming conventions.
65
+ - Renames `inline` to `inlineBody` in the repo-root `__tests__/reusable-workflow-runtime.app.unit.test.ts`.
66
+
67
+ ### ๐Ÿ‘ท CI
68
+
69
+ - Make the release workflow Corepack-agnostic (#842)
70
+
71
+ - Removes the `Disable Corepack` step from `release.reusable.yaml`, which Node 25's removal of Corepack from the distribution would make fail with `command not found` once `actions/setup-node` moves past Node 24, and keeps `COREPACK_ENABLE_STRICT: '0'`, which guards the source-build path where the pnpm `packageManager` pin survives.
72
+ - Adds a case to `reusable-workflow-runtime.app.unit.test.ts` that fails the test run on any reusable workflow whose `run:` commands invoke `corepack`, enumerated from `.github/workflows` so that a workflow added later is covered without editing a list.
73
+ - Drops the Corepack recommendation from the README's pnpm prerequisite, which now points at pnpm's installation page, and from the fix text of the `.tool-versions does not list pnpm` check in nmr's readyup kit, which now names the `packageManager` field.
74
+
5
75
  ## 0.38.0 โ€” 2026-09-18
6
76
 
7
77
  ### ๐ŸŽ‰ Features