@williamthorsen/nmr 0.44.0 → 0.46.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.
@@ -41,10 +41,18 @@ export interface NmrStepTarget {
41
41
  isWorkspaceRoot: boolean;
42
42
  }
43
43
  /**
44
- * How a `package.json` entry names the command under which it is declared: as its whole value, or alongside
45
- * other steps. `sole` covers an entry with trailing arguments, which do not declare a step of their own.
44
+ * How an entry names the command under which it is declared: as its whole value, or alongside other steps.
45
+ * `sole` covers an entry with trailing arguments, which do not declare a step of their own.
46
46
  */
47
47
  export type SelfReference = 'chained' | 'sole';
48
+ /**
49
+ * Where an entry resolves, which decides the scopes that `-w`, `-R`, and `-F` send its command to:
50
+ * - `root`: the root's `package.json` or the config's `rootScripts`, resolved at the monorepo root.
51
+ * - `package`: a non-root package's own `package.json`, resolved in that package alone.
52
+ * - `workspaceRegistry`: the config's `workspaceScripts`, resolved in every package that does not override the
53
+ * command.
54
+ */
55
+ export type SelfReferenceScope = 'package' | 'root' | 'workspaceRegistry';
48
56
  /**
49
57
  * Composes the structural step that re-invokes nmr for one composite element.
50
58
  *
@@ -94,24 +102,41 @@ export declare function quoteToken(token: string): string;
94
102
  */
95
103
  export declare function readNmrStep(step: Step): NmrStepTarget | undefined;
96
104
  /**
97
- * Reports whether a `package.json` entry re-invokes the command under which it is declared, and whether it
98
- * declares anything besides. Reports nothing for an entry that names another command, or none.
99
- *
100
- * Honouring such an entry would spawn a shell running the same command in the same directory, which reads the
101
- * same entry again without bound, so resolution discards it however it reads. `chained` separates an entry that
102
- * thereby loses steps from one that declares nothing to lose.
105
+ * Reports whether a string entry re-invokes the command under which it is declared, and whether it declares
106
+ * anything besides. Reports nothing for an entry that names another command, or none.
103
107
  *
104
- * A segment that delegates sends the command to other scopes rather than back to this one. `-w` re-enters
105
- * only from the root, a package's `-w` entry resolving against the root's registry and `package.json` instead of its own.
108
+ * Honouring such an entry would spawn a shell running the same command in a scope that reads the same entry
109
+ * again, without bound. `chained` separates an entry that thereby loses steps from one that declares nothing to
110
+ * lose.
106
111
  *
107
- * Partial in the same ways `findNmrCrossing` is, and for the same reason: What goes unrecognized re-enters
108
- * without bound, which hangs rather than passing quietly.
112
+ * Partial in the same ways `findNmrCrossing` is, and for the same reason: What goes unrecognized re-enters once,
113
+ * and the run fails when `enterChain` finds the command repeated in the same directory.
109
114
  */
110
115
  export declare function readSelfReference(options: {
111
- anchoredAtRoot: boolean;
112
116
  commandName: string;
117
+ scope: SelfReferenceScope;
113
118
  script: string;
114
119
  }): SelfReference | undefined;
120
+ /**
121
+ * Returns the command that a string script names when its whole value is `nmr <command>`, or `undefined` for any
122
+ * other value.
123
+ *
124
+ * Exact by design: A flag, an argument, an environment assignment, or a launcher each change what the invocation
125
+ * runs, or where, so a value that contains one is not interchangeable with the script that it names.
126
+ */
127
+ export declare function readSoleNmrCommand(script: string): string | undefined;
128
+ /**
129
+ * Reports whether a step list names the command under which it is declared, and whether it declares anything
130
+ * besides. Takes each element's instruction: the bare string, or a spec's `run`.
131
+ *
132
+ * Reads an element as the instruction that nmr composes into `nmr <element>`, and applies the declaring scope
133
+ * directly rather than through the `-w` that composition adds at the root.
134
+ */
135
+ export declare function readStepListSelfReference(options: {
136
+ commandName: string;
137
+ elements: readonly string[];
138
+ scope: SelfReferenceScope;
139
+ }): SelfReference | undefined;
115
140
  /**
116
141
  * Renders a step list as the `&&` chain that a shell runs.
117
142
  *
package/dist/esm/steps.js CHANGED
@@ -49,18 +49,50 @@ export function readNmrStep(step) {
49
49
  return file === 'nmr' ? readNmrTarget(rest) : undefined;
50
50
  }
51
51
  export function readSelfReference(options) {
52
- const { anchoredAtRoot, commandName, script } = options;
52
+ const { commandName, scope, script } = options;
53
53
  const segments = splitSegments(script).filter((segment) => segment.trim() !== '');
54
54
  const doesReenter = segments.some((segment) => {
55
- const tail = readNmrTail(segment);
56
- const target = tail === undefined ? undefined : readNmrTarget(tail);
57
- return target?.command === commandName && !target.isDelegate && (anchoredAtRoot || !target.isWorkspaceRoot);
55
+ const invocation = readNmrTail(segment);
56
+ const target = invocation === undefined ? undefined : readNmrTarget(invocation.tail);
57
+ if (invocation === undefined || target === undefined) {
58
+ return false;
59
+ }
60
+ const launcherScope = readLauncherScope(invocation.launcher, invocation.launcherArgs);
61
+ const effectiveTarget = {
62
+ command: target.command,
63
+ isDelegate: target.isDelegate || launcherScope.isDelegate,
64
+ isRecursive: target.isRecursive || launcherScope.isRecursive,
65
+ isWorkspaceRoot: target.isWorkspaceRoot || launcherScope.isWorkspaceRoot,
66
+ };
67
+ return isReentry(effectiveTarget, commandName, scope);
58
68
  });
59
69
  if (!doesReenter) {
60
70
  return undefined;
61
71
  }
62
72
  return segments.length === 1 ? 'sole' : 'chained';
63
73
  }
74
+ export function readSoleNmrCommand(script) {
75
+ const segments = splitSegments(script).filter((segment) => segment.trim() !== '');
76
+ if (segments.length !== 1) {
77
+ return undefined;
78
+ }
79
+ const [program, command, ...rest] = tokenizeSegment(segments[0] ?? '');
80
+ if (program !== 'nmr' || command === undefined || rest.length > 0) {
81
+ return undefined;
82
+ }
83
+ return SHELL_SAFE_TOKEN.test(command) && !command.startsWith('-') ? command : undefined;
84
+ }
85
+ export function readStepListSelfReference(options) {
86
+ const { commandName, elements, scope } = options;
87
+ const doesReenter = elements.some((element) => {
88
+ const target = readNmrTarget(tokenize(element));
89
+ return target !== undefined && isReentry(target, commandName, scope);
90
+ });
91
+ if (!doesReenter) {
92
+ return undefined;
93
+ }
94
+ return elements.length === 1 ? 'sole' : 'chained';
95
+ }
64
96
  export function renderChain(steps) {
65
97
  return steps.map(renderStep).join(' && ');
66
98
  }
@@ -74,24 +106,59 @@ function isRedirectionOperator(char, precedingText, nextChar) {
74
106
  }
75
107
  return (char === '&' && nextChar === '>') || /[<>]\s*$/.test(precedingText);
76
108
  }
109
+ function isReentry(target, commandName, scope) {
110
+ if (target.command !== commandName) {
111
+ return false;
112
+ }
113
+ switch (scope) {
114
+ case 'root':
115
+ return !target.isDelegate;
116
+ case 'package':
117
+ return target.isRecursive || (!target.isDelegate && !target.isWorkspaceRoot);
118
+ case 'workspaceRegistry':
119
+ return target.isDelegate || !target.isWorkspaceRoot;
120
+ }
121
+ }
122
+ function readLauncherScope(launcher, launcherArgs) {
123
+ const scope = { isDelegate: false, isRecursive: false, isWorkspaceRoot: false };
124
+ if (launcher !== 'pnpm') {
125
+ return scope;
126
+ }
127
+ for (const token of launcherArgs) {
128
+ if (token === '-r' || token === '--recursive') {
129
+ scope.isDelegate = true;
130
+ scope.isRecursive = true;
131
+ }
132
+ else if (token === '-F' || token === '--filter' || token.startsWith('--filter=')) {
133
+ scope.isDelegate = true;
134
+ }
135
+ else if (token === '-w' || token === '--workspace-root') {
136
+ scope.isWorkspaceRoot = true;
137
+ }
138
+ }
139
+ return scope;
140
+ }
77
141
  function readNmrTail(segment) {
78
142
  const [head, ...rest] = dropLeadingAssignments(tokenizeSegment(segment));
79
143
  if (head === undefined) {
80
144
  return undefined;
81
145
  }
82
146
  if (head === 'nmr') {
83
- return rest;
147
+ return { launcher: undefined, launcherArgs: [], tail: rest };
84
148
  }
85
149
  const subcommands = LAUNCHERS.get(head);
86
150
  if (subcommands === undefined) {
87
151
  return undefined;
88
152
  }
89
- if (subcommands.size === 0) {
90
- const nameIndex = rest.findIndex((token) => !token.startsWith('-'));
91
- return nameIndex !== -1 && rest[nameIndex] === 'nmr' ? rest.slice(nameIndex + 1) : undefined;
153
+ const nmrIndex = subcommands.size === 0
154
+ ? rest.findIndex((token) => !token.startsWith('-'))
155
+ : rest.findIndex((token) => subcommands.has(token)) + 1;
156
+ if (nmrIndex <= 0 && subcommands.size > 0) {
157
+ return undefined;
92
158
  }
93
- const subcommandIndex = rest.findIndex((token) => subcommands.has(token));
94
- return subcommandIndex !== -1 && rest[subcommandIndex + 1] === 'nmr' ? rest.slice(subcommandIndex + 2) : undefined;
159
+ return rest[nmrIndex] === 'nmr'
160
+ ? { launcher: head, launcherArgs: rest.slice(0, nmrIndex), tail: rest.slice(nmrIndex + 1) }
161
+ : undefined;
95
162
  }
96
163
  function readNmrTarget(tokens) {
97
164
  let isDelegate = false;
@@ -8,6 +8,13 @@ import type { CheckOptions } from 'taze';
8
8
  */
9
9
  export type TazeConfig = {
10
10
  [K in keyof CheckOptions]?: CheckOptions[K] | undefined;
11
+ } & {
12
+ /**
13
+ * Maturity period in days for each package pattern, read by `nmr-taze` rather than by taze. A key is a package
14
+ * name in which `*` matches any run of characters, and the first key that matches a package decides its period.
15
+ * Every other dependency keeps `maturityPeriod`.
16
+ */
17
+ packageMaturityPeriod?: Record<string, number> | undefined;
11
18
  };
12
19
  /**
13
20
  * Builds a taze configuration from nmr's shared upgrade policy and a repo's own settings.
@@ -56,10 +56,12 @@ export interface TestFileScanOptions {
56
56
  excludedBasenames?: readonly string[];
57
57
  }
58
58
  /**
59
- * The isolation ladder, ordered by the furthest thing that a test reaches. A tier names what a test reaches, never
60
- * how it invokes it: A test driving a compiler through its JavaScript API is `tool`, exactly as one spawning `tsc`
61
- * would be. Each named tier's name is also its filename infix, so `parse.tool.test.ts` is collected by the `tool`
62
- * project.
59
+ * The isolation ladder, ordered by the furthest thing that a test reaches: `unit` runs only the package's own code
60
+ * and the libraries that it calls, and `tool` starts a process or makes another program do its work. A tier names
61
+ * what a test reaches, never how it invokes it: A test driving a compiler through its JavaScript API is `tool`,
62
+ * exactly as one spawning `tsc` would be, and so is an embedded test, which runs in-process an engine that the
63
+ * package installs, such as PGlite. Each named tier's name is also its filename infix, so `parse.tool.test.ts` is
64
+ * collected by the `tool` project.
63
65
  */
64
66
  export declare const TIER_NAMES: readonly ["unit", "tool", "localhost", "remote"];
65
67
  /** One of the four isolation tiers. */
@@ -3,8 +3,10 @@ process.env['GIT_CONFIG_GLOBAL'] = os.devNull;
3
3
  process.env['GIT_CONFIG_SYSTEM'] = os.devNull;
4
4
  process.env['GIT_CONFIG_NOSYSTEM'] = '1';
5
5
  process.env['GIT_ATTR_NOSYSTEM'] = '1';
6
- process.env['GIT_CONFIG_COUNT'] = '2';
6
+ process.env['GIT_CONFIG_COUNT'] = '3';
7
7
  process.env['GIT_CONFIG_KEY_0'] = 'core.excludesFile';
8
8
  process.env['GIT_CONFIG_VALUE_0'] = os.devNull;
9
9
  process.env['GIT_CONFIG_KEY_1'] = 'core.attributesFile';
10
10
  process.env['GIT_CONFIG_VALUE_1'] = os.devNull;
11
+ process.env['GIT_CONFIG_KEY_2'] = 'maintenance.auto';
12
+ process.env['GIT_CONFIG_VALUE_2'] = 'false';
package/docs/reporting.md CHANGED
@@ -65,6 +65,8 @@ Because the format is nmr's own reporting, and nmr does not tell the commands th
65
65
 
66
66
  `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.
67
67
 
68
+ `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.
69
+
68
70
  ### Output style
69
71
 
70
72
  `--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.
package/docs/scripts.md CHANGED
@@ -22,7 +22,13 @@ If the package does not declare its own override, the highest-tier value that is
22
22
 
23
23
  A `package.json` entry that re-invokes the command under which it is declared is never honored. Honoring `"build": "nmr build"` would spawn a shell running `nmr build` in the same directory, reaching the same entry again without bound, so nmr discards the entry and resolves the command from the tier below. An entry that contains only the self-reference, trailing arguments included, is discarded in silence: It does not declare any step that nmr would lose. Because discarding an entry that chains anything else onto it (`"build": "nmr build && rdy compile"`, or `"build": "rdy compile && nmr build"`) would lose those steps, nmr rejects the invocation instead, naming the file, the key, and where the steps belong. For an ordinary command that is a `<command>:pre` or `<command>:post` script: `"build:pre": "rdy verify"` and `"build:post": "rdy compile"` wrap nmr's own `build` rather than replacing it. For a hook entry it is the entry itself, since nmr does not wrap a `:pre` or `:post` script in any hooks of its own: `"build:post": "nmr build:post && rdy compile"` becomes `"build:post": "rdy compile"`.
24
24
 
25
- nmr recognizes the re-invocation wherever it appears in the entry, on the grammar that [the shelled-nmr line](#script-values) uses, and a segment containing `-F` or `-R` is not one: It hands the command to other scopes rather than back to this one. Nor is `-w` from inside a package, which targets the root's registry and the root's `package.json`. A filter that selects the declaring package is the hole in that exemption: `nmr -F own-pkg build` in `own-pkg`'s own `build` re-enters without bound, and nmr does not recognize it.
25
+ nmr recognizes the re-invocation wherever it appears in the entry, on the grammar that [the shelled-nmr line](#script-values) uses, and decides from the scope flags whether it returns to the entry. At the root, a segment containing `-F` or `-R` is not one: It hands the command to the packages, and pnpm's recursion leaves the root out. Inside a package, `-w` is not one, since it targets the root's registry and the root's `package.json`, and `-R` is, since it fans out to every package, the declaring one included. pnpm's own `-r`, `--filter`, and `-w`, standing ahead of `exec nmr`, count as nmr's. A filter that selects the declaring package is the hole in the `-F` exemption: `nmr -F own-pkg build` in `own-pkg`'s own `build` re-enters the entry, and nmr does not recognize it.
26
+
27
+ A `.config/nmr.config.ts` entry that names the key under which it is declared, as a shell segment of a string value or as a step-list element, is rejected when the config loads, so every command fails until it is fixed. Discarding it, as tier 3 does, would run the built-in default, which is a command that the repo did not declare. A `rootScripts` entry resolves at the root, so the root's scope rule applies to it. A `workspaceScripts` entry resolves in every package that does not override the command, so `-R` and `-F` re-enter it and only `-w` leaves it: `workspaceScripts: { build: ['-R build'] }` sends each package back to the same entry. The error names the file, the field, and the key, and the edit depends on the key. When nmr's built-in defaults define the key, delete the entry and move the steps that it adds to a `<command>:pre` or `<command>:post` script, which wraps nmr's own command: `workspaceScripts: { build: 'nmr build && rdy compile' }` becomes `workspaceScripts: { 'build:post': 'rdy compile' }`. For a hook, or a command that only the config defines, delete the re-invocation: The steps beside it are the command.
28
+
29
+ Neither tier detects a cycle through two entries, such as `workspaceScripts: { build: ['-w build'] }` against the root's default `build: ['-R build']`, which also re-enters the entry.
30
+
31
+ What recognition misses, the run itself stops. Each nmr process that resolves a command passes that command, with the directory from whose registry it resolved it, to the processes that it starts, in `NMR_ENTRY_CHAIN`. A process that finds its own command and directory already on that chain fails with an error naming both and listing the chain, outermost first, instead of running the entry again. Only the ancestors on the chain count, so a command that runs twice in sequence and a `<command>:pre` or `<command>:post` hook are unaffected, and a `-R` or `-F` fan-out, which resolves nothing itself, neither checks the chain nor extends it.
26
32
 
27
33
  ### Script values
28
34
 
@@ -71,9 +77,11 @@ A string value is spawned through a shell as one command. An `nmr` invocation in
71
77
 
72
78
  The line names the declaration to which it is an edit (the file, the field, and the key), and its remedy follows from that site. In a `.config/nmr.config.ts` entry, keep the nmr steps as a step list and move anything else to a `<command>:pre` or `<command>:post` script: A step-list element can name only an nmr command, because nmr composes it into `nmr <element>`. A `package.json` entry has to go, since `package.json` cannot declare a step list: Delete it outright when it restates what nmr already runs for that command, and move the steps that it adds to a `<command>:pre` or `<command>:post` script when they are the package's own. When the registry does not define that command, both apply: Name the command in `.config/nmr.config.ts`, and put the package-specific steps in a hook. The pair is named rather than `:post` alone because an entry's extra steps may run on either side of the chain: `rdy verify && nmr compile` puts them first. A hook is the exception at both tiers: nmr does not wrap a `:pre` or `:post` script in any hooks of its own. Move a hook's other steps to a script of their own, and name that script in the step list.
73
79
 
80
+ One kind of `package.json` entry is resolved in its own file: an entry whose whole value is `nmr <name>`, without flags, arguments, assignments, or a launcher, where `<name>` is a script in the same `package.json` that runs a command other than nmr and does not have a `:pre` or `:post` hook. Replace the entry's value with that command, and delete the script: `"build:pre": "nmr compile:ports"` beside `"compile:ports": "node scripts/compile-ports.ts"` becomes `"build:pre": "node scripts/compile-ports.ts"`. Keep the script when the registry also defines `<name>`, because the script then overrides nmr's own command. The script must come from the same file: Inlining a command that resolves from the registry would copy nmr's own command text into the package, where nmr's later changes do not reach it.
81
+
74
82
  nmr recognizes `nmr` in command position: at the start of a step, after an unquoted `&&`, `||`, `;`, `|`, or a newline, past any leading `NAME=value` assignments, and behind `npx`, `bunx`, or an `exec`, `dlx`, or `x` subcommand of `npm`, `pnpm`, `yarn`, or `bun`. This line reports a boundary rather than enforcing a rule: It covers a step that nmr runs as written, and only the reporting of the nested run is degraded. The line is also partial in three ways: a value-taking flag standing immediately before the program name hides it (`npx -p foo nmr`), a launcher outside that set goes unreported, and a `devBin` substitution that introduces `nmr` itself is not reported, the line being an edit to a declaration rather than to a substitution.
75
83
 
76
- A [self-referential `package.json` entry](#three-tier-override-system) is the shape that nmr cannot run as written, and it is rejected rather than reported. The same recognition finds it, so the same residuals apply: What goes unrecognized there re-enters nmr without bound, which hangs rather than passing quietly.
84
+ A [self-referential entry](#three-tier-override-system), in `package.json` or in `.config/nmr.config.ts`, is the shape that nmr cannot run as written, and it is rejected rather than reported. The same recognition finds it, so the same residuals apply: What goes unrecognized there re-enters nmr once, and the run then fails with an error naming the command and the directory that it re-entered.
77
85
 
78
86
  ## Pre and post hooks
79
87
 
package/docs/upgrades.md CHANGED
@@ -51,6 +51,34 @@ Two consequences of the `minor` range mode declared by the policy:
51
51
  - 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 a developer runs `nmr upgrade patch`, and a `minor` ceiling hides it from `nmr upgrade major`.
52
52
  - 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'`.
53
53
 
54
+ ### Per-package maturity periods
55
+
56
+ `packageMaturityPeriod` gives the packages that match a pattern a quarantine of their own, in days. Every other dependency keeps `maturityPeriod`. taze applies one maturity period to a whole run, so `nmr upgrade` runs it once for each entry and once more for every other dependency, each run headed on stderr by the patterns and the period that it covers. The command exits with the first non-zero exit code among the runs.
57
+
58
+ ```ts
59
+ import { defineConfig } from '@williamthorsen/nmr/taze';
60
+
61
+ export default defineConfig({
62
+ // Propose first-party releases after one day, and third-party ones after the policy's seven.
63
+ packageMaturityPeriod: {
64
+ '@williamthorsen/*': 1,
65
+ readyup: 1,
66
+ },
67
+ });
68
+ ```
69
+
70
+ A key is a package name in which `*` matches any run of characters; a regex key, a key containing a comma, or a version selector such as `typescript@7` fails the command before taze runs. When several keys match a package, the first one decides its period. A period of `0` removes the quarantine for those packages.
71
+
72
+ A package listed in `maturityPeriodExclude`, or in the `minimumReleaseAgeExclude` of `pnpm-workspace.yaml`, which taze inherits into it, skips the quarantine in every run. Remove it from that list before giving it a period here.
73
+
74
+ `--maturity-period <days>` on the command line applies one period to every dependency for that call, in a single run:
75
+
76
+ ```bash
77
+ nmr upgrade --maturity-period 0
78
+ ```
79
+
80
+ Every other argument reaches every run, and `--include` and `--exclude` narrow each run as they narrow a single one. Because each run reports separately, `--json` prints one document per run, `--interactive` opens one selection per run, and `--install` installs after each run.
81
+
54
82
  ## Request timeout
55
83
 
56
84
  Every `upgrade` script calls the upgrade tool with a 30-second request timeout, in place of the tool's own 5 seconds.
@@ -101,7 +129,7 @@ nmr report-overrides
101
129
 
102
130
  ## `nmr-taze`
103
131
 
