@williamthorsen/nmr 0.55.0 → 1.0.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 (108) hide show
  1. package/.meta/changelog.json +124 -0
  2. package/.readyup/kits/default.js +667 -167
  3. package/.readyup/manifest.json +77 -46
  4. package/CHANGELOG.md +40 -0
  5. package/README.md +1 -14
  6. package/agents/guidance/rulebooks/nmr.md +1 -1
  7. package/bin/ensure-prepublish-hooks.js +1 -1
  8. package/bin/nmr-actionlint.js +1 -1
  9. package/bin/nmr-clean.js +1 -1
  10. package/bin/nmr-compile.js +1 -1
  11. package/bin/nmr-fmt.js +1 -1
  12. package/bin/nmr-report-catalog.js +1 -1
  13. package/bin/nmr-report-overrides.js +1 -1
  14. package/bin/nmr-shellcheck.js +1 -1
  15. package/bin/nmr-taze.js +1 -1
  16. package/bin/nmr-vitest.js +1 -1
  17. package/bin/nmr.js +1 -1
  18. package/dist/esm/bins.d.ts +11 -0
  19. package/dist/esm/bins.js +11 -0
  20. package/dist/esm/check-cache-entries.js +5 -5
  21. package/dist/esm/check-cache-gate.js +2 -1
  22. package/dist/esm/child-env.js +2 -1
  23. package/dist/esm/child-process.d.ts +2 -0
  24. package/dist/esm/child-process.js +3 -3
  25. package/dist/esm/cli-actionlint.js +2 -1
  26. package/dist/esm/cli-build.js +2 -1
  27. package/dist/esm/cli-clean.js +2 -1
  28. package/dist/esm/cli-ensure-prepublish-hooks.js +29 -16
  29. package/dist/esm/cli-fmt.js +2 -1
  30. package/dist/esm/cli-report-catalog.js +2 -1
  31. package/dist/esm/cli-report-overrides.js +2 -1
  32. package/dist/esm/cli-shellcheck.js +2 -1
  33. package/dist/esm/cli-taze.js +2 -1
  34. package/dist/esm/cli-vitest.js +2 -1
  35. package/dist/esm/cli.js +2 -1
  36. package/dist/esm/commands/actionlint.d.ts +3 -3
  37. package/dist/esm/commands/actionlint.js +5 -5
  38. package/dist/esm/commands/build.js +6 -6
  39. package/dist/esm/commands/clean.js +3 -1
  40. package/dist/esm/commands/compiler.js +3 -3
  41. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +1 -1
  42. package/dist/esm/commands/ensure-prepublish-hooks.js +4 -5
  43. package/dist/esm/commands/fmt.d.ts +3 -3
  44. package/dist/esm/commands/fmt.js +33 -52
  45. package/dist/esm/commands/format-targets.js +1 -13
  46. package/dist/esm/commands/node-pin.js +3 -4
  47. package/dist/esm/commands/rewrite-specifiers.js +2 -2
  48. package/dist/esm/commands/run-vitest.js +5 -5
  49. package/dist/esm/commands/shellcheck.d.ts +3 -3
  50. package/dist/esm/commands/shellcheck.js +8 -11
  51. package/dist/esm/commands/taze.js +5 -4
  52. package/dist/esm/commands/tsconfig-chain.js +3 -3
  53. package/dist/esm/commands/workflow-tools.d.ts +12 -3
  54. package/dist/esm/commands/workflow-tools.js +29 -14
  55. package/dist/esm/config-validation.js +9 -9
  56. package/dist/esm/config.js +2 -3
  57. package/dist/esm/delegate-host.d.ts +1 -1
  58. package/dist/esm/delegate-host.js +5 -3
  59. package/dist/esm/entry-chain.js +2 -3
  60. package/dist/esm/fan-out.d.ts +3 -3
  61. package/dist/esm/fan-out.js +14 -6
  62. package/dist/esm/git-ignored-paths.js +3 -3
  63. package/dist/esm/help.d.ts +14 -15
  64. package/dist/esm/help.js +29 -54
  65. package/dist/esm/helpers/filter-selection.js +2 -2
  66. package/dist/esm/helpers/package-json.js +8 -9
  67. package/dist/esm/helpers/resolveXdgBaseDir.d.ts +5 -0
  68. package/dist/esm/helpers/resolveXdgBaseDir.js +12 -0
  69. package/dist/esm/helpers/type-guards.d.ts +0 -2
  70. package/dist/esm/helpers/type-guards.js +2 -4
  71. package/dist/esm/invocation-args.d.ts +0 -32
  72. package/dist/esm/invocation-args.js +23 -121
  73. package/dist/esm/invocation.d.ts +3 -1
  74. package/dist/esm/invocation.js +1 -0
  75. package/dist/esm/machine-lock.js +3 -4
  76. package/dist/esm/nmr-flags.d.ts +109 -0
  77. package/dist/esm/nmr-flags.js +120 -0
  78. package/dist/esm/output-style.d.ts +1 -1
  79. package/dist/esm/output-style.js +2 -1
  80. package/dist/esm/pass-manifest.js +2 -2
  81. package/dist/esm/presentation.d.ts +1 -1
  82. package/dist/esm/remote-host-session.d.ts +2 -0
  83. package/dist/esm/remote-host-session.js +1 -0
  84. package/dist/esm/remote-probe.js +2 -2
  85. package/dist/esm/remote-run.d.ts +3 -3
  86. package/dist/esm/remote-run.js +6 -4
  87. package/dist/esm/remote-session.js +4 -4
  88. package/dist/esm/remote-snapshot.js +8 -22
  89. package/dist/esm/resolver.js +3 -4
  90. package/dist/esm/route.d.ts +29 -0
  91. package/dist/esm/route.js +94 -0
  92. package/dist/esm/run-chain.d.ts +1 -1
  93. package/dist/esm/run-chain.js +4 -2
  94. package/dist/esm/runCli.d.ts +2 -0
  95. package/dist/esm/runCli.js +36 -78
  96. package/dist/esm/stated-outcome.js +3 -3
  97. package/dist/esm/steps.js +6 -30
  98. package/dist/esm/user-config.js +7 -14
  99. package/dist/esm/vitest-source-resolution.js +5 -6
  100. package/dist/esm/vitest-temp-isolation.d.ts +2 -0
  101. package/dist/esm/vitest-temp-isolation.js +44 -0
  102. package/dist/esm/vitest.d.ts +9 -0
  103. package/dist/esm/vitest.js +8 -5
  104. package/docs/reporting.md +3 -3
  105. package/docs/scripts.md +1 -1
  106. package/docs/utilities.md +5 -5
  107. package/docs/vitest.md +11 -6
  108. package/package.json +4 -4
@@ -1,10 +1,9 @@
1
1
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { isBuiltin } from 'node:module';
3
3
  import path from 'node:path';
4
- import { UserError } from '@williamthorsen/nmr-core';
4
+ import { isRecord, UserError } from '@williamthorsen/nmr-core';
5
5
  import { findDirectoryChainMatch } from '@williamthorsen/toolbelt.filesystem';
6
6
  import { parsePackageJson } from "./helpers/package-json.js";
7
- import { isObject } from "./helpers/type-guards.js";
8
7
  const SHARED_CONDITIONS = ['module', 'import', 'development', 'default'];
9
8
  const CLIENT_CONDITION = 'browser';
10
9
  const SERVER_CONDITION = 'node';
@@ -32,7 +31,7 @@ export function resolveSourceTarget(specifier, importer, context) {
32
31
  if (packageDir === undefined || isInsideNodeModules(packageDir))
33
32
  return undefined;
34
33
  const manifest = readManifest(packageDir, context.manifests);
35
- if (!isObject(manifest))
34
+ if (!isRecord(manifest))
36
35
  return undefined;
37
36
  const matchedSubpath = matchSubpath(manifest['exports'], parsedSpecifier.subpath);
38
37
  if (!matchedSubpath)
@@ -72,7 +71,7 @@ function findSelfReferenceDir(name, fromDir) {
72
71
  const file = path.join(dir, 'package.json');
73
72
  if (existsSync(file)) {
74
73
  const parsedManifest = parsePackageJson(readFileSync(file, 'utf8'), file);
75
- return isObject(parsedManifest) && parsedManifest['name'] === name && parsedManifest['exports'] !== undefined
74
+ return isRecord(parsedManifest) && parsedManifest['name'] === name && parsedManifest['exports'] !== undefined
76
75
  ? dir
77
76
  : undefined;
78
77
  }
@@ -122,7 +121,7 @@ function isInsideNodeModules(dir) {
122
121
  function matchSubpath(exportsValue, subpath) {
123
122
  if (exportsValue === undefined)
124
123
  return undefined;
125
- if (!isObject(exportsValue) || Object.keys(exportsValue).every((key) => !key.startsWith('.'))) {
124
+ if (!isRecord(exportsValue) || Object.keys(exportsValue).every((key) => !key.startsWith('.'))) {
126
125
  return subpath === '.' ? { value: exportsValue } : undefined;
127
126
  }
128
127
  const exactValue = exportsValue[subpath];
@@ -181,7 +180,7 @@ function selectTarget(value, conditions) {
181
180
  }
182
181
  return undefined;
183
182
  }
184
- if (!isObject(value))
183
+ if (!isRecord(value))
185
184
  return undefined;
186
185
  const source = value[SOURCE_CONDITION];
187
186
  if (source !== undefined) {
@@ -0,0 +1,2 @@
1
+ /** Creates this run's temp directory, points `TMPDIR` at it, and returns the teardown that removes it. */
2
+ export declare function setup(): () => void;
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { hasErrnoCode } from '@williamthorsen/nmr-core';
5
+ const RUN_DIR_PREFIX = 'nmr-vitest-';
6
+ const RUN_DIR_PATTERN = /^nmr-vitest-([1-9]\d*)-./;
7
+ export function setup() {
8
+ const baseDir = os.tmpdir();
9
+ removeOrphanedRunDirs(baseDir);
10
+ const runDir = fs.mkdtempSync(path.join(baseDir, `${RUN_DIR_PREFIX}${process.pid}-`));
11
+ const previousTmpdir = process.env['TMPDIR'];
12
+ process.env['TMPDIR'] = runDir;
13
+ return function teardown() {
14
+ if (previousTmpdir === undefined) {
15
+ delete process.env['TMPDIR'];
16
+ }
17
+ else {
18
+ process.env['TMPDIR'] = previousTmpdir;
19
+ }
20
+ fs.rmSync(runDir, { force: true, maxRetries: 5, recursive: true });
21
+ };
22
+ }
23
+ function isProcessGone(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return false;
27
+ }
28
+ catch (error) {
29
+ return hasErrnoCode(error, 'ESRCH');
30
+ }
31
+ }
32
+ function removeOrphanedRunDirs(baseDir) {
33
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
34
+ for (const entry of entries) {
35
+ const pidText = RUN_DIR_PATTERN.exec(entry.name)?.[1];
36
+ if (!entry.isDirectory() || pidText === undefined || !isProcessGone(Number(pidText)))
37
+ continue;
38
+ try {
39
+ fs.rmSync(path.join(baseDir, entry.name), { force: true, recursive: true });
40
+ }
41
+ catch {
42
+ }
43
+ }
44
+ }
@@ -44,6 +44,15 @@ export interface VitestConfigOptions {
44
44
  * Turn it off only when a suite is meant to read the developer's own git configuration.
45
45
  */
46
46
  shouldIsolateGit?: boolean;
47
+ /**
48
+ * Loads nmr's temp-isolation global setup into the root config. Defaults to `true`. The setup gives the run a
49
+ * directory of its own under the temp directory in effect at startup, points `TMPDIR` at it for every worker and
50
+ * every process that a test spawns, and removes it when the run ends, along with any that an interrupted run left
51
+ * behind. Vitest runs a project's own `globalSetup` before the root's, so one that a layer adds through `project`
52
+ * runs outside the run directory. Turn the isolation off only when a suite must leave files in the ambient temp
53
+ * directory after the run.
54
+ */
55
+ shouldIsolateTempDir?: boolean;
47
56
  /**
48
57
  * Resolves a package whose files are outside every `node_modules` through its `source` export condition, so a
49
58
  * suite runs without a prior build. Defaults to `true`. A package that does not declare a `source` condition is
@@ -1,9 +1,9 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { isRecord } from '@williamthorsen/nmr-core';
4
5
  import { defaultExclude, mergeConfig } from 'vitest/config';
5
6
  import { listGitIgnoredPaths } from "./git-ignored-paths.js";
6
- import { isObject } from "./helpers/type-guards.js";
7
7
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
8
8
  import { deriveDefaultMaxWorkers, readMaxWorkersFlag } from "./vitest-max-workers.js";
9
9
  import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
@@ -12,7 +12,8 @@ import { toWorkspacePackageGlobs } from "./workspace-globs.js";
12
12
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
13
13
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
14
14
  const TIER_TIMEOUT_MS = 30_000;
15
- const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
15
+ const GIT_ISOLATION_SETUP_FILE = resolveSiblingModule('vitest-git-isolation');
16
+ const TEMP_ISOLATION_SETUP_FILE = resolveSiblingModule('vitest-temp-isolation');
16
17
  const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
17
18
  const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
18
19
  const COVERAGE_EXCLUDE = ['**/__{fixtures,mocks,tests}__/**', '**/index.ts', '**/*.d.ts'];
@@ -22,6 +23,7 @@ const RECOGNIZED_OPTIONS = {
22
23
  project: true,
23
24
  root: true,
24
25
  shouldIsolateGit: true,
26
+ shouldIsolateTempDir: true,
25
27
  shouldResolveFromSource: true,
26
28
  testCollectionExclude: true,
27
29
  tiers: true,
@@ -44,7 +46,7 @@ export function defineVitestConfig(...layers) {
44
46
  }
45
47
  export function defineRootVitestConfig(...layers) {
46
48
  const lastLayer = layers.at(-1);
47
- const monorepoRoot = isObject(lastLayer) ? lastLayer['monorepoRoot'] : undefined;
49
+ const monorepoRoot = isRecord(lastLayer) ? lastLayer['monorepoRoot'] : undefined;
48
50
  if (typeof monorepoRoot !== 'string' || !path.isAbsolute(monorepoRoot)) {
49
51
  throw new TypeError(MISSING_MONOREPO_ROOT);
50
52
  }
@@ -74,6 +76,7 @@ function buildConfig(declaredLayers, { coverageInclude, ignoredPathsRoot, projec
74
76
  include: coverageInclude,
75
77
  provider: 'v8',
76
78
  },
79
+ ...(resolveFlag(layers, 'shouldIsolateTempDir', true) && { globalSetup: [TEMP_ISOLATION_SETUP_FILE] }),
77
80
  passWithNoTests: true,
78
81
  projects: buildProjects(layers, projectExclude, projectRoot, ignoredPathsRoot),
79
82
  silent: 'passed-only',
@@ -189,7 +192,7 @@ function resolveFlag(layers, name, isOnByDefault) {
189
192
  }
190
193
  return resolvedFlag;
191
194
  }
192
- function resolveGitIsolationSetupFile() {
195
+ function resolveSiblingModule(basename) {
193
196
  const thisFile = fileURLToPath(import.meta.url);
194
- return path.join(path.dirname(thisFile), `vitest-git-isolation${path.extname(thisFile)}`);
197
+ return path.join(path.dirname(thisFile), `${basename}${path.extname(thisFile)}`);
195
198
  }
package/docs/reporting.md CHANGED
@@ -64,13 +64,13 @@ Because the format is nmr's own reporting, and nmr does not tell the commands th
64
64
 
65
65
  ## Reserved environment variables
66
66
 
67
- `NMR_COMMAND_VERBOSITY` is nmr's own, and sets how loudly a run reports the output of the commands that it runs. Its values are `full` and `quiet`; nmr reports any other non-empty value and exits 1, rather than falling back to a mode that nobody chose, and an empty value reads as unset. Each process in a chain suppresses the output of the command that it runs rather than of everything below it, so the failing command's output still prints on a failure. It governs those commands' output alone: nmr's [verdicts](#what-nmr-reports) print in every verbosity, and the override notice is the one message that a quiet run withholds.
67
+ `NMR_COMMAND_VERBOSITY` is nmr's own, and sets how loudly a run reports the output of the commands that it runs. Its values are `full` and `quiet`; nmr reports any other non-empty value as a usage error and exits 2, rather than falling back to a mode that nobody chose, and an empty value reads as unset. Each process in a chain suppresses the output of the command that it runs rather than of everything below it, so the failing command's output still prints on a failure. It governs those commands' output alone: nmr's [verdicts](#what-nmr-reports) print in every verbosity, and the override notice is the one message that a quiet run withholds.
68
68
 
69
69
  `nmr -q <command>` sets it for the run and outranks an inherited value. Both values are spelled out so that either direction is expressible: Export `NMR_COMMAND_VERBOSITY=quiet` for a quiet shell, and set `full` on one invocation to opt back out.
70
70
 
71
71
  `NMR_OUTPUT_STYLE` is nmr's own as well, and sets the style in which nmr renders every marker that it prints; see [output style](#output-style) below.
72
72
 
73
- `NMR_REPORT_FORMAT` is nmr's own as well, and sets how nmr renders its own verdicts. Its values are `text` and `json`; nmr reports any other non-empty value and exits 1, and an empty value reads as unset. `--json` sets it for the run and outranks an inherited value, and both values are spelled out so that either direction is expressible: Export `NMR_REPORT_FORMAT=json` for a shell whose consumer parses, and set `text` on one invocation to opt back out. The format has two sources, then, whereas a verbosity has four: It does not have a repo-level default or harness detection, because detection would hand every agent JSON in place of the prose that the [bundled guidance](../README.md#agent-guidance) teaches. See [reporting for a machine](#reporting-for-a-machine) for what the objects contain.
73
+ `NMR_REPORT_FORMAT` is nmr's own as well, and sets how nmr renders its own verdicts. Its values are `text` and `json`; nmr reports any other non-empty value as a usage error and exits 2, and an empty value reads as unset. `--json` sets it for the run and outranks an inherited value, and both values are spelled out so that either direction is expressible: Export `NMR_REPORT_FORMAT=json` for a shell whose consumer parses, and set `text` on one invocation to opt back out. The format has two sources, then, whereas a verbosity has four: It does not have a repo-level default or harness detection, because detection would hand every agent JSON in place of the prose that the [bundled guidance](../README.md#agent-guidance) teaches. See [reporting for a machine](#reporting-for-a-machine) for what the objects contain.
74
74
 
75
75
  `NMR_ENTRY_CHAIN` is nmr's own as well. It lists the commands that the current run has entered and the directories in which it entered them, and nmr passes it to every process that it starts, so that a process re-entering a command already running in its directory fails instead of repeating without end; see [self-referential entries](scripts.md#three-tier-override-system). Do not set it: nmr reports a value that it cannot read and exits 1.
76
76
 
@@ -78,7 +78,7 @@ Because the format is nmr's own reporting, and nmr does not tell the commands th
78
78
 
79
79
  ### Output style
80
80
 
81
- `--output-style <auto|plain|rich>` sets the style for one invocation, and `NMR_OUTPUT_STYLE` sets it for a shell: `rich` marks each verdict with an emoji, such as ✅ or ❌, and `plain` prints a word in its place, such as `PASS` or `FAIL`, which a reader of a log can search for. `auto`, the default, defers to detection. Any other value is a usage error, and nmr exits with code 1, whichever source named it. The flag outranks the variable, and both accept `auto` as well as a style, so a shell exporting one style is overridable on one invocation in either direction.
81
+ `--output-style <auto|plain|rich>` sets the style for one invocation, and `NMR_OUTPUT_STYLE` sets it for a shell: `rich` marks each verdict with an emoji, such as ✅ or ❌, and `plain` prints a word in its place, such as `PASS` or `FAIL`, which a reader of a log can search for. `auto`, the default, defers to detection. Any other value is a usage error, and nmr exits with code 2, whichever source named it. The flag outranks the variable, and both accept `auto` as well as a style, so a shell exporting one style is overridable on one invocation in either direction.
82
82
 
83
83
  Detection decides stdout and stderr separately. A stream is plain when `CI` is set to anything other than an empty string or `false`, when the stream is not a terminal, or when `TERM` is `linux`; otherwise it is rich. `--json` output does not contain a marker in either style.
84
84
 
package/docs/scripts.md CHANGED
@@ -198,7 +198,7 @@ The run never enters a package, so per-package `lint:pre` and `lint:post` hooks
198
198
 
199
199
  `lint:workflows`, a root command, lints the GitHub Actions workflows under `.github/workflows/` with [actionlint](https://github.com/rhysd/actionlint), including its shellcheck pass over every `run:` block, and fails on any finding. A repo without `.github/workflows/` passes. The command runs `nmr-actionlint`, which forwards trailing arguments to actionlint, so `nmr lint:workflows .github/workflows/ci.yaml` lints that file alone; inside `check`, `check:strict`, and `root:check`, the step declines them.
200
200
 
201
- The result does not depend on what the machine has installed. nmr pins actionlint 1.7.12 and shellcheck 0.11.0, downloads each from its upstream GitHub release on first use, verifies it against the SHA-256 recorded in nmr, and caches it under `node_modules/.cache/nmr-tools/` at the monorepo root. The first run therefore needs access to `github.com`, and a failed download or a checksum mismatch fails the command. The pyflakes integration is off. The pins move only with a release of nmr.
201
+ The result does not depend on what the machine has installed. nmr pins actionlint 1.7.12 and shellcheck 0.11.0, downloads each from its upstream GitHub release on first use, verifies it against the SHA-256 recorded in nmr, and caches it once per machine under `$XDG_CACHE_HOME/nmr/tools/`, or `~/.cache/nmr/tools/` when `XDG_CACHE_HOME` is unset or not absolute, so that every worktree on the machine shares one copy. A run first looks in that cache and then in `node_modules/.cache/nmr-tools/` at the monorepo root. When the tool is in neither, the run installs it into the machine cache, or into the monorepo-root cache when the machine cache cannot be written, as in a sandbox that confines writes to the worktree. The first run on a machine therefore needs access to `github.com`, and a failed download or a checksum mismatch fails the command. The pyflakes integration is off. The pins move only with a release of nmr.
202
202
 
203
203
  The supported platforms are macOS and Linux on x64 and arm64; on any other, the command fails and names the platform. actionlint reads its own configuration from `.github/actionlint.yaml`, through which a repo can ignore a finding.
204
204
 
package/docs/utilities.md CHANGED
@@ -100,8 +100,8 @@ Verify that all publishable workspace packages have a `prepublishOnly` script. E
100
100
  ensure-prepublish-hooks
101
101
  ```
102
102
 
103
- | Flag | Description | Default |
104
- | --------------------- | ----------------------------- | ----------------- |
105
- | `--fix` | Add missing hooks | — |
106
- | `--dry-run` | Preview what `--fix` would do | — |
107
- | `--command <command>` | Custom hook command | `"npm run build"` |
103
+ | Flag | Description | Default |
104
+ | --------------------- | ----------------------------- | ------------- |
105
+ | `--fix` | Add missing hooks | — |
106
+ | `--dry-run` | Preview what `--fix` would do | — |
107
+ | `--command <command>` | Custom hook command | `"nmr build"` |
package/docs/vitest.md CHANGED
@@ -108,18 +108,23 @@ In a repo that has not declared the check, [nmr's readyup kit](../README.md#conf
108
108
 
109
109
  ## What the config supplies
110
110
 
111
- Two settings that a workspace test runner needs almost universally are part of what the factory produces, so a config file declares neither:
111
+ Three settings that a workspace test runner needs almost universally are part of what the factory produces, so a config file declares none of them:
112
112
 
113
113
  - **A package outside `node_modules` resolves from source.** Because a cross-package import resolves to the dependency's `.ts` rather than its `dist/`, through the `source` condition declared by its `exports` map, a suite runs without a prior build. The reach is what the boundary names: a workspace package, or one linked into the repo from elsewhere, whose real path is outside every `node_modules`. A package that does not declare a `source` condition is unaffected, and so is every package under `node_modules`, whatever its `exports` map declares.
114
114
  - **Git subprocesses are isolated.** Every project loads a setup file that points `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` at the null device, sets `GIT_CONFIG_NOSYSTEM` and `GIT_ATTR_NOSYSTEM`, and injects `core.excludesFile`, `core.attributesFile`, and `maintenance.auto=false` through `GIT_CONFIG_COUNT` and its key/value pairs, so a test that spawns `git` reads neither the developer's identity, nor a signing config that can block on a passphrase, nor their global ignore rules and attributes, and a command such as `git commit` does not start a detached `git maintenance` process that could still be writing under `.git` when the test removes the repository. Those last two need a step of their own: Because neither path is config-derived, git resolves each from `$XDG_CONFIG_HOME/git/`, or from `~/.config/git/` when that variable is unset, whatever the config variables say. `GIT_CONFIG_GLOBAL` already replaces `$XDG_CONFIG_HOME/git/config`; those two are the rest of what that directory supplies. The system attributes file at `$(prefix)/etc/gitattributes` is not named by any config variable at all, which is what `GIT_ATTR_NOSYSTEM` covers. Redirecting `XDG_CONFIG_HOME` would cover the per-user pair as well, but the setup file does not redirect it, because other tools spawned by a test read that variable for configuration of their own: pnpm fails to start under `XDG_CONFIG_HOME=/dev/null`. An injected key outranks repository-local config, so a repository setting its own `core.excludesFile` is overridden under the isolation; a test needing its own value passes it on the invocation as `git -c core.excludesFile=...`, which outranks the injection. The setup file leads each project's `setupFiles`, ahead of every entry that the layers add, because a shared setup establishes the environment in which the rest run.
115
+ - **Each run has its own temp directory.** The root config loads a global setup that creates a run directory named `nmr-vitest-<pid>-…` under the temp directory in effect at startup, points `TMPDIR` at it, and removes it when the run ends, whether its tests pass or fail. Every worker and every process that a test spawns inherits the variable, so scratch files that a test does not clean up stay out of the shared temp directory, where they would otherwise accumulate across runs and repositories until a tmpfs with a fixed inode count has none left. A run stopped by a signal skips global teardown, so the setup first removes each run directory whose process has exited, and leaves those whose process is still running. Vitest runs a project's own `globalSetup` before the root's, so one that a layer adds through `project` runs outside the run directory.
115
116
 
116
- 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:
117
+ The first two 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. The third exists because a leak shows up only once the temp directory is full, in whatever command next writes to it. Turn any of them off through its own option, which folds across layers like any other, the last layer to declare it winning:
117
118
 
118
119
  ```ts
119
- export default defineVitestConfig({ shouldIsolateGit: false, shouldResolveFromSource: false });
120
+ export default defineVitestConfig({
121
+ shouldIsolateGit: false,
122
+ shouldIsolateTempDir: false,
123
+ shouldResolveFromSource: false,
124
+ });
120
125
  ```
121
126
 
122
- `shouldResolveFromSource: false` is for a repo whose own packages declare a `source` condition that it does not want in tests; `shouldIsolateGit: false` is for a suite meant to read the developer's own git configuration.
127
+ `shouldResolveFromSource: false` is for a repo whose own packages declare a `source` condition that it does not want in tests; `shouldIsolateGit: false` is for a suite meant to read the developer's own git configuration; `shouldIsolateTempDir: false` is for a suite whose files must outlive the run in the ambient temp directory.
123
128
 
124
129
  **Vite replaces its condition defaults rather than extending them.** A config writing `conditions: ['my-condition']` 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. Because the factory emits Vite's defaults for each environment, whichever way `shouldResolveFromSource` is set, a layer adding a condition extends a complete list rather than replacing one.
125
130
 
@@ -157,7 +162,7 @@ export default defineVitestConfig({ tsconfigPaths: true });
157
162
 
158
163
  It folds across layers like the other two, the last layer to declare it winning. It does not need an `ssr` counterpart the way a condition does: Because Vite keeps this setting outside its per-environment resolve options, the single key applies to the environment through which a test's own imports resolve.
159
164
 
160
- **It is an opt-in because its silent direction is the reverse of the two defaults'.** Leaving it off fails loudly, with an unresolved import naming the specifier. Turning it on is the silent direction, for a repo that declares `paths` for `tsc` alone: A specifier that resolved through a package's `exports` starts resolving through the alias instead, and nothing in the run reports the switch. A repo that does not declare `paths` is unaffected either way.
165
+ **It is an opt-in because its silent direction is the reverse of the defaults'.** Leaving it off fails loudly, with an unresolved import naming the specifier. Turning it on is the silent direction, for a repo that declares `paths` for `tsc` alone: A specifier that resolved through a package's `exports` starts resolving through the alias instead, and nothing in the run reports the switch. A repo that does not declare `paths` is unaffected either way.
161
166
 
162
167
  **It requires Vite 8**, the version that introduced `resolve.tsconfigPaths`. nmr declares `vite` as an optional peer at `>=8.0.0 <9`, so a repo that declares a stale `vite` of its own sees a warning at install time. That check does not apply to a repo that does not declare `vite` itself, whose Vite is the one that Vitest brings in: a dependency under Vitest 4 and an auto-installed peer under Vitest 5. Both majors accept Vite 6 and 7: On those, the emitted key does nothing and the aliased import fails exactly as it would with the flag off.
163
168
 
@@ -223,7 +228,7 @@ Order matters for `setupFiles`, because a shared setup file establishes the envi
223
228
 
224
229
  **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.
225
230
 
226
- 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 that excludes one package's tests. Source resolution and git isolation do not need such a guard: The factory supplies both, so a config file cannot omit them by forgetting a layer.
231
+ 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 that excludes one package's tests. Source resolution, git isolation, and temp-directory isolation do not need such a guard: The factory supplies all three, so a config file cannot omit them by forgetting a layer.
227
232
 
228
233
  ## What the config excludes
229
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.55.0",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -80,10 +80,10 @@
80
80
  "!docs/*.v*.md"
81
81
  ],
82
82
  "dependencies": {
83
- "@williamthorsen/nmr-core": "0.15.1",
83
+ "@williamthorsen/nmr-core": "0.16.0",
84
84
  "@williamthorsen/toolbelt.errors": "0.9.0",
85
85
  "@williamthorsen/toolbelt.filesystem": "0.10.0",
86
- "glob": "14.0.3",
86
+ "glob": "14.0.4",
87
87
  "prettier": "3.9.10",
88
88
  "prettier-plugin-sh": "0.20.2",
89
89
  "taze": "21.3.0",
@@ -91,7 +91,7 @@
91
91
  },
92
92
  "devDependencies": {
93
93
  "@williamthorsen/toolbelt.testing": "0.8.0",
94
- "@williamthorsen/toolbelt.vitest": "0.13.1",
94
+ "@williamthorsen/toolbelt.vitest": "0.13.2",
95
95
  "esbuild": "0.28.2",
96
96
  "readyup": "0.45.0",
97
97
  "tinyglobby": "0.2.17",