@williamthorsen/nmr 0.27.0 → 0.28.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.23.0";
3
+ export const __readyupVersion = "0.24.0";
4
4
 
5
5
 
6
6
  // .readyup/kits/default.ts
@@ -163,6 +163,7 @@ var default_default = defineRdyKit({
163
163
  {
164
164
  name: ".tool-versions does not list pnpm",
165
165
  severity: "warn",
166
+ quiet: true,
166
167
  check: toolVersionsHasNoPnpm,
167
168
  fix: "Remove pnpm from .tool-versions \u2014 manage via packageManager field and corepack"
168
169
  },
@@ -201,16 +202,10 @@ var default_default = defineRdyKit({
201
202
  {
202
203
  name: "root package.json has no nmr-provided scripts",
203
204
  severity: "warn",
205
+ quiet: true,
204
206
  check: noRedundantRootScripts,
205
207
  fix: "Remove scripts from root package.json that nmr provides as built-in root scripts \u2014 invoke via nmr directly"
206
208
  },
207
- {
208
- name: "root:lint:strict does not use echo fallback",
209
- severity: "warn",
210
- skip: () => !scriptExists("root:lint:strict") ? "no root:lint:strict script" : false,
211
- check: () => !scriptMatches("root:lint:strict", /\becho\b/),
212
- fix: "Replace the echo fallback in root:lint:strict \u2014 strict-lint now supports path arguments"
213
- },
214
209
  // -- Workspace build readiness -------------------------------------------
215
210
  {
216
211
  name: "all workspace packages can build",
@@ -222,6 +217,7 @@ var default_default = defineRdyKit({
222
217
  {
223
218
  name: "no retired Vitest config variants",
224
219
  severity: "error",
220
+ quiet: true,
225
221
  check: () => noRetiredVitestConfigs(),
226
222
  fix: "Delete every vitest.standalone.config.* and vitest.integration.config.*. nmr's test scripts select Vitest projects instead of naming config files"
227
223
  },
@@ -246,6 +242,7 @@ var default_default = defineRdyKit({
246
242
  {
247
243
  name: "no package re-exports the ancestor Vitest config",
248
244
  severity: "recommend",
245
+ quiet: true,
249
246
  check: () => noReExportOnlyVitestConfigs(),
250
247
  fix: "Delete these files. Vitest resolves config by walking up from the run root, so a per-package re-export is redundant"
251
248
  },
@@ -263,23 +260,18 @@ var default_default = defineRdyKit({
263
260
  check: () => hasDevDependency("v11y-check"),
264
261
  fix: "pnpm add --save-dev v11y-check"
265
262
  },
266
- {
267
- name: "code-quality workflow does not use nmr prepush",
268
- severity: "warn",
269
- skip: () => !fileExists(".github/workflows/code-quality.yaml") ? "no code-quality workflow" : false,
270
- check: codeQualityWorkflowDoesNotUseNmrPrepush,
271
- fix: 'Change the check-command in .github/workflows/code-quality.yaml from "pnpm exec nmr prepush" to "pnpm exec nmr ci", which runs the same checks without the audit that audit.yaml already runs'
272
- },
273
263
  // -- Legacy script runner ------------------------------------------------
274
264
  {
275
265
  name: "scripts/run-workspace-script.ts does not exist",
276
266
  severity: "error",
267
+ quiet: true,
277
268
  check: () => !fileExists("scripts/run-workspace-script.ts"),
278
269
  fix: "Delete scripts/run-workspace-script.ts \u2014 nmr replaces this custom script runner"
279
270
  },
280
271
  {
281
272
  name: 'no workspace packages reference run-workspace-script or "pnpm run ws"',
282
273
  severity: "error",
274
+ quiet: true,
283
275
  check: noWorkspaceRunScriptReferences,
284
276
  fix: 'Remove "ws" script entries and replace any "pnpm run ws" invocations with nmr in each packages/*/package.json'
285
277
  }
@@ -365,16 +357,11 @@ function hasPrettierConfigKey(cwd) {
365
357
  if (manifest === void 0) return false;
366
358
  try {
367
359
  const parsed = JSON.parse(manifest);
368
- return isRecord(parsed) && parsed.prettier !== void 0;
360
+ return isRecord(parsed) && parsed["prettier"] !== void 0;
369
361
  } catch {
370
362
  return false;
371
363
  }
372
364
  }
373
- function codeQualityWorkflowDoesNotUseNmrPrepush() {
374
- const content = readFile(".github/workflows/code-quality.yaml");
375
- if (content === void 0) return true;
376
- return !/check-command:\s*pnpm exec nmr prepush(\s|$)/.test(content);
377
- }
378
365
  function everyTestFileNamesItsTier(cwd = process.cwd()) {
379
366
  const untiered = findTestFiles(cwd).filter((path2) => !hasTierInfix(path2));
380
367
  if (untiered.length === 0) return true;
@@ -388,11 +375,11 @@ function formatPaths(paths) {
388
375
  ${paths.map((path2) => ` ${path2}`).join("\n")}`;
389
376
  }
390
377
  function getMinVersion() {
391
- const picked = { "version": "0.27.0" };
392
- if (typeof picked.version !== "string") {
378
+ const picked = { "version": "0.28.0" };
379
+ if (typeof picked["version"] !== "string") {
393
380
  throw new TypeError("nmr/package.json: 'version' must be a string");
394
381
  }
395
- return picked.version;
382
+ return picked["version"];
396
383
  }
397
384
  function hasSupportedEslintVersion() {
398
385
  return hasMinDevDependencyVersion("eslint", MIN_ESLINT_VERSION, {
@@ -423,7 +410,7 @@ function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
423
410
  function noRedundantRootScripts() {
424
411
  const pkg = readPackageJson();
425
412
  if (!pkg) return true;
426
- const scripts = pkg.scripts;
413
+ const scripts = pkg["scripts"];
427
414
  if (!isRecord(scripts)) return true;
428
415
  const builtInNames = Object.keys(getDefaultRootScripts());
429
416
  const redundant = Object.keys(scripts).filter((name) => builtInNames.includes(name));
@@ -468,20 +455,6 @@ function readFileIn(cwd, relativePath) {
468
455
  function resolvesVersionViaWorkspace(range) {
469
456
  return WORKSPACE_VERSION_MARKERS.some((marker) => range.startsWith(marker));
470
457
  }
471
- function scriptExists(name) {
472
- const pkg = readPackageJson();
473
- if (!pkg) return false;
474
- const scripts = pkg.scripts;
475
- return isRecord(scripts) && Object.hasOwn(scripts, name);
476
- }
477
- function scriptMatches(name, pattern) {
478
- const pkg = readPackageJson();
479
- if (!pkg) return false;
480
- const scripts = pkg.scripts;
481
- if (!isRecord(scripts)) return false;
482
- const value = scripts[name];
483
- return typeof value === "string" && pattern.test(value);
484
- }
485
458
  function toolVersionsHasNoPnpm() {
486
459
  const content = readFile(".tool-versions");
487
460
  if (content === void 0) return true;
@@ -494,7 +467,6 @@ function vitestRootConfigBuildsOnSharedConfig(cwd = process.cwd()) {
494
467
  return checkRootVitestConfig("vitest.root.config", "defineRootVitestConfig", cwd);
495
468
  }
496
469
  export {
497
- codeQualityWorkflowDoesNotUseNmrPrepush,
498
470
  default_default as default,
499
471
  everyTestFileNamesItsTier,
500
472
  hasSupportedEslintVersion,
@@ -7,10 +7,10 @@
7
7
  ],
8
8
  "name": "default",
9
9
  "path": "kits/default.js",
10
- "readyupVersion": "0.23.0",
10
+ "readyupVersion": "0.24.0",
11
11
  "source": "kits/default.ts",
12
- "sourceHash": "d95cb08e",
13
- "targetHash": "e4affaeb"
12
+ "sourceHash": "7b2a223f",
13
+ "targetHash": "b7abeb12"
14
14
  }
15
15
  ]
16
16
  }
package/README.md CHANGED
@@ -3,15 +3,29 @@
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.27.0 (2026-08-05)
6
+ ## Release notes — v0.28.0 (2026-08-08)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - 🚨 **Breaking:** Lint the whole tree in one process, not one per workspace (#604)
10
+ - Stop reporting absence checks that pass (#610)
11
11
 
12
- Linting of the entire monorepo now runs in a single call instead of via fan-out, eliminating the startup time previously incurred for each workspace.
12
+ A ReadyUp check that checks for a problem (such as an unwanted file or a stale config reference) now stays silent unless it finds one. The `nmr`, `release-kit`, and `v11y-check` ReadyUp kits previously printed a line for every such check on every run.
13
13
 
14
- As a result of the change, `lint:pre` and `lint:post` hooks in a workspace are no longer triggered by a run from the monorepo root. They continue to be triggered by a run targeting the workspace itself.
14
+ - Stop reporting non-issues in the readyup kits (#613)
15
+
16
+ Modifies the `release-kit` and `nmr` ReadyUp kits to avoid verbose reporting when an initial check determines that further checks aren't applicable. In particular, a repo with no `release-kit` config file gets a single line in the `release-kit` report, and a repo that publishes nothing now bypasses the `npm-auto-publish` kit instead of failing it.
17
+
18
+ ### 🐛 Bug fixes
19
+
20
+ - Fix prototype-key hole in nmr command runner (#624)
21
+
22
+ Fixes an issue where running `nmr` with a command named for property inherited by JS objects (such as `constructor` or `toString`) failed with a type error instead of reporting the command as unknown.
23
+
24
+ Also fixes all lint issues in the `nmr` package.
25
+
26
+ - Publish build output atomically so a failed build cannot destroy it (#631)
27
+
28
+ Fixes an issue where a failed or still-running `nmr build` could leave a package with no build output at all, breaking the tooling that a subsequent build depends on. Separately, the error reported when a package's build output is missing now names the correct recovery command.
15
29
  <!-- /section:release-notes -->
16
30
 
17
31
  ## Installation
@@ -628,7 +642,9 @@ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler
628
642
 
629
643
  **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.
630
644
 
631
- **The build owns its output directory.** Every rebuild wipes `dist/esm` before emitting, so the directory holds exactly the current emit: a source that was deleted, or that a widened ignore set now covers, leaves nothing behind. Assets therefore belong outside the output directory, or in a `build:post` hook, which runs after the wipe and so composes correctly. Note that a bare `nmr compile` runs no hook, so assets a `build:post` step copies in are absent until a full `nmr build` restores them. An `outdir` that does not resolve inside the package is rejected rather than wiped.
645
+ **The build owns its output directory.** Every rebuild replaces `dist/esm` wholesale, so the directory holds exactly the current emit: a source that was deleted, or that a widened ignore set now covers, leaves nothing behind. Assets therefore belong outside the output directory, or in a `build:post` hook, which runs after the output is published and so composes correctly. Note that a bare `nmr compile` runs no hook, so assets a `build:post` step copies in are absent until a full `nmr build` restores them. An `outdir` that does not resolve inside the package is rejected rather than replaced.
646
+
647
+ **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.
632
648
 
633
649
  `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.
634
650
 
@@ -3,7 +3,7 @@ try {
3
3
  await import('../dist/esm/cli-ensure-prepublish-hooks.js');
4
4
  } catch (error) {
5
5
  if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('ensure-prepublish-hooks: build output not found — run `pnpm run build` first\n');
6
+ process.stderr.write('ensure-prepublish-hooks: build output not found — run `pnpm run bootstrap` first\n');
7
7
  } else {
8
8
  process.stderr.write(`ensure-prepublish-hooks: failed to load: ${error.message}\n`);
9
9
  }
package/bin/nmr-clean.js CHANGED
@@ -3,7 +3,7 @@ try {
3
3
  await import('../dist/esm/cli-clean.js');
4
4
  } catch (error) {
5
5
  if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr-clean: build output not found — run `pnpm run build` first\n');
6
+ process.stderr.write('nmr-clean: build output not found — run `pnpm run bootstrap` first\n');
7
7
  } else {
8
8
  process.stderr.write(`nmr-clean: failed to load: ${error.message}\n`);
9
9
  }
@@ -3,7 +3,7 @@ try {
3
3
  await import('../dist/esm/cli-build.js');
4
4
  } catch (error) {
5
5
  if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr-compile: build output not found — run `pnpm run build` first\n');
6
+ process.stderr.write('nmr-compile: build output not found — run `pnpm run bootstrap` first\n');
7
7
  } else {
8
8
  process.stderr.write(`nmr-compile: failed to load: ${error.message}\n`);
9
9
  }
package/bin/nmr-fmt.js CHANGED
@@ -3,7 +3,7 @@ try {
3
3
  await import('../dist/esm/cli-fmt.js');
4
4
  } catch (error) {
5
5
  if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr-fmt: build output not found — run `pnpm run build` first\n');
6
+ process.stderr.write('nmr-fmt: build output not found — run `pnpm run bootstrap` first\n');
7
7
  } else {
8
8
  process.stderr.write(`nmr-fmt: failed to load: ${error.message}\n`);
9
9
  }
@@ -3,7 +3,7 @@ try {
3
3
  await import('../dist/esm/cli-report-overrides.js');
4
4
  } catch (error) {
5
5
  if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr-report-overrides: build output not found — run `pnpm run build` first\n');
6
+ process.stderr.write('nmr-report-overrides: build output not found — run `pnpm run bootstrap` first\n');
7
7
  } else {
8
8
  process.stderr.write(`nmr-report-overrides: failed to load: ${error.message}\n`);
9
9
  }
package/bin/nmr-taze.js CHANGED
@@ -3,7 +3,7 @@ try {
3
3
  await import('../dist/esm/cli-taze.js');
4
4
  } catch (error) {
5
5
  if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr-taze: build output not found — run `pnpm run build` first\n');
6
+ process.stderr.write('nmr-taze: build output not found — run `pnpm run bootstrap` first\n');
7
7
  } else {
8
8
  process.stderr.write(`nmr-taze: failed to load: ${error.message}\n`);
9
9
  }
package/bin/nmr.js CHANGED
@@ -3,7 +3,7 @@ try {
3
3
  await import('../dist/esm/cli.js');
4
4
  } catch (error) {
5
5
  if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr: build output not found — run `pnpm run build` first\n');
6
+ process.stderr.write('nmr: build output not found — run `pnpm run bootstrap` first\n');
7
7
  } else {
8
8
  process.stderr.write(`nmr: failed to load: ${error.message}\n`);
9
9
  }
@@ -105,7 +105,7 @@ export async function readBuildOutputState(monorepoRoot, config) {
105
105
  return state;
106
106
  }
107
107
  const registry = buildWorkspaceRegistry(config);
108
- if (JSON.stringify(registry.build) !== JSON.stringify(getDefaultWorkspaceScripts().build)) {
108
+ if (JSON.stringify(registry['build']) !== JSON.stringify(getDefaultWorkspaceScripts()['build'])) {
109
109
  return state;
110
110
  }
111
111
  for (const packageDir of packageDirs) {
@@ -132,7 +132,8 @@ export async function removeCheckCache(scopeDir) {
132
132
  }
133
133
  export function resolveCacheableCommands(checkCache) {
134
134
  const commands = new Set([...DEFAULT_CACHEABLE_COMMANDS, ...(checkCache?.extraCommands ?? [])]);
135
- for (const excluded of checkCache?.excludeCommands ?? []) {
135
+ const excludedCommands = checkCache?.excludeCommands ?? [];
136
+ for (const excluded of excludedCommands) {
136
137
  commands.delete(excluded);
137
138
  }
138
139
  return commands;
@@ -184,10 +185,10 @@ function isCheckCacheEntry(value) {
184
185
  if (stringFields.some((field) => typeof value[field] !== 'string')) {
185
186
  return false;
186
187
  }
187
- return (typeof value.durationMs === 'number' &&
188
- Number.isFinite(value.durationMs) &&
189
- !Number.isNaN(Date.parse(String(value.recordedAt))) &&
190
- isStringRecord(value.buildDigests));
188
+ return (typeof value['durationMs'] === 'number' &&
189
+ Number.isFinite(value['durationMs']) &&
190
+ !Number.isNaN(Date.parse(String(value['recordedAt']))) &&
191
+ isStringRecord(value['buildDigests']));
191
192
  }
192
193
  function isProbeSubject(packageDir, registry) {
193
194
  const build = resolveScript('build', registry, packageDir, false);
@@ -4,6 +4,10 @@ export interface BuildOptions {
4
4
  ignorePatterns?: string[];
5
5
  outdir?: string;
6
6
  }
7
+ export interface ScratchDirs {
8
+ previous: string;
9
+ staging: string;
10
+ }
7
11
  export declare const DEFAULT_ENTRY_GLOBS: string[];
8
12
  export declare const DEFAULT_IGNORE_PATTERNS: string[];
9
13
  export declare const DEFAULT_OUTDIR = "dist/esm/";
@@ -11,3 +15,4 @@ export declare function hasBuildOutput(packageDir: string, options?: BuildOption
11
15
  export declare function hasExpectedBuildOutput(packageDir: string, outdir: string, entryPoints: string[]): boolean;
12
16
  export declare function readBuildDigest(packageDir: string): Promise<string | undefined>;
13
17
  export declare function resolveBuildCachePath(packageDir: string): string;
18
+ export declare function resolveScratchDirs(emitDir: string): ScratchDirs;
@@ -34,3 +34,11 @@ export function resolveBuildCachePath(packageDir) {
34
34
  extension: '.hash',
35
35
  });
36
36
  }
37
+ export function resolveScratchDirs(emitDir) {
38
+ const parent = path.dirname(emitDir);
39
+ const name = path.basename(emitDir);
40
+ return {
41
+ previous: path.join(parent, `.${name}.previous`),
42
+ staging: path.join(parent, `.${name}.staging`),
43
+ };
44
+ }
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { readFile, rm } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile, rename, rm } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  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, } from "./build-output.js";
9
+ import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, } from "./build-output.js";
10
10
  const PACKAGE_ICON = '📦';
11
11
  const SKIPPED_ICON = '⏭️';
12
12
  const SOURCE_ROOT = 'src';
@@ -18,7 +18,7 @@ export async function buildPackage(packageDir, options = {}) {
18
18
  assertSupportedTypeScript();
19
19
  const cachePath = resolveBuildCachePath(packageDir);
20
20
  const outdir = options.outdir ?? DEFAULT_OUTDIR;
21
- resolveEmitDir(packageDir, outdir);
21
+ const emitDir = resolveEmitDir(packageDir, outdir);
22
22
  const emitConfig = { outdir, declaration: true, rewriteRelativeImportExtensions: true };
23
23
  const entryPoints = await glob(options.entryGlobs ?? DEFAULT_ENTRY_GLOBS, {
24
24
  cwd: packageDir,
@@ -32,6 +32,7 @@ export async function buildPackage(packageDir, options = {}) {
32
32
  ];
33
33
  const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, ts.version, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
34
34
  if (!changed) {
35
+ await discardScratchDirs(resolveScratchDirs(emitDir));
35
36
  return;
36
37
  }
37
38
  await emitPackage(packageDir, entryPoints, outdir);
@@ -64,10 +65,7 @@ export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json
64
65
  throw new Error(`nmr-compile: failed to read ${normalized}.\n${formatDiagnostics([configFile.error])}`);
65
66
  }
66
67
  for (const entry of normalizeExtendsField(configFile.config)) {
67
- const baseConfig = resolveExtendsTarget(entry, normalized);
68
- if (baseConfig !== undefined) {
69
- walk(baseConfig);
70
- }
68
+ walk(resolveExtendsTarget(entry, normalized));
71
69
  }
72
70
  }
73
71
  walk(path.resolve(packageDir, configFileName));
@@ -77,29 +75,64 @@ async function emitPackage(packageDir, entryPoints, outdir) {
77
75
  const compilerOptions = synthesizeCompilerOptions(packageDir, outdir);
78
76
  const rootNames = entryPoints.map((entry) => path.resolve(packageDir, entry));
79
77
  const sourceRoot = path.resolve(packageDir, SOURCE_ROOT);
78
+ const emitDir = resolveEmitDir(packageDir, outdir);
79
+ const scratchDirs = resolveScratchDirs(emitDir);
80
+ await removeScratchDirs(scratchDirs);
80
81
  const program = ts.createProgram(rootNames, compilerOptions);
81
- await rm(resolveEmitDir(packageDir, outdir), { force: true, recursive: true });
82
- const emittedFiles = [];
82
+ const emitted = new Map();
83
83
  const emitResult = program.emit(undefined, (fileName, text, writeByteOrderMark) => {
84
- ts.sys.writeFile(fileName, text, writeByteOrderMark);
85
- emittedFiles.push(fileName);
84
+ emitted.set(fileName, { text, writeByteOrderMark });
86
85
  });
87
86
  if (emitResult.emitSkipped) {
88
87
  throw new Error(`nmr-compile: emit failed.\n${formatDiagnostics(emitResult.diagnostics)}`);
89
88
  }
90
- for (const file of emittedFiles) {
91
- rewriteOutputSpecifiers(file, compilerOptions, sourceRoot);
89
+ const staged = new Map();
90
+ for (const [fileName, file] of emitted) {
91
+ staged.set(fileName, { ...file, text: rewriteSpecifiers(fileName, file.text, compilerOptions, sourceRoot) });
92
+ }
93
+ if (staged.size === 0) {
94
+ await rm(emitDir, { force: true, recursive: true });
95
+ return;
96
+ }
97
+ writeStagedOutput(staged, emitDir, scratchDirs.staging);
98
+ await swapIntoPlace(emitDir, scratchDirs);
99
+ }
100
+ async function discardScratchDirs(scratchDirs) {
101
+ try {
102
+ await removeScratchDirs(scratchDirs);
103
+ }
104
+ catch {
92
105
  }
93
106
  }
107
+ async function removeScratchDirs(scratchDirs) {
108
+ await rm(scratchDirs.previous, { force: true, recursive: true });
109
+ await rm(scratchDirs.staging, { force: true, recursive: true });
110
+ }
94
111
  function resolveEmitDir(packageDir, outdir) {
95
112
  const resolved = path.resolve(packageDir, outdir);
96
113
  const relative = path.relative(packageDir, resolved);
97
114
  if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
98
115
  throw new Error(`nmr-compile: refusing to build into '${outdir}', which does not resolve inside the package. ` +
99
- 'The build wipes its output directory before each emit, so the directory must sit below the package root.');
116
+ 'The build replaces its output directory on each emit, so the directory must sit below the package root.');
100
117
  }
101
118
  return resolved;
102
119
  }
120
+ async function swapIntoPlace(emitDir, scratchDirs) {
121
+ const hadPreviousOutput = existsSync(emitDir);
122
+ if (hadPreviousOutput) {
123
+ await rename(emitDir, scratchDirs.previous);
124
+ }
125
+ try {
126
+ await rename(scratchDirs.staging, emitDir);
127
+ }
128
+ catch (error) {
129
+ if (hadPreviousOutput && !existsSync(emitDir)) {
130
+ await rename(scratchDirs.previous, emitDir);
131
+ }
132
+ throw error;
133
+ }
134
+ await discardScratchDirs(scratchDirs);
135
+ }
103
136
  function synthesizeCompilerOptions(packageDir, outdir) {
104
137
  const configPath = path.join(packageDir, 'tsconfig.json');
105
138
  const configFile = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName));
@@ -125,12 +158,20 @@ function synthesizeCompilerOptions(packageDir, outdir) {
125
158
  noEmitOnError: false,
126
159
  };
127
160
  }
128
- function rewriteOutputSpecifiers(outputFile, compilerOptions, sourceRoot) {
161
+ function writeStagedOutput(staged, emitDir, stagingDir) {
162
+ for (const [fileName, file] of staged) {
163
+ if (!isWithin(emitDir, fileName)) {
164
+ throw new Error(`nmr-compile: the compiler emitted ${fileName}, which is outside the output directory ${emitDir}. ` +
165
+ "Verify the resolved tsconfig's 'outDir' and 'declarationDir'.");
166
+ }
167
+ ts.sys.writeFile(path.join(stagingDir, path.relative(emitDir, fileName)), file.text, file.writeByteOrderMark);
168
+ }
169
+ }
170
+ function rewriteSpecifiers(outputFile, text, compilerOptions, sourceRoot) {
129
171
  if (!isRewritableOutput(outputFile)) {
130
- return;
172
+ return text;
131
173
  }
132
- const originalText = readFileSync(outputFile, 'utf8');
133
- const sourceFile = ts.createSourceFile(outputFile, originalText, ts.ScriptTarget.Latest, false, scriptKindFor(outputFile));
174
+ const sourceFile = ts.createSourceFile(outputFile, text, ts.ScriptTarget.Latest, false, scriptKindFor(outputFile));
134
175
  const aliasPrefixes = collectAliasPrefixes(compilerOptions);
135
176
  const sourceContainingFile = mapOutputToSource(outputFile, compilerOptions, sourceRoot);
136
177
  const edits = [];
@@ -140,18 +181,18 @@ function rewriteOutputSpecifiers(outputFile, compilerOptions, sourceRoot) {
140
181
  return;
141
182
  }
142
183
  const start = literal.getStart(sourceFile);
143
- const quote = originalText[start] ?? '"';
184
+ const quote = text[start] ?? '"';
144
185
  edits.push({ start, end: literal.getEnd(), text: `${quote}${replacement}${quote}` });
145
186
  });
146
187
  if (edits.length === 0) {
147
- return;
188
+ return text;
148
189
  }
149
190
  const orderedEdits = [...edits].sort((a, b) => b.start - a.start);
150
- let updatedText = originalText;
191
+ let updatedText = text;
151
192
  for (const edit of orderedEdits) {
152
193
  updatedText = updatedText.slice(0, edit.start) + edit.text + updatedText.slice(edit.end);
153
194
  }
154
- writeFileSync(outputFile, updatedText);
195
+ return updatedText;
155
196
  }
156
197
  function resolveSpecifierReplacement(specifier, sourceContainingFile, compilerOptions, sourceRoot, aliasPrefixes) {
157
198
  if (isRelativeSpecifier(specifier)) {
@@ -267,9 +308,20 @@ function resolveExtendsTarget(extendsEntry, fromConfigPath) {
267
308
  return base;
268
309
  }
269
310
  const withJsonExtension = `${base}.json`;
270
- return ts.sys.fileExists(withJsonExtension) ? withJsonExtension : undefined;
311
+ if (ts.sys.fileExists(withJsonExtension)) {
312
+ return withJsonExtension;
313
+ }
314
+ throw new Error(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not exist.`);
315
+ }
316
+ const resolved = resolvePackageSpecifier(extendsEntry, fromConfigPath) ??
317
+ resolvePackageSpecifier(`${extendsEntry}/tsconfig.json`, fromConfigPath);
318
+ if (resolved === undefined) {
319
+ throw new Error(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not resolve to a file.`);
271
320
  }
272
- const { resolvedModule } = ts.resolveModuleName(extendsEntry, fromConfigPath, { moduleResolution: ts.ModuleResolutionKind.NodeNext, resolveJsonModule: true }, ts.sys);
321
+ return resolved;
322
+ }
323
+ function resolvePackageSpecifier(specifier, fromConfigPath) {
324
+ const { resolvedModule } = ts.resolveModuleName(specifier, fromConfigPath, { moduleResolution: ts.ModuleResolutionKind.NodeNext, resolveJsonModule: true }, ts.sys);
273
325
  return resolvedModule?.resolvedFileName;
274
326
  }
275
327
  function isRelativeSpecifier(specifier) {
@@ -17,12 +17,12 @@ export function ensurePrepublishHooks(monorepoRoot, options) {
17
17
  packageName,
18
18
  packageDir,
19
19
  isPrivate: true,
20
- prepublishOnly: pkg.scripts?.prepublishOnly,
20
+ prepublishOnly: pkg.scripts?.['prepublishOnly'],
21
21
  action: 'ok',
22
22
  });
23
23
  continue;
24
24
  }
25
- const existing = pkg.scripts?.prepublishOnly;
25
+ const existing = pkg.scripts?.['prepublishOnly'];
26
26
  if (existing) {
27
27
  packages.push({
28
28
  packageName,
@@ -66,8 +66,8 @@ function addPrepublishOnly(packageDir, command) {
66
66
  if (!isObject(parsed)) {
67
67
  throw new TypeError(`Invalid package.json in ${packageDir}: expected an object`);
68
68
  }
69
- const scripts = isObject(parsed.scripts) ? parsed.scripts : {};
70
- scripts.prepublishOnly = command;
71
- parsed.scripts = scripts;
69
+ const scripts = isObject(parsed['scripts']) ? parsed['scripts'] : {};
70
+ scripts['prepublishOnly'] = command;
71
+ parsed['scripts'] = scripts;
72
72
  writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
73
73
  }
@@ -108,7 +108,7 @@ function resolvePrettierCli() {
108
108
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
109
109
  if (!isObject(manifest))
110
110
  return missing;
111
- const bin = isObject(manifest.bin) ? manifest.bin[PRETTIER_PACKAGE] : manifest.bin;
111
+ const bin = isObject(manifest['bin']) ? manifest['bin'][PRETTIER_PACKAGE] : manifest['bin'];
112
112
  if (typeof bin !== 'string')
113
113
  return missing;
114
114
  return { ok: true, cliPath: path.resolve(path.dirname(manifestPath), bin) };
@@ -42,7 +42,7 @@ function isScriptRecord(value) {
42
42
  return true;
43
43
  }
44
44
  function validateScriptField(value, fieldName, configPath) {
45
- if (!(fieldName in value) || value[fieldName] === undefined) {
45
+ if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
46
46
  return undefined;
47
47
  }
48
48
  if (!isScriptRecord(value[fieldName])) {
@@ -54,7 +54,7 @@ function isStringArray(value) {
54
54
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
55
55
  }
56
56
  function validateBuildField(value, configPath) {
57
- const build = value.build;
57
+ const build = value['build'];
58
58
  if (build === undefined) {
59
59
  return undefined;
60
60
  }
@@ -62,14 +62,14 @@ function validateBuildField(value, configPath) {
62
62
  throw new Error(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
63
63
  }
64
64
  assertRecognizedKeys(build, RECOGNIZED_BUILD_KEYS, configPath, 'build.');
65
- const extraIgnorePatterns = build.extraIgnorePatterns;
65
+ const extraIgnorePatterns = build['extraIgnorePatterns'];
66
66
  if (extraIgnorePatterns !== undefined && !isStringArray(extraIgnorePatterns)) {
67
67
  throw new Error(`Invalid nmr config at ${configPath}: \`build.extraIgnorePatterns\` must be a string[]`);
68
68
  }
69
69
  return extraIgnorePatterns === undefined ? {} : { extraIgnorePatterns };
70
70
  }
71
71
  function validateCheckCacheField(value, configPath) {
72
- const checkCache = value.checkCache;
72
+ const checkCache = value['checkCache'];
73
73
  if (checkCache === undefined) {
74
74
  return undefined;
75
75
  }
@@ -78,7 +78,7 @@ function validateCheckCacheField(value, configPath) {
78
78
  }
79
79
  assertRecognizedKeys(checkCache, RECOGNIZED_CHECK_CACHE_KEYS, configPath, 'checkCache.');
80
80
  const config = {};
81
- const enabled = checkCache.enabled;
81
+ const enabled = checkCache['enabled'];
82
82
  if (enabled !== undefined) {
83
83
  if (typeof enabled !== 'boolean') {
84
84
  throw new TypeError(`Invalid nmr config at ${configPath}: \`checkCache.enabled\` must be a boolean`);
@@ -98,7 +98,7 @@ function validateCheckCacheField(value, configPath) {
98
98
  return config;
99
99
  }
100
100
  function validateStringRecordField(value, fieldName, configPath) {
101
- if (!(fieldName in value) || value[fieldName] === undefined) {
101
+ if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
102
102
  return undefined;
103
103
  }
104
104
  if (!isStringRecord(value[fieldName])) {
@@ -135,7 +135,7 @@ export async function loadConfig(baseDir) {
135
135
  return {};
136
136
  }
137
137
  const imported = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
138
- const loaded = isObject(imported) ? imported.default : undefined;
138
+ const loaded = isObject(imported) ? imported['default'] : undefined;
139
139
  return validateConfig(loaded, configPath);
140
140
  }
141
141
  export async function loadRootConfig(monorepoRoot) {
package/dist/esm/help.js CHANGED
@@ -50,7 +50,7 @@ function collectOverrides(packageDir) {
50
50
  function applyOverrides(registry, overrides) {
51
51
  const marked = new Set();
52
52
  for (const [name, value] of Object.entries(overrides)) {
53
- if (!(name in registry)) {
53
+ if (!Object.hasOwn(registry, name)) {
54
54
  continue;
55
55
  }
56
56
  registry[name] = value;
@@ -7,27 +7,29 @@ export function readPackageJson(dir) {
7
7
  throw new TypeError(`Invalid package.json in ${dir}: expected an object`);
8
8
  }
9
9
  const pkg = {};
10
- if (typeof parsed.name === 'string')
11
- pkg.name = parsed.name;
12
- if (parsed.private === true)
10
+ if (typeof parsed['name'] === 'string')
11
+ pkg.name = parsed['name'];
12
+ if (parsed['private'] === true)
13
13
  pkg.private = true;
14
- if (typeof parsed.version === 'string')
15
- pkg.version = parsed.version;
16
- if (typeof parsed.packageManager === 'string')
17
- pkg.packageManager = parsed.packageManager;
18
- if (isObject(parsed.scripts)) {
14
+ if (typeof parsed['version'] === 'string')
15
+ pkg.version = parsed['version'];
16
+ if (typeof parsed['packageManager'] === 'string')
17
+ pkg.packageManager = parsed['packageManager'];
18
+ if (isObject(parsed['scripts'])) {
19
19
  const scripts = {};
20
- for (const [key, val] of Object.entries(parsed.scripts)) {
20
+ const scriptEntries = Object.entries(parsed['scripts']);
21
+ for (const [key, val] of scriptEntries) {
21
22
  if (typeof val === 'string')
22
23
  scripts[key] = val;
23
24
  }
24
25
  pkg.scripts = scripts;
25
26
  }
26
- if (isObject(parsed.pnpm)) {
27
- const pnpm = parsed.pnpm;
28
- if (isObject(pnpm.overrides)) {
27
+ if (isObject(parsed['pnpm'])) {
28
+ const pnpm = parsed['pnpm'];
29
+ if (isObject(pnpm['overrides'])) {
29
30
  const overrides = {};
30
- for (const [key, val] of Object.entries(pnpm.overrides)) {
31
+ const overrideEntries = Object.entries(pnpm['overrides']);
32
+ for (const [key, val] of overrideEntries) {
31
33
  if (typeof val === 'string')
32
34
  overrides[key] = val;
33
35
  }
@@ -46,7 +46,7 @@ export function readPackageJsonScripts(packageDir) {
46
46
  const parsed = JSON.parse(raw);
47
47
  if (!isObject(parsed))
48
48
  return undefined;
49
- const scripts = parsed.scripts;
49
+ const scripts = parsed['scripts'];
50
50
  if (!isObject(scripts))
51
51
  return undefined;
52
52
  const result = {};
@@ -82,13 +82,16 @@ export function isSelfReferential(script, commandName) {
82
82
  export function resolveScript(commandName, registry, packageDir, workspaceRoot) {
83
83
  if (packageDir) {
84
84
  const pkgScripts = readPackageJsonScripts(packageDir);
85
- if (pkgScripts && commandName in pkgScripts) {
85
+ if (pkgScripts && Object.hasOwn(pkgScripts, commandName)) {
86
86
  const override = pkgScripts[commandName];
87
87
  if (override !== undefined && !isSelfReferential(override, commandName)) {
88
88
  return { command: override, source: 'package' };
89
89
  }
90
90
  }
91
91
  }
92
+ if (!Object.hasOwn(registry, commandName)) {
93
+ return undefined;
94
+ }
92
95
  const registryEntry = registry[commandName];
93
96
  if (registryEntry === undefined) {
94
97
  return undefined;
@@ -53,7 +53,7 @@ export async function runCli(options) {
53
53
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
54
54
  const resolved = resolveScript(command, registry, anchorDir, parsed.workspaceRoot);
55
55
  if (!resolved) {
56
- if (env.NMR_RUN_IF_PRESENT === '1') {
56
+ if (env['NMR_RUN_IF_PRESENT'] === '1') {
57
57
  return { exitCode: 0 };
58
58
  }
59
59
  reportError(`Unknown command: ${command}`, stderr);
@@ -105,7 +105,8 @@ function buildChildEnv(env, snapshot, noCache) {
105
105
  };
106
106
  }
107
107
  function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
108
- if (quiet || resolved.source !== 'package' || registry[command] === undefined) {
108
+ const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
109
+ if (quiet || resolved.source !== 'package' || registryEntry === undefined) {
109
110
  return undefined;
110
111
  }
111
112
  return `📦 ${path.basename(anchorDir)}: Using override script: ${resolved.command}\n`;
@@ -15,7 +15,7 @@ export function defineVitestConfig(...layers) {
15
15
  }
16
16
  export function defineRootVitestConfig(...layers) {
17
17
  const lastLayer = layers.at(-1);
18
- const monorepoRoot = isObject(lastLayer) ? lastLayer.monorepoRoot : undefined;
18
+ const monorepoRoot = isObject(lastLayer) ? lastLayer['monorepoRoot'] : undefined;
19
19
  if (typeof monorepoRoot !== 'string' || !path.isAbsolute(monorepoRoot)) {
20
20
  throw new TypeError(MISSING_MONOREPO_ROOT);
21
21
  }
@@ -42,7 +42,12 @@ function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], pro
42
42
  watch: false,
43
43
  },
44
44
  };
45
- return layers.reduce((merged, { root }) => (root ? mergeConfig(merged, root) : merged), config);
45
+ let merged = config;
46
+ for (const { root } of layers) {
47
+ if (root)
48
+ merged = mergeConfig(merged, root);
49
+ }
50
+ return merged;
46
51
  }
47
52
  function buildProjects(layers, extraExclude, projectRoot) {
48
53
  const projectTiers = [
@@ -65,7 +70,11 @@ function buildProjects(layers, extraExclude, projectRoot) {
65
70
  ...(timeout !== undefined && { hookTimeout: timeout, testTimeout: timeout }),
66
71
  },
67
72
  };
68
- return layers.reduce((merged, layer) => applyLayer(merged, layer, name), project);
73
+ let merged = project;
74
+ for (const layer of layers) {
75
+ merged = applyLayer(merged, layer, name);
76
+ }
77
+ return merged;
69
78
  });
70
79
  }
71
80
  function applyLayer(project, layer, tier) {
@@ -76,7 +85,8 @@ function applyLayer(project, layer, tier) {
76
85
  function assertKnownTiers(layers) {
77
86
  const known = TIER_NAMES;
78
87
  for (const { tiers } of layers) {
79
- for (const name of Object.keys(tiers ?? {})) {
88
+ const declaredTiers = Object.keys(tiers ?? {});
89
+ for (const name of declaredTiers) {
80
90
  if (!known.includes(name)) {
81
91
  throw new TypeError(`Unknown tier "${name}" in \`tiers\`. Valid tiers: ${TIER_NAMES.join(', ')}.`);
82
92
  }
@@ -33,7 +33,7 @@ export function getWorkspacePackageDirs(monorepoRoot) {
33
33
  function getPackagesFromParsedYaml(parsed) {
34
34
  if (!isObject(parsed))
35
35
  return undefined;
36
- const packages = parsed.packages;
36
+ const packages = parsed['packages'];
37
37
  if (!Array.isArray(packages))
38
38
  return undefined;
39
39
  if (!packages.every((p) => typeof p === 'string'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -68,11 +68,11 @@
68
68
  "prettier-plugin-sh": "0.19.0",
69
69
  "taze": "19.16.0",
70
70
  "yaml": "2.9.0",
71
- "@williamthorsen/nmr-core": "0.9.2"
71
+ "@williamthorsen/nmr-core": "0.9.3"
72
72
  },
73
73
  "devDependencies": {
74
74
  "prettier": "3.9.6",
75
- "readyup": "0.23.0",
75
+ "readyup": "0.24.0",
76
76
  "tinyglobby": "0.2.17",
77
77
  "vitest": "4.1.10"
78
78
  },