104
- 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 includes one. Every `upgrade` chain ends with it, `root:upgrade` included; see [dependency upgrades](#dependency-upgrades) for the workflow, and [request timeout](#request-timeout) for why the timeout is set here.
132
+ Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it and adding a 30-second request timeout, unless the invocation already includes one. When the taze config declares [per-package maturity periods](#per-package-maturity-periods), it runs taze once per period, adding to each run the filters and the period that confine it. Every `upgrade` chain ends with it, `root:upgrade` included; see [dependency upgrades](#dependency-upgrades) for the workflow, and [request timeout](#request-timeout) for why the timeout is set here.
105
133
 
106
134
  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 that nmr controls.
107
135
 
package/docs/utilities.md CHANGED
@@ -47,9 +47,9 @@ nmr-fmt --write packages/nmr
47
47
 
48
48
  `fmt` and `fmt:check` format the files that git reports: tracked files, plus untracked files that git does not ignore. Prettier reads `.gitignore` and `.prettierignore` from the working directory alone, so a pattern in `packages/<pkg>/.gitignore` never applies to a run started at the monorepo root. Because git knows the whole hierarchy, including `.git/info/exclude` and `core.excludesFile`, and every `.prettierignore` in the tree is discovered from the repository root, package-level exclusions apply from any directory as well.
49
49
 
50
- A file that git ignores is never formatted, even when named directly. Because trailing arguments are git pathspecs rather than Prettier flags, `nmr fmt:check packages/nmr` narrows the run, while an unrecognized option and a pathspec that does not match any formattable file both fail it. Running outside a git repository fails rather than reporting a clean run.
50
+ A file that git ignores is never formatted, even when named directly. Because trailing arguments are git pathspecs rather than Prettier flags, `nmr fmt:check packages/nmr` narrows the run, while an unrecognized option and a pathspec for which git lists no file both fail it. Running outside a git repository fails rather than reporting a clean run.
51
51
 
52
- `nmr-fmt` drops paths that the index names but the filesystem does not have (a file deleted but not yet staged), submodule gitlinks, and symlinks whatever they point at, before calling Prettier, which rejects a symlink named explicitly. It also skips a path that it is denied access to, such as a `.envrc` that a sandbox shields, and names each one on stderr, so that a check that passes does not hide a file that it never read. Any other failure to stat a listed path fails the run, with an error that names the path.
52
+ `nmr-fmt` drops paths that the index names but the filesystem does not have (a file deleted but not yet staged), submodule gitlinks, and symlinks whatever they point at, before calling Prettier, which rejects a symlink named explicitly. It also skips a path that it is denied access to, such as a `.envrc` that a sandbox shields, and names each one on stderr, so that a check that passes does not hide a file that it never read. A pathspec whose every match is skipped still counts as matched: The run passes and names the skipped paths on stderr, so that a pre-commit hook passing only staged symlinks or a submodule bump does not fail. Any other failure to stat a listed path fails the run, with an error that names the path.
53
53
 
54
54
  ### Cache
55
55
 
package/docs/vitest.md CHANGED
@@ -10,18 +10,24 @@ The [README](../README.md#shared-vitest-config) shows the one-line config file.
10
10
 
11
11
  The config declares four projects, an ordered ladder named for the furthest thing that a test reaches:
12
12
 
13
- | Project | Matches | Reaches |
14
- | ----------- | ------------------------------------------- | ---------------------------------------------------- |
15
- | `unit` | every test file that the others don't claim | nothing outside the package's own dependency closure |
16
- | `tool` | `*.tool.test.{ts,tsx}` | a program that the environment must supply |
17
- | `localhost` | `*.localhost.test.{ts,tsx}` | a service running on this machine |
18
- | `remote` | `*.remote.test.{ts,tsx}` | a machine that isn't this one |
13
+ | Project | Matches | Reaches |
14
+ | ----------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
15
+ | `unit` | every test file that the others don't claim | only the package's own code and the libraries that it calls |
16
+ | `tool` | `*.tool.test.{ts,tsx}` | a process that the test starts, or a program other than the package's own code that the test makes do its work |
17
+ | `localhost` | `*.localhost.test.{ts,tsx}` | a service already running on this machine |
18
+ | `remote` | `*.remote.test.{ts,tsx}` | a machine that isn't this one |
19
19
 
20
20
  All four match only under a `__tests__` directory.
21
21
 
22
- **A tier names what a test reaches, never how it invokes it.** A test driving a compiler through its JavaScript API is `tool`, exactly as one spawning the compiler binary is; whether a program ships as a library or an executable is an accident of packaging, not a property of the test. A package's `peerDependencies`, plus whatever binaries it spawns, are a good statement of where its `tool` boundary is: A bundled dependency is inside the package's own closure and is always present, so reaching one is `unit`.
22
+ **A tier names the furthest program that a test makes do its work.** A test is `unit` while everything that it runs is the package's own code or a library that the code calls for a result. It is `tool` when it starts a process, or when it makes a program other than the package's own code do its work, whether the environment supplies that program or the package installs it. The machine sets how long such a program takes to start and to run, which is why a spawned process counts even when it runs the package's own code.
23
23
 
24
- **A tier is not a statement about preconditions.** A `unit` test may still need something set up before it runs: a build, a generated fixture, a seeded file. It is `unit` because it reaches nothing beyond the test process _while running_. The filesystem belongs to `unit` for the same reason the tiers exist: They gate on what must be available, and the filesystem always is.
24
+ **A tier names what a test reaches, never how it invokes it.** A test driving a compiler through its JavaScript API is `tool`, exactly as one spawning the compiler binary is; whether a program ships as a library or an executable is an accident of packaging, not a property of the test. The same holds for an **embedded** test, which runs, inside the test process, an engine that the package installs as a dependency and that is not the package's own code. An engine does a job of its own, such as a database, a compiler, or a bundler; a library is a set of functions that the code calls for a result. Embedded tests are `tool`: A test that starts PGlite (Postgres compiled to WebAssembly) is `tool`, and a test that calls a parsing library is `unit`.
25
+
26
+ **A service that the test starts itself takes the tier of the code that runs it.** A test that calls `net.Server.listen(0)` on a server implemented by the package's own code and reaches it with `fetch` is `unit`: Both ends of the loopback connection are inside the test process, and the connection is how the test invokes that code. The same server started as a child process is `tool`, as is a service whose program the environment supplies, such as a `postgres` binary, or one that the package installs, such as PGlite. `localhost` is for a service that is already running when the test starts, such as a separately started backend or a seeded database.
27
+
28
+ **`peerDependencies` name candidate tools; they do not classify a test.** A package's peers, with the binaries that it spawns and the engines that it installs, are the programs that its tests might make do their work, and a test is `tool` only when it does. A test that imports `AST_NODE_TYPES` from `@typescript-eslint/utils` for its constants, or calls a helper that ships beside a tool and does none of the tool's work, stays `unit`; a test that runs ESLint over a fixture is `tool`. That the peer must be installed for the import to succeed is a precondition, not a tier argument, as the next paragraph states.
29
+
30
+ **A tier is not a statement about preconditions.** A `unit` test may still need something set up before it runs: a build, a generated fixture, a seeded file, an installed peer. It is `unit` because it does not make any program beyond its own code do work _while running_. The filesystem belongs to `unit` for the same reason: Reaching it does not run another program, and it is always available.
25
31
 
26
32
  **Every test file names its tier**, in the form `<subject>[.<aspect>].<tier>.test.ts`. Only the segment immediately before `.test.` selects a project, so an earlier one is free for documentation: `resolveConfig.packaged.unit.test.ts` reads as a companion to `resolveConfig.unit.test.ts` and still names `unit`.
27
33
 
@@ -103,7 +109,7 @@ In a repo that has not declared the check, [nmr's readyup kit](../README.md#conf
103
109
  Two settings that a workspace test runner needs almost universally are part of what the factory produces, so a config file declares neither:
104
110
 
105
111
  - **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.
106
- - **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` and `core.attributesFile` 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. 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.
112
+ - **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.
107
113
 
108
114
  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:
109
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -77,13 +77,14 @@
77
77
  "!docs/*.v*.md"
78
78
  ],
79
79
  "dependencies": {
80
- "@williamthorsen/nmr-core": "0.13.2",
80
+ "@williamthorsen/nmr-core": "0.13.3",
81
81
  "@williamthorsen/toolbelt.errors": "0.8.3",
82
82
  "@williamthorsen/toolbelt.filesystem": "0.9.3",
83
83
  "glob": "13.0.6",
84
84
  "prettier": "3.9.9",
85
85
  "prettier-plugin-sh": "0.20.2",
86
- "taze": "21.3.0"
86
+ "taze": "21.3.0",
87
+ "unconfig": "7.5.0"
87
88
  },
88
89
  "devDependencies": {
89
90
  "@williamthorsen/toolbelt.testing": "0.6.3",
@@ -92,7 +93,7 @@
92
93
  "readyup": "0.40.0",
93
94
  "tinyglobby": "0.2.17",
94
95
  "typescript": "6.0.3",
95
- "vite": "8.3.2",
96
+ "vite": "8.3.3",
96
97
  "vitest": "4.1.11",
97
98
  "yaml": "2.9.1"
98
99
  },