@williamthorsen/nmr 0.32.2 → 0.34.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,10 +1,10 @@
1
1
  /** @noformat — @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.29.0";
3
+ export const __readyupVersion = "0.32.0";
4
4
 
5
5
 
6
6
  // .readyup/kits/default.ts
7
- import { existsSync, globSync, readdirSync as readdirSync2, readFileSync } from "node:fs";
7
+ import { existsSync, globSync, readdirSync as readdirSync2 } from "node:fs";
8
8
  import { basename, join, sep } from "node:path";
9
9
  import { defineRdyKit } from "readyup";
10
10
  import {
@@ -25,7 +25,7 @@ var ROOT_TYPECHECK_STEP = { run: "root:typecheck", declinesArgs: true };
25
25
  var workspaceScripts = {
26
26
  build: ["compile"],
27
27
  check: [TYPECHECK_STEP, "fmt:check", "lint:check", "test"],
28
- "check:strict": [TYPECHECK_STEP, "fmt:check", "lint:strict", "test:coverage"],
28
+ "check:strict": [TYPECHECK_STEP, "fmt:check", "lint:strict", "test"],
29
29
  clean: "nmr-clean",
30
30
  compile: "nmr-compile",
31
31
  fix: ["lint", "fmt"],
@@ -53,7 +53,7 @@ var rootScripts = {
53
53
  "audit:prod": "pnpm exec v11y --prod",
54
54
  build: ["-R build"],
55
55
  check: [TYPECHECK_STEP, "fmt:check", "lint:check", "test"],
56
- "check:strict": [TYPECHECK_STEP, "fmt:check", "lint:strict", "test:coverage"],
56
+ "check:strict": [TYPECHECK_STEP, "fmt:check", "lint:strict", "test"],
57
57
  // Excludes the audit, which in CI has a workflow of its own. The build is what the narrowed check runs
58
58
  // against, so it declines the arguments rather than being narrowed by them.
59
59
  ci: [{ run: "build", declinesArgs: true }, "check:strict"],
@@ -278,6 +278,13 @@ var default_default = defineRdyKit({
278
278
  check: () => tazeConfigBuildsOnSharedConfig(),
279
279
  fix: "Replace the taze config with: import { defineConfig } from '@williamthorsen/nmr/taze'; export default defineConfig(); \u2014 nmr's upgrade policy reaches a repo only through this file, so without it `nmr upgrade` reports nothing where dependencies are pinned to exact versions"
280
280
  },
281
+ {
282
+ name: "taze config declares no option taze discards",
283
+ severity: "warn",
284
+ quiet: true,
285
+ check: () => tazeConfigAvoidsClobberedOptions(),
286
+ fix: "Set these through the upgrade script instead, as rootScripts: { upgrade: 'nmr-report-overrides && nmr-taze --recursive --request-timeout 90000' } in .config/nmr.config.ts, keeping the rest of the default script \u2014 taze's CLI writes a default for each of them over whatever the config file declares, so the value there never reaches taze (antfu-collective/taze#317). nmr already forwards a 30-second request timeout"
287
+ },
281
288
  // -- Audit dependency --------------------------------------------------------
282
289
  {
283
290
  name: "v11y-check in devDependencies",
@@ -311,6 +318,13 @@ var SHARED_PRETTIER_MODULE = "@williamthorsen/nmr/prettier";
311
318
  var INERT_PRETTIER_CONFIGS = [".prettierrc", ".prettierrc.{json,json5,yaml,yml,toml}"];
312
319
  var SHARED_TAZE_MODULE = "@williamthorsen/nmr/taze";
313
320
  var INERT_TAZE_CONFIGS = [".tazerc", ".tazerc.json", "taze.config.json"];
321
+ var CLOBBERED_TAZE_OPTIONS = [
322
+ { key: "concurrency", pattern: /\bconcurrency\s*:/ },
323
+ { key: "githubActions", pattern: /\bgithubActions\s*:\s*(?:false|\{)/ },
324
+ { key: "ignoreOtherWorkspaces", pattern: /\bignoreOtherWorkspaces\s*:\s*false/ },
325
+ { key: "nodeVersion", pattern: /\bnodeVersion\s*:\s*false/ },
326
+ { key: "requestTimeout", pattern: /\brequestTimeout\s*:/ }
327
+ ];
314
328
  var RE_EXPORT_LINE_PATTERN = /^export\s*(?:\{\s*default\s*}|\*)\s*from\s*['"]\.\.\/[^'"]*['"];?$/;
315
329
  var MIN_ESLINT_VERSION = "10.0.0";
316
330
  var MIN_STRICT_LINT_VERSION = "9.3.0";
@@ -402,7 +416,7 @@ function formatPaths(paths) {
402
416
  ${paths.map((path2) => ` ${path2}`).join("\n")}`;
403
417
  }
404
418
  function getMinVersion() {
405
- const picked = { "version": "0.32.2" };
419
+ const picked = { "version": "0.34.0" };
406
420
  if (typeof picked["version"] !== "string") {
407
421
  throw new TypeError("nmr/package.json: 'version' must be a string");
408
422
  }
@@ -482,11 +496,7 @@ function noWorkspaceRunScriptReferences() {
482
496
  };
483
497
  }
484
498
  function readFileIn(cwd, relativePath) {
485
- try {
486
- return readFileSync(join(cwd, relativePath), "utf8");
487
- } catch {
488
- return void 0;
489
- }
499
+ return readFile(join(cwd, relativePath));
490
500
  }
491
501
  function readPnpmFieldKeys(content) {
492
502
  if (content === void 0) return void 0;
@@ -514,6 +524,18 @@ function tazeConfigBuildsOnSharedConfig(cwd = process.cwd()) {
514
524
  if (stale.length === 0) return true;
515
525
  return { ok: false, detail: `does not import defineConfig from ${SHARED_TAZE_MODULE}: ${stale.join(", ")}` };
516
526
  }
527
+ function tazeConfigAvoidsClobberedOptions(cwd = process.cwd()) {
528
+ const configs = findFiles([`taze.config.${CONFIG_EXTENSIONS}`], cwd);
529
+ const findings = [];
530
+ for (const relativePath of configs) {
531
+ const content = readFileIn(cwd, relativePath);
532
+ if (content === void 0) continue;
533
+ const discarded = CLOBBERED_TAZE_OPTIONS.filter(({ pattern }) => pattern.test(content)).map(({ key }) => key);
534
+ if (discarded.length > 0) findings.push(`${relativePath}: ${discarded.join(", ")}`);
535
+ }
536
+ if (findings.length === 0) return true;
537
+ return { ok: false, detail: formatPaths(findings) };
538
+ }
517
539
  function describeMissingTazeConfig(cwd) {
518
540
  const inert = findFiles(INERT_TAZE_CONFIGS, cwd);
519
541
  if (inert.length > 0) return `holds no code to call the factory: ${inert.join(", ")}`;
@@ -539,6 +561,7 @@ export {
539
561
  noReExportOnlyVitestConfigs,
540
562
  noRetiredVitestConfigs,
541
563
  prettierConfigBuildsOnSharedConfig,
564
+ tazeConfigAvoidsClobberedOptions,
542
565
  tazeConfigBuildsOnSharedConfig,
543
566
  vitestConfigBuildsOnSharedConfig,
544
567
  vitestRootConfigBuildsOnSharedConfig
@@ -8,12 +8,12 @@
8
8
  "esbuildVersion": "0.28.1",
9
9
  "inputs": [
10
10
  {
11
- "hash": "904ac753",
11
+ "hash": "f58e8934",
12
12
  "kind": "module",
13
13
  "path": "kits/default.ts"
14
14
  },
15
15
  {
16
- "hash": "cca25de2",
16
+ "hash": "cbbd2a2c",
17
17
  "kind": "inline",
18
18
  "path": "../package.json",
19
19
  "paths": [
@@ -21,7 +21,7 @@
21
21
  ]
22
22
  },
23
23
  {
24
- "hash": "94bb3c62",
24
+ "hash": "74bd7994",
25
25
  "kind": "module",
26
26
  "path": "../src/default-scripts.ts"
27
27
  },
@@ -38,10 +38,10 @@
38
38
  ],
39
39
  "name": "default",
40
40
  "path": "kits/default.js",
41
- "readyupVersion": "0.29.0",
41
+ "readyupVersion": "0.32.0",
42
42
  "source": "kits/default.ts",
43
- "sourceHash": "904ac753",
44
- "targetHash": "bc4d49d1"
43
+ "sourceHash": "f58e8934",
44
+ "targetHash": "debdcb6e"
45
45
  }
46
46
  ]
47
47
  }
package/README.md CHANGED
@@ -3,19 +3,25 @@
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.32.2 (2026-08-19)
6
+ ## Release notes — v0.34.0 (2026-08-24)
7
7
 
8
- ### 🐛 Bug fixes
8
+ ### 🎉 Features
9
+
10
+ - 🚨 **Breaking:** Make defineVitestConfig resolve from source and isolate git (#749)
11
+
12
+ Adds two defaults to `defineVitestConfig` and `defineRootVitestConfig` in `@williamthorsen/nmr/vitest`: Workspace packages resolve through their `source` export condition, so a suite runs without a prior build; and every project loads a setup file that keeps the developer's git configuration out of the subprocesses a test spawns.
9
13
 
10
- - Recognize nmr behind a quoted environment assignment (#709)
14
+ Each is individually opt-out-able through the new `isolateGit` and `resolveFromSource` options. A consumer's own `resolve.conditions` and `setupFiles` compose alongside what the factory supplies.
11
15
 
12
- Fixes an issue where a `package.json` entry that re-invoked its own command from behind an environment assignment whose quoted value carried a space (e.g., `"build": "NODE_OPTIONS=\"--a --b\" nmr build"`) caused nmr to hang.
16
+ Migration: A workspace package declaring a `source` condition is resolved from source in tests that previously read its `dist`. A package that publishes only its build output takes a bespoke condition name instead, as `@williamthorsen/nmr-core` does with `nmr-source`.
13
17
 
14
- - Restore upgrade reporting for repos that pin exact versions (#720)
18
+ ### 🐛 Bug fixes
19
+
20
+ - Stop nmr upgrade from timing out against a proxying registry (#744)
15
21
 
16
- Fixes an issue where `nmr upgrade` reported nothing in a repo that pins dependencies to exact versions, however far behind the tree was. nmr's shared upgrade policy now supplies both settings the upgrade tool needs to report a bare pin -- locked dependencies are included, and the range searched is `minor` -- and the default `upgrade` scripts no longer pass `--include-locked`, which went inert on its own in taze 20.0.1. The policy reaches a repo only through a `taze.config.ts` calling `defineConfig`, and nmr's ReadyUp kit now warns when a repo carries none.
22
+ Fixes an issue where `nmr upgrade` timed out intermittently against a registry that proxies npm. Every `upgrade` script now calls taze with a 30-second request timeout in place of the 5-second default; a different request timeout specified by the caller is respected.
17
23
 
18
- The `minor` range mode applies to every dependency, not only a pinned one: a `~` range is searched as `^`, so minor upgrades are proposed for it, and a `packageMode` entry other than `minor` drops its dependency from a default pass rather than narrowing it.
24
+ The ReadyUp kit bundled with nmr now warns when a taze config sets a value that the taze CLI will not use.
19
25
  <!-- /section:release-notes -->
20
26
 
21
27
  ## Installation
@@ -311,7 +317,7 @@ A check that passed on a working tree passes again on the same working tree. nmr
311
317
 
312
318
  ```console
313
319
  $ nmr ci
314
- # ... four minutes of build, typecheck, lint, and coverage ...
320
+ # ... four minutes of build, typecheck, lint, and tests ...
315
321
 
316
322
  $ nmr ci
317
323
  ⏭️ node-monorepo-tools: ci: passed 2m ago on this tree, saved ~4m
@@ -406,7 +412,7 @@ $ nmr check
406
412
  ⏭️ nmr-core: check: passed 3m ago on this tree, saved ~48s — replayed: nmr-core: fmt:check: All matched files use Prettier code style!; nmr-core: test: Test Files 6 passed (6) Tests 41 passed (41)
407
413
  ```
408
414
 
409
- The assembly is flat: a constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test:coverage` rather than one opaque `check:strict` line. A delegate expands into the scopes it fans out to, and a constituent that recorded no excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all, so `ci` replays what `check:strict` earned and nothing for `build`.
415
+ The assembly is flat: a constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test` rather than one opaque `check:strict` line. A delegate expands into the scopes it fans out to, and a constituent that recorded no excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all, so `ci` replays what `check:strict` earned and nothing for `build`.
410
416
 
411
417
  It is assembled when the composite records its pass rather than when it skips, so every excerpt in it was certified during that one run, on that one tree. `NMR_RUN_ID` is what makes that provable; see [reserved environment variables](#reserved-environment-variables).
412
418
 
@@ -560,7 +566,7 @@ export default defineConfig({
560
566
 
561
567
  `defineConfig` supplies nmr's shared upgrade policy — a seven-day quarantine on brand-new releases, and the pair of settings that report a dependency pinned to an exact version: locked dependencies are included, and the range searched is `minor` — so your config carries only what is specific to your repo. Any setting you declare wins over nmr's default. Passing `undefined` clears a default rather than falling back to it, which is how you hand the quarantine policy back to `pnpm-workspace.yaml`'s `minimumReleaseAge`. `mode` is the exception: the upgrade tool carries a default of its own for it, and clearing nmr's leaves no valid range to search, so name a mode instead.
562
568
 
563
- Everything the [taze configuration](https://github.com/antfu-collective/taze#config-file) accepts is accepted here.
569
+ Everything the [taze configuration](https://github.com/antfu-collective/taze#config-file) accepts is accepted here, with five exceptions that reach the upgrade tool from no config file at all: `concurrency`, `githubActions`, `ignoreOtherWorkspaces`, `nodeVersion`, and `requestTimeout`. The tool's CLI writes a default for each of them over whatever the config declares ([taze#317](https://github.com/antfu-collective/taze/issues/317)), so the command line is the only place they can be set. nmr's ReadyUp kit warns when your config declares one, and [request timeout](#request-timeout) below shows how to set it instead.
564
570
 
565
571
  > **Note:** This file is what activates nmr's upgrade policy at all. A repo without one gets neither the release quarantine nor the settings that report a dependency pinned to an exact version, and `nmr upgrade` reports nothing in a repo using pnpm's `savePrefix: ''`. nmr's ReadyUp kit warns when the file is missing.
566
572
 
@@ -569,6 +575,32 @@ Two consequences of the `minor` range mode the policy declares:
569
575
  - A `packageMode` entry is honored only by the pass whose mode matches it, or by a `default` pass; every other pass drops that dependency rather than narrowing it. Under the policy's `minor` mode, a `patch` ceiling hides its dependency until you run `nmr upgrade patch`, and a `minor` ceiling hides it from `nmr upgrade major`.
570
576
  - A `~` range is searched as `^`, so minor upgrades are proposed for it and `--write` applies them under the original `~`. To hold one dependency to patches, give it a `packageMode` entry of `patch` and read it from `nmr upgrade patch`; to hold the whole repo there, declare `mode: 'patch'`.
571
577
 
578
+ ### Request timeout
579
+
580
+ Every `upgrade` script reaches the upgrade tool with a 30-second request timeout, in place of the tool's own 5 seconds.
581
+
582
+ Five seconds is too tight for a registry that proxies npm. The tool fetches a full packument whenever the resolved registry is not `registry.npmjs.org`, and a large one runs to tens of megabytes; the budget covers the whole retry chain rather than a single attempt, so one slow response ends the run with `Timeout requesting "<package>"`. The symptom is an upgrade pass that succeeds or fails according to how warm the tool's cache happens to be.
583
+
584
+ Pass a different value for one run:
585
+
586
+ ```bash
587
+ nmr upgrade --request-timeout 90000
588
+ ```
589
+
590
+ To change it for every run, override the `upgrade` script in `.config/nmr.config.ts`, keeping the rest of the default. This is nmr's own config, not the `taze.config.ts` above; taze ignores a `rootScripts` key, and nothing reports it:
591
+
592
+ ```ts
593
+ import { defineConfig } from '@williamthorsen/nmr/config';
594
+
595
+ export default defineConfig({
596
+ rootScripts: {
597
+ upgrade: 'nmr-report-overrides && nmr-taze --recursive --request-timeout 90000',
598
+ },
599
+ });
600
+ ```
601
+
602
+ The override is one command string rather than a list of steps, because a composite element names an nmr command and both halves here are binaries.
603
+
572
604
  ## Default script registries
573
605
 
574
606
  These scripts are available out of the box. Repo-wide config (tier 2) and per-package overrides (tier 3) can add to or replace any of them.
@@ -579,7 +611,7 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
579
611
  | ---------------- | ----------------------------------------------------------- |
580
612
  | `build` | `compile` |
581
613
  | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
582
- | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
614
+ | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test` |
583
615
  | `clean` | `nmr-clean` |
584
616
  | `compile` | `nmr-compile` |
585
617
  | `fix` | `lint`, `fmt` |
@@ -640,10 +672,12 @@ Neither is bound to a git hook. `prepush` is named for when you run it, not for
640
672
 
641
673
  #### Check and quality
642
674
 
643
- | Command | Runs |
644
- | -------------- | -------------------------------------------------------- |
645
- | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
646
- | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
675
+ | Command | Runs |
676
+ | -------------- | ----------------------------------------------- |
677
+ | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
678
+ | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test` |
679
+
680
+ Neither gate computes coverage: Instrumentation multiplies the wall time of the run they exist to keep short. Coverage is [`test:coverage`](#test-selections), run as a step of its own; a repo that wants it inside the gate [overrides](#configuration) `check:strict`.
647
681
 
648
682
  #### Fix
649
683
 
@@ -879,7 +913,7 @@ nmr-fmt --write packages/nmr
879
913
 
880
914
  ### `nmr-taze`
881
915
 
882
- Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched. This is what every `upgrade` chain ends with, `root:upgrade` included — see [dependency upgrades](#dependency-upgrades) for the workflow.
916
+ Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched and adding one of its own: a 30-second request timeout, unless the invocation already carries one. This is what every `upgrade` chain ends with, `root:upgrade` included — see [dependency upgrades](#dependency-upgrades) for the workflow, and [request timeout](#request-timeout) for why the timeout is set here.
883
917
 
884
918
  Under pnpm's isolated `node_modules`, a transitive package's binary is absent from the consuming repo's `node_modules/.bin`, so a repo that depends on nmr cannot run `taze` directly. `nmr-taze` can, because nmr is a direct dependency, and it resolves the tool from the tree nmr controls.
885
919
 
@@ -1060,6 +1094,25 @@ Every tier above `unit` carries a 30-second `testTimeout` and `hookTimeout`, whe
1060
1094
 
1061
1095
  To raise a budget for one tier and no other, use the `tiers` seam below. The `project` seam merges over both budgets as well, but like every option passed through it the value reaches all four projects at once -- raising a tier's budget raises `unit`'s with it. To lift the ceiling for a single file rather than a whole tier, pass a timeout to the individual test or hook, which stays the narrower tool.
1062
1096
 
1097
+ ### What the config supplies
1098
+
1099
+ Two settings a workspace test runner needs almost universally are part of what the factory produces, so a config file declares neither:
1100
+
1101
+ - **Workspace packages resolve from source.** `source` leads the emitted `resolve.conditions` and `ssr.resolve.conditions`, so a cross-package import reaches the dependency's `.ts` rather than its `dist/` and a suite runs without a prior build. A package declaring no such condition is unaffected.
1102
+ - **Git subprocesses are isolated.** Every project loads a setup file that points `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at the null device and sets `GIT_CONFIG_NOSYSTEM`, so a test that spawns `git` reads neither the developer's identity nor a signing config that can block on a passphrase. It leads each project's `setupFiles`, ahead of every entry the layers add, because a shared setup establishes the environment the rest run in.
1103
+
1104
+ Both defaults exist because omitting either is silent rather than loud: the tiers still exist and the suite still runs green, testing `dist/` and the developer's git identity instead. Turn either off through its own option, which folds across layers like any other, the last layer to declare it winning:
1105
+
1106
+ ```ts
1107
+ export default defineVitestConfig({ isolateGit: false, resolveFromSource: false });
1108
+ ```
1109
+
1110
+ `resolveFromSource: false` is for a repo that resolves a `source` condition it does not want in tests; `isolateGit: false` is for a suite meant to read the developer's own git configuration.
1111
+
1112
+ **Vite replaces its condition defaults rather than extending them.** A config writing `conditions: ['source']` by hand therefore drops `module`, so a dependency exposing a `module` entry falls through to whatever its `exports` lists next. A replaced list still resolves `node` and `development` under Vitest, which is why nothing in a test run reports the loss. The factory emits `source` alongside Vite's defaults for each environment, so a config declaring no conditions of its own starts from a complete list.
1113
+
1114
+ **A package declaring a `source` condition must ship its sources.** Every nmr consumer now resolves that condition, so a published package whose `files` carries `dist` alone resolves to a path that is not in the tarball. A package that publishes only its build output takes a bespoke condition name instead, the way `@williamthorsen/nmr-core` uses `nmr-source` for the build's own bootstrap.
1115
+
1063
1116
  ### Customizing by scope
1064
1117
 
1065
1118
  Vitest applies some options at the root of a `projects` config and others per project, and placing one at the wrong level is silent rather than loud. The factory therefore takes separate override surfaces instead of one merged config:
@@ -1067,7 +1120,7 @@ Vitest applies some options at the root of a `projects` config and others per pr
1067
1120
  ```ts
1068
1121
  export default defineVitestConfig({
1069
1122
  // Vite-level options, plus the test options Vitest honours only at the root.
1070
- root: { resolve: { conditions: ['development'] } },
1123
+ root: { ssr: { resolve: { conditions: ['my-condition'] } } },
1071
1124
  // Applied to every project.
1072
1125
  project: { setupFiles: ['./vitest.setup.ts'] },
1073
1126
  // Applied to one tier, after the `project` block above.
@@ -1081,7 +1134,9 @@ export default defineVitestConfig({
1081
1134
 
1082
1135
  Arrays concatenate rather than replace. `exclude` and `setupFiles` therefore add to what the config already declares, and no surface can narrow `include` or drop a default exclusion. Adding an `include` pattern through `project` widens all four projects at once, so a file matching it is collected by each and runs four times.
1083
1136
 
1084
- `resolve.conditions` concatenates too, but layer order carries no meaning there: Vite consumes conditions as a set, and which one wins is decided by the key order of the consumed package's own `exports`. A later layer can add a condition and can never remove or outrank one an earlier layer contributed. `resolve.alias` is the one key that merges override-first, so a later alias takes precedence over an earlier one.
1137
+ `resolve.conditions` concatenates too, onto the `source` condition and the Vite defaults the factory already emits, but layer order carries no meaning there: Vite consumes conditions as a set, and which one wins is decided by the key order of the consumed package's own `exports`. A later layer can add a condition and can never remove or outrank one an earlier layer contributed; removing `source` is what `resolveFromSource: false` is for. `resolve.alias` is the one key that merges override-first, so a later alias takes precedence over an earlier one.
1138
+
1139
+ **A condition for the tests' own resolution goes under `ssr`.** `resolve` is per-environment, and Vitest resolves a test's imports through the server environment, so `root: { ssr: { resolve: { conditions: ['my-condition'] } } }` is the seam that reaches them. A top-level `root: { resolve: { conditions: [...] } }` entry reaches the client environment, which only browser-mode tests resolve through; it composes into that array and never reports that a node test did not see it. `resolve.alias` is not per-environment, so a top-level alias reaches both.
1085
1140
 
1086
1141
  ### Sharing options across config files
1087
1142
 
@@ -1094,7 +1149,7 @@ import { fileURLToPath } from 'node:url';
1094
1149
  import type { VitestConfigOptions } from '@williamthorsen/nmr/vitest';
1095
1150
 
1096
1151
  export const shared: VitestConfigOptions = {
1097
- root: { resolve: { conditions: ['source'] } },
1152
+ root: { resolve: { alias: { '~': fileURLToPath(new URL('.', import.meta.url)) } } },
1098
1153
  project: { setupFiles: [fileURLToPath(new URL('./vitest.setup.ts', import.meta.url))] },
1099
1154
  };
1100
1155
  ```
@@ -1116,7 +1171,7 @@ Order is the point where `setupFiles` is concerned, since a shared setup file es
1116
1171
 
1117
1172
  **Do not merge two returned configs.** `mergeConfig(defineVitestConfig(), defineVitestConfig(mine))` looks like the idiomatic recovery and fails at startup: both sides declare the same four project names, which Vitest rejects with `Project name "unit" ... is not unique`. Layers merge the factory's inputs instead, which is why they yield four projects however many fold.
1118
1173
 
1119
- A config file that omits the shared layer still loses those settings, silently -- Vitest's own resolution contract, not something the factory can intercept. Guard it with a test that fails when a package's suite goes missing, which also catches a shared `exclude` pattern swallowing one package's tests.
1174
+ A config file that omits the shared layer still loses those settings, silently -- Vitest's own resolution contract, not something the factory can intercept. Guard it with a test that fails when a package's suite goes missing, which also catches a shared `exclude` pattern swallowing one package's tests. Source resolution and git isolation need no such guard: the factory supplies both, so a config file cannot omit them by forgetting a layer.
1120
1175
 
1121
1176
  ### What the config excludes
1122
1177
 
@@ -1173,3 +1228,17 @@ nmr once selected a package's test scripts by looking for a `vitest.integration.
1173
1228
  Until step 1 is done, every test command that names a tier fails against a config declaring no projects, with `Error: No projects matched the filter "unit", "tool".` at startup. `test:all` is the exception: it names no project, so it runs everything the config collects.
1174
1229
 
1175
1230
  That is a change for the better. While `test` selected by negation, an unmigrated config produced a _green_ run, because `--project '!integration'` had nothing to exclude, so the separation was lost with no error anywhere. Positive selection turns the same state into a startup failure that names the missing projects.
1231
+
1232
+ ### Migrating a repo that layered source resolution or git isolation by hand
1233
+
1234
+ The factory supplies both, so the layer a repo wrote for them, and any guard test that checked every config file imported it, can go.
1235
+
1236
+ 1. Delete the shared module's `resolve.conditions` entry for `source` and its `ssr.resolve` twin. Keep the module for whatever else it carries; delete it outright once it carries nothing.
1237
+ 2. Delete the git-isolation setup file and its `setupFiles` entry. nmr's runs in its place, ahead of every entry a layer adds.
1238
+ 3. Delete the test that asserted each config file imports the shared module, where the module is gone.
1239
+
1240
+ Two behaviors change on upgrade even in a repo that layered neither.
1241
+
1242
+ A suite whose tests run `git commit` while relying on the developer's global identity now fails with git's own `Please tell me who you are`. Set the identity locally in the fixture repo the test builds -- `git -C <repo> config user.email`, `user.name`, and `commit.gpgsign false` -- which is what makes the test report the same result on every machine.
1243
+
1244
+ A workspace package declaring a `source` condition is now resolved from source in tests that previously read its `dist`. That is the point of the default, and it means a suite stops exercising build output it may have been exercising by accident.
@@ -4,6 +4,10 @@ import { fileURLToPath } from 'node:url';
4
4
  import { reportError } from '@williamthorsen/nmr-core';
5
5
  import { describeError } from '@williamthorsen/toolbelt.errors';
6
6
  const TAZE_CLI_SPECIFIER = 'taze/cli';
7
+ const REQUEST_TIMEOUT_FLAG = '--request-timeout';
8
+ const REQUEST_TIMEOUT_FLAGS = [REQUEST_TIMEOUT_FLAG, '--requestTimeout'];
9
+ const REQUEST_TIMEOUT_MS = 30_000;
10
+ const ARGUMENT_TERMINATOR = '--';
7
11
  export function runTaze(args, options = {}) {
8
12
  const stderr = options.stderr ?? process.stderr;
9
13
  const resolveCliPath = options.resolveCliPath ?? resolveTazeCliPath;
@@ -17,7 +21,7 @@ export function runTaze(args, options = {}) {
17
21
  reportError(`Could not resolve '${TAZE_CLI_SPECIFIER}' from nmr's dependencies. Reinstall the workspace to restore it. (${detail})`, stderr);
18
22
  return 1;
19
23
  }
20
- const outcome = spawn(process.execPath, [cliPath, ...args]);
24
+ const outcome = spawn(process.execPath, [cliPath, ...composeRequestTimeoutArgs(args), ...args]);
21
25
  if (outcome.error) {
22
26
  reportError(`Failed to run taze: ${outcome.error.message}`, stderr);
23
27
  return 1;
@@ -27,6 +31,18 @@ export function runTaze(args, options = {}) {
27
31
  export function resolveTazeCliPath() {
28
32
  return fileURLToPath(import.meta.resolve(TAZE_CLI_SPECIFIER));
29
33
  }
34
+ function carriesRequestTimeout(args) {
35
+ for (const arg of args) {
36
+ if (arg === ARGUMENT_TERMINATOR)
37
+ return false;
38
+ if (REQUEST_TIMEOUT_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`)))
39
+ return true;
40
+ }
41
+ return false;
42
+ }
43
+ function composeRequestTimeoutArgs(args) {
44
+ return carriesRequestTimeout(args) ? [] : [REQUEST_TIMEOUT_FLAG, String(REQUEST_TIMEOUT_MS)];
45
+ }
30
46
  function spawnNode(nodePath, argv) {
31
47
  const result = spawnSync(nodePath, argv, { stdio: 'inherit' });
32
48
  return { status: result.status, error: result.error };
@@ -4,7 +4,7 @@ const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', declinesArgs: true };
4
4
  export const workspaceScripts = {
5
5
  build: ['compile'],
6
6
  check: [TYPECHECK_STEP, 'fmt:check', 'lint:check', 'test'],
7
- 'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test:coverage'],
7
+ 'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test'],
8
8
  clean: 'nmr-clean',
9
9
  compile: 'nmr-compile',
10
10
  fix: ['lint', 'fmt'],
@@ -31,7 +31,7 @@ export const rootScripts = {
31
31
  'audit:prod': 'pnpm exec v11y --prod',
32
32
  build: ['-R build'],
33
33
  check: [TYPECHECK_STEP, 'fmt:check', 'lint:check', 'test'],
34
- 'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test:coverage'],
34
+ 'check:strict': [TYPECHECK_STEP, 'fmt:check', 'lint:strict', 'test'],
35
35
  ci: [{ run: 'build', declinesArgs: true }, 'check:strict'],
36
36
  clean: 'nmr-clean',
37
37
  fix: ['lint', 'fmt'],
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import os from 'node:os';
2
+ process.env['GIT_CONFIG_GLOBAL'] = os.devNull;
3
+ process.env['GIT_CONFIG_SYSTEM'] = os.devNull;
4
+ process.env['GIT_CONFIG_NOSYSTEM'] = '1';
@@ -10,6 +10,8 @@ export interface VitestConfigOptions {
10
10
  root?: RootOverrides;
11
11
  project?: ProjectConfig;
12
12
  tiers?: Partial<Record<TierName, ProjectConfig>>;
13
+ isolateGit?: boolean;
14
+ resolveFromSource?: boolean;
13
15
  }
14
16
  export interface RootVitestConfigOptions extends VitestConfigOptions {
15
17
  monorepoRoot: string;
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
2
3
  import { defaultExclude, mergeConfig } from 'vitest/config';
3
4
  import { isObject } from "./helpers/type-guards.js";
4
5
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
@@ -6,6 +7,9 @@ import { getWorkspacePackageDirs } from "./workspace.js";
6
7
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
7
8
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
8
9
  const TIER_TIMEOUT = 30_000;
10
+ const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
11
+ const SOURCE_CLIENT_CONDITIONS = ['source', 'module', 'browser', 'development|production'];
12
+ const SOURCE_SERVER_CONDITIONS = ['source', 'module', 'node', 'development|production'];
9
13
  const COVERAGE_EXCLUDE = ['**/__{fixtures,mocks,tests}__/**', '**/index.ts', '**/*.d.ts'];
10
14
  const COLLECTION_EXCLUDE = [...new Set([...defaultExclude, ...TEST_COLLECTION_EXCLUDE.map((dir) => `**/${dir}/**`)])];
11
15
  const PACKAGE_COVERAGE_INCLUDE = ['**/src/**/*.{ts,tsx}'];
@@ -29,6 +33,10 @@ function buildConfig(declaredLayers, { coverageInclude, projectExclude = [], pro
29
33
  const layers = declaredLayers.filter((layer) => layer !== undefined);
30
34
  assertKnownTiers(layers);
31
35
  const config = {
36
+ ...(resolveFlag(layers, 'resolveFromSource') && {
37
+ resolve: { conditions: SOURCE_CLIENT_CONDITIONS },
38
+ ssr: { resolve: { conditions: SOURCE_SERVER_CONDITIONS } },
39
+ }),
32
40
  test: {
33
41
  coverage: {
34
42
  enabled: false,
@@ -59,6 +67,7 @@ function buildProjects(layers, extraExclude, projectRoot) {
59
67
  timeout: TIER_TIMEOUT,
60
68
  })),
61
69
  ];
70
+ const isolateGit = resolveFlag(layers, 'isolateGit');
62
71
  return projectTiers.map(({ exclude, include, name, timeout }) => {
63
72
  const project = {
64
73
  extends: true,
@@ -67,6 +76,7 @@ function buildProjects(layers, extraExclude, projectRoot) {
67
76
  exclude: [...COLLECTION_EXCLUDE, ...exclude, ...extraExclude],
68
77
  include,
69
78
  name,
79
+ ...(isolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
70
80
  ...(timeout !== undefined && { hookTimeout: timeout, testTimeout: timeout }),
71
81
  },
72
82
  };
@@ -98,3 +108,16 @@ function getWorkspaceExcludePatterns(monorepoRoot) {
98
108
  .map((dir) => `${path.relative(monorepoRoot, dir).split(path.sep).join('/')}/**`)
99
109
  .toSorted();
100
110
  }
111
+ function resolveFlag(layers, name) {
112
+ let resolved = true;
113
+ for (const layer of layers) {
114
+ const declared = layer[name];
115
+ if (declared !== undefined)
116
+ resolved = declared;
117
+ }
118
+ return resolved;
119
+ }
120
+ function resolveGitIsolationSetupFile() {
121
+ const thisFile = fileURLToPath(import.meta.url);
122
+ return path.join(path.dirname(thisFile), `vitest-git-isolation${path.extname(thisFile)}`);
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.32.2",
3
+ "version": "0.34.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -21,7 +21,8 @@
21
21
  "license": "ISC",
22
22
  "author": "William Thorsen <william@thorsen.dev> (https://github.com/williamthorsen)",
23
23
  "sideEffects": [
24
- "./dist/esm/cli*.js"
24
+ "./dist/esm/cli*.js",
25
+ "./dist/esm/vitest-git-isolation.js"
25
26
  ],
26
27
  "type": "module",
27
28
  "exports": {
@@ -68,21 +69,22 @@
68
69
  "dist"
69
70
  ],
70
71
  "dependencies": {
71
- "@williamthorsen/toolbelt.errors": "0.5.0",
72
+ "@williamthorsen/toolbelt.errors": "0.6.1",
72
73
  "glob": "13.0.6",
73
74
  "prettier-plugin-sh": "0.19.0",
74
- "taze": "20.0.1",
75
+ "taze": "21.1.0",
75
76
  "yaml": "2.9.0",
76
- "@williamthorsen/nmr-core": "0.10.2"
77
+ "@williamthorsen/nmr-core": "0.10.3"
77
78
  },
78
79
  "devDependencies": {
79
- "@williamthorsen/toolbelt.filesystem": "0.6.0",
80
- "@williamthorsen/toolbelt.testing": "0.3.0",
81
- "@williamthorsen/toolbelt.vitest": "0.4.0",
80
+ "@williamthorsen/toolbelt.filesystem": "0.8.0",
81
+ "@williamthorsen/toolbelt.testing": "0.4.0",
82
+ "@williamthorsen/toolbelt.vitest": "0.6.0",
82
83
  "prettier": "3.9.6",
83
- "readyup": "0.29.0",
84
+ "readyup": "0.32.0",
84
85
  "tinyglobby": "0.2.17",
85
86
  "typescript": "6.0.3",
87
+ "vite": "8.2.1",
86
88
  "vitest": "4.1.10"
87
89
  },
88
90
  "peerDependencies": {