@williamthorsen/nmr 0.43.0 → 0.45.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 (47) hide show
  1. package/.meta/changelog.json +168 -0
  2. package/.readyup/kits/default.js +1331 -136
  3. package/.readyup/manifest.json +148 -9
  4. package/CHANGELOG.md +51 -0
  5. package/README.md +17 -3
  6. package/agents/guidance/rulebooks/nmr.md +1 -1
  7. package/dist/esm/commands/fmt-cache.d.ts +26 -0
  8. package/dist/esm/commands/fmt-cache.js +71 -0
  9. package/dist/esm/commands/fmt.d.ts +10 -3
  10. package/dist/esm/commands/fmt.js +34 -6
  11. package/dist/esm/config.js +74 -9
  12. package/dist/esm/default-scripts.d.ts +10 -1
  13. package/dist/esm/default-scripts.js +45 -39
  14. package/dist/esm/defineConfig.d.ts +1 -1
  15. package/dist/esm/entry-chain.d.ts +24 -0
  16. package/dist/esm/entry-chain.js +57 -0
  17. package/dist/esm/glyphs.d.ts +1 -1
  18. package/dist/esm/help.d.ts +1 -1
  19. package/dist/esm/help.js +3 -3
  20. package/dist/esm/helpers/createOutputRecorder.d.ts +17 -0
  21. package/dist/esm/helpers/createOutputRecorder.js +22 -0
  22. package/dist/esm/helpers/escapeControlCharacters.d.ts +10 -0
  23. package/dist/esm/helpers/escapeControlCharacters.js +12 -0
  24. package/dist/esm/resolve-scripts.d.ts +5 -3
  25. package/dist/esm/resolve-scripts.js +7 -3
  26. package/dist/esm/resolver.d.ts +5 -4
  27. package/dist/esm/resolver.js +16 -6
  28. package/dist/esm/runCli.js +146 -101
  29. package/dist/esm/runner.d.ts +7 -0
  30. package/dist/esm/runner.js +105 -15
  31. package/dist/esm/steps.d.ts +50 -18
  32. package/dist/esm/steps.js +82 -13
  33. package/dist/esm/taze.d.ts +5 -2
  34. package/dist/esm/taze.js +4 -1
  35. package/dist/esm/tiers.d.ts +5 -4
  36. package/dist/esm/types.d.ts +10 -0
  37. package/dist/esm/vitest-git-isolation.js +3 -1
  38. package/dist/esm/vitest.js +9 -5
  39. package/dist/esm/workspace-globs.d.ts +5 -0
  40. package/dist/esm/workspace-globs.js +8 -0
  41. package/docs/check-cache.md +2 -0
  42. package/docs/reporting.md +2 -0
  43. package/docs/scripts.md +36 -10
  44. package/docs/upgrades.md +1 -1
  45. package/docs/utilities.md +14 -2
  46. package/docs/vitest.md +20 -8
  47. package/package.json +7 -7
@@ -8,8 +8,9 @@
8
8
  * `shouldDeclineArguments` is passed from the composite element that composed the step to the one reader that acts
9
9
  * on it, the binding of the invocation's trailing arguments. Every stage between the two passes it through unread.
10
10
  *
11
- * `shouldWithholdInput` is read by the runner alone, which gives the step's child the null device as stdin rather
12
- * than nmr's own. Like `shouldDeclineArguments`, it is set only when it holds and leaves the rendered chain unchanged.
11
+ * `shouldRunInBackground` and `shouldWithholdInput` are read by the runner alone. The first starts the step without
12
+ * waiting for it; the second gives the step's child the null device as stdin rather than nmr's own. Like
13
+ * `shouldDeclineArguments`, each is set only when it holds and leaves the rendered chain unchanged.
13
14
  */
14
15
  export type Step = {
15
16
  kind: 'opaque';
@@ -18,8 +19,14 @@ export type Step = {
18
19
  kind: 'structural';
19
20
  argv: readonly [string, ...(readonly string[])];
20
21
  shouldDeclineArguments?: boolean;
22
+ shouldRunInBackground?: boolean;
21
23
  shouldWithholdInput?: boolean;
22
24
  };
25
+ /** The declarations that a composite element adds to the step that it composes. */
26
+ export interface StepFlags {
27
+ shouldDeclineArguments?: boolean | undefined;
28
+ shouldRunInBackground?: boolean | undefined;
29
+ }
23
30
  /** What a structural step asks of the nmr process that it spawns. */
24
31
  export interface NmrStepTarget {
25
32
  command: string;
@@ -34,20 +41,28 @@ export interface NmrStepTarget {
34
41
  isWorkspaceRoot: boolean;
35
42
  }
36
43
  /**
37
- * How a `package.json` entry names the command under which it is declared: as its whole value, or alongside
38
- * 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.
39
46
  */
40
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';
41
56
  /**
42
57
  * Composes the structural step that re-invokes nmr for one composite element.
43
58
  *
44
59
  * The element tokenizes on whitespace, so it may contain nmr's own flags but cannot contain a space-bearing
45
60
  * token. `-w` is prepended as its own token: The child selects the root registry on its own.
46
61
  *
47
- * Because `shouldDeclineArguments` is set only when it holds, a step that takes the trailing arguments compares
48
- * equal to one composed without the option.
62
+ * Because each flag is set only when it holds, a step composed with a flag at `false` compares equal to one
63
+ * composed without it.
49
64
  */
50
- export declare function composeNmrStep(element: string, isWorkspaceRoot: boolean, shouldDeclineArguments?: boolean): Step;
65
+ export declare function composeNmrStep(element: string, isWorkspaceRoot: boolean, flags?: StepFlags): Step;
51
66
  /**
52
67
  * Returns the steps that still run in a workspace without any package: every step but the ones that a `-R` fans
53
68
  * out.
@@ -87,24 +102,41 @@ export declare function quoteToken(token: string): string;
87
102
  */
88
103
  export declare function readNmrStep(step: Step): NmrStepTarget | undefined;
89
104
  /**
90
- * Reports whether a `package.json` entry re-invokes the command under which it is declared, and whether it
91
- * declares anything besides. Reports nothing for an entry that names another command, or none.
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.
92
107
  *
93
- * Honouring such an entry would spawn a shell running the same command in the same directory, which reads the
94
- * same entry again without bound, so resolution discards it however it reads. `chained` separates an entry that
95
- * thereby loses steps from one that declares nothing to lose.
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.
96
111
  *
97
- * A segment that delegates sends the command to other scopes rather than back to this one. `-w` re-enters
98
- * only from the root, a package's `-w` entry resolving against the root's registry and `package.json` instead of its own.
99
- *
100
- * Partial in the same ways `findNmrCrossing` is, and for the same reason: What goes unrecognized re-enters
101
- * 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.
102
114
  */
103
115
  export declare function readSelfReference(options: {
104
- anchoredAtRoot: boolean;
105
116
  commandName: string;
117
+ scope: SelfReferenceScope;
106
118
  script: string;
107
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;
108
140
  /**
109
141
  * Renders a step list as the `&&` chain that a shell runs.
110
142
  *
package/dist/esm/steps.js CHANGED
@@ -11,12 +11,14 @@ const QUOTES = new Set(['"', "'"]);
11
11
  const SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
12
12
  const SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
13
13
  const TOKEN_SEPARATORS = new Set([' ', '\t', '\n', '\r']);
14
- export function composeNmrStep(element, isWorkspaceRoot, shouldDeclineArguments = false) {
15
- const flags = isWorkspaceRoot ? ['-w'] : [];
14
+ export function composeNmrStep(element, isWorkspaceRoot, flags = {}) {
15
+ const { shouldDeclineArguments = false, shouldRunInBackground = false } = flags;
16
+ const nmrFlags = isWorkspaceRoot ? ['-w'] : [];
16
17
  return {
17
18
  kind: 'structural',
18
- argv: ['nmr', ...flags, ...tokenize(element)],
19
+ argv: ['nmr', ...nmrFlags, ...tokenize(element)],
19
20
  ...(shouldDeclineArguments && { shouldDeclineArguments }),
21
+ ...(shouldRunInBackground && { shouldRunInBackground }),
20
22
  };
21
23
  }
22
24
  export function dropRecursiveSteps(steps) {
@@ -47,18 +49,50 @@ export function readNmrStep(step) {
47
49
  return file === 'nmr' ? readNmrTarget(rest) : undefined;
48
50
  }
49
51
  export function readSelfReference(options) {
50
- const { anchoredAtRoot, commandName, script } = options;
52
+ const { commandName, scope, script } = options;
51
53
  const segments = splitSegments(script).filter((segment) => segment.trim() !== '');
52
54
  const doesReenter = segments.some((segment) => {
53
- const tail = readNmrTail(segment);
54
- const target = tail === undefined ? undefined : readNmrTarget(tail);
55
- 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);
56
68
  });
57
69
  if (!doesReenter) {
58
70
  return undefined;
59
71
  }
60
72
  return segments.length === 1 ? 'sole' : 'chained';
61
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
+ }
62
96
  export function renderChain(steps) {
63
97
  return steps.map(renderStep).join(' && ');
64
98
  }
@@ -72,24 +106,59 @@ function isRedirectionOperator(char, precedingText, nextChar) {
72
106
  }
73
107
  return (char === '&' && nextChar === '>') || /[<>]\s*$/.test(precedingText);
74
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
+ }
75
141
  function readNmrTail(segment) {
76
142
  const [head, ...rest] = dropLeadingAssignments(tokenizeSegment(segment));
77
143
  if (head === undefined) {
78
144
  return undefined;
79
145
  }
80
146
  if (head === 'nmr') {
81
- return rest;
147
+ return { launcher: undefined, launcherArgs: [], tail: rest };
82
148
  }
83
149
  const subcommands = LAUNCHERS.get(head);
84
150
  if (subcommands === undefined) {
85
151
  return undefined;
86
152
  }
87
- if (subcommands.size === 0) {
88
- const nameIndex = rest.findIndex((token) => !token.startsWith('-'));
89
- 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;
90
158
  }
91
- const subcommandIndex = rest.findIndex((token) => subcommands.has(token));
92
- 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;
93
162
  }
94
163
  function readNmrTarget(tokens) {
95
164
  let isDelegate = false;
@@ -12,8 +12,11 @@ export type TazeConfig = {
12
12
  /**
13
13
  * Builds a taze configuration from nmr's shared upgrade policy and a repo's own settings.
14
14
  *
15
- * Any property that the caller supplies wins, including `0` and an explicit `undefined`. The latter restores
16
- * taze's inheritance of pnpm's `minimumReleaseAge`, which only applies while `maturityPeriod` is unset.
15
+ * Any property that the caller supplies wins, including `0` and an explicit `undefined`, which clears nmr's
16
+ * default and hands the setting back to taze. Clearing `maturityPeriod` restores taze's inheritance of pnpm's
17
+ * `minimumReleaseAge`, which only applies while it is unset. Clearing `mode` yields taze's own `'default'`, which
18
+ * follows each declared range; passed through as `undefined`, it would overwrite taze's default and leave no
19
+ * valid range to search.
17
20
  *
18
21
  * The merge is shallow, which suits a policy of scalar defaults. A nested default would clobber rather
19
22
  * than merge, so adding one means revisiting this.
package/dist/esm/taze.js CHANGED
@@ -4,5 +4,8 @@ const SHARED_POLICY = {
4
4
  mode: 'minor',
5
5
  };
6
6
  export function defineConfig(config) {
7
- return { ...SHARED_POLICY, ...config };
7
+ const merged = { ...SHARED_POLICY, ...config };
8
+ if ('mode' in config && config.mode === undefined)
9
+ merged.mode = 'default';
10
+ return merged;
8
11
  }
@@ -56,10 +56,11 @@ 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, which decides what the environment must
60
+ * supply for the test to run. A tier names what a test reaches, never how it invokes it: A test driving a compiler
61
+ * through its JavaScript API is `tool`, exactly as one spawning `tsc` would be, and a spawned process is itself
62
+ * something reached. Each named tier's name is also its filename infix, so `parse.tool.test.ts` is collected by the
63
+ * `tool` project.
63
64
  */
64
65
  export declare const TIER_NAMES: readonly ["unit", "tool", "localhost", "remote"];
65
66
  /** One of the four isolation tiers. */
@@ -28,6 +28,15 @@ export interface CheckCacheConfig {
28
28
  */
29
29
  extraCommands?: string[];
30
30
  }
31
+ /** Self-hosted runner settings honored at the monorepo root, read by nmr's readyup kit. */
32
+ export interface SelfHostedRunnerConfig {
33
+ /**
34
+ * Why the repo keeps its CI off the self-hosted runners although it qualifies, such as a circular dependency on
35
+ * the runners themselves. Declaring it stops the kit from recommending `CI_RUNS_ON`. A missing tool is not a
36
+ * reason: A workflow step installs it.
37
+ */
38
+ exempt: string;
39
+ }
31
40
  /** Output settings honored at the monorepo root. */
32
41
  export interface OutputConfig {
33
42
  /**
@@ -49,4 +58,5 @@ export interface NmrConfig {
49
58
  output?: OutputConfig;
50
59
  workspaceScripts?: Record<string, ScriptValue>;
51
60
  rootScripts?: Record<string, ScriptValue>;
61
+ selfHostedRunner?: SelfHostedRunnerConfig;
52
62
  }
@@ -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';
@@ -1,3 +1,4 @@
1
+ import os from 'node:os';
1
2
  import path from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { defaultExclude, mergeConfig } from 'vitest/config';
@@ -6,9 +7,12 @@ import { isObject } from "./helpers/type-guards.js";
6
7
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
7
8
  import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
8
9
  import { getWorkspacePackageDirs } from "./workspace.js";
10
+ import { toWorkspacePackageGlobs } from "./workspace-globs.js";
9
11
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
10
12
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
11
13
  const TIER_TIMEOUT_MS = 30_000;
14
+ const SMALL_MACHINE_MAX_CORES = 2;
15
+ const SMALL_MACHINE_MAX_WORKERS = 3;
12
16
  const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
13
17
  const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
14
18
  const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
@@ -49,7 +53,7 @@ export function defineRootVitestConfig(...layers) {
49
53
  return buildConfig(layers, {
50
54
  coverageInclude: [],
51
55
  ignoredPathsRoot: monorepoRoot,
52
- projectExclude: getWorkspaceExcludePatterns(monorepoRoot),
56
+ projectExclude: toWorkspacePackageGlobs(monorepoRoot, getWorkspacePackageDirs(monorepoRoot)),
53
57
  projectRoot: monorepoRoot,
54
58
  });
55
59
  }
@@ -95,6 +99,7 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
95
99
  })),
96
100
  ];
97
101
  const collectionExclude = buildCollectionExclude(layers, ignoredPathsRoot);
102
+ const maxWorkers = resolveDefaultMaxWorkers();
98
103
  const shouldIsolateGit = resolveFlag(layers, 'shouldIsolateGit', true);
99
104
  return projectTiers.map(({ exclude, include, name, timeoutMs }) => {
100
105
  const project = {
@@ -103,6 +108,7 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
103
108
  test: {
104
109
  exclude: [...collectionExclude, ...exclude, ...extraExclude],
105
110
  include,
111
+ ...(maxWorkers !== undefined && { maxWorkers }),
106
112
  name,
107
113
  ...(shouldIsolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
108
114
  ...(timeoutMs !== undefined && { hookTimeout: timeoutMs, testTimeout: timeoutMs }),
@@ -169,10 +175,8 @@ function formatOptionKeys(keys) {
169
175
  .map((key) => `\`${key}\``)
170
176
  .join(', ');
171
177
  }
172
- function getWorkspaceExcludePatterns(monorepoRoot) {
173
- return getWorkspacePackageDirs(monorepoRoot)
174
- .map((dir) => `${path.relative(monorepoRoot, dir).split(path.sep).join('/')}/**`)
175
- .toSorted();
178
+ function resolveDefaultMaxWorkers() {
179
+ return os.availableParallelism() <= SMALL_MACHINE_MAX_CORES ? SMALL_MACHINE_MAX_WORKERS : undefined;
176
180
  }
177
181
  function resolveFlag(layers, name, isOnByDefault) {
178
182
  let resolvedFlag = isOnByDefault;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Renders each package directory as a glob, relative to `monorepoRoot`, that matches everything beneath it, sorted. A
3
+ * directory that is the root itself is skipped, since its glob would match the whole repo.
4
+ */
5
+ export declare function toWorkspacePackageGlobs(monorepoRoot: string, packageDirs: readonly string[]): string[];
@@ -0,0 +1,8 @@
1
+ import path from 'node:path';
2
+ export function toWorkspacePackageGlobs(monorepoRoot, packageDirs) {
3
+ return packageDirs
4
+ .map((dir) => path.relative(monorepoRoot, dir).split(path.sep).join('/'))
5
+ .filter((relativeDir) => relativeDir !== '')
6
+ .map((relativeDir) => `${relativeDir}/**`)
7
+ .toSorted();
8
+ }
@@ -148,6 +148,8 @@ Try these in order; the first is almost always the right one.
148
148
  | `rm -rf node_modules/.cache/nmr-check` | Forgets every recorded pass, leaving build output alone. |
149
149
  | `nmr clean` | Forgets every recorded pass and removes build output, at any scope. |
150
150
 
151
+ `--no-cache` and `NMR_NO_CACHE=1` also bypass the Prettier cache that `nmr-fmt` keeps, because nmr exports `NMR_NO_CACHE=1` to the commands that it runs. nmr exports it too when a command is given trailing arguments, so `nmr fmt:check <pathspec>` runs uncached. See [`nmr-fmt`'s cache](utilities.md#cache).
152
+
151
153
  `--no-cache` belongs before the command name. After it, it is an argument to the command rather than a flag to nmr; nmr reports this rather than silently ignoring the bypass.
152
154
 
153
155
  ## Reserved environment variables
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
 
@@ -35,6 +41,21 @@ A script value is a string or an array of steps. Only the built-in defaults and
35
41
 
36
42
  Each array element is a command name, optionally preceded by nmr's own flags, split on whitespace: `-R test` runs the element in every package, the way `nmr -R test` does. An element containing a quoted argument or shell syntax is rejected when the config loads: Name that command as a script of its own and reference the name.
37
43
 
44
+ #### Running a step in the background
45
+
46
+ A composite runs its steps in series. An element declared with `shouldRunInBackground` starts and is left running while the steps after it run:
47
+
48
+ ```ts
49
+ // "check:strict": [{ run: "lint:strict", shouldRunInBackground: true }, "typecheck", "fmt:check", "test"]
50
+ // nmr lint:strict runs alongside nmr typecheck, then nmr fmt:check, then nmr test
51
+ ```
52
+
53
+ The background step's output is held rather than streamed, and printed whole at the first step boundary after the step ends, so it never interleaves with another step's output. When nmr writes to a terminal, the step receives `FORCE_COLOR=1`, unless `NO_COLOR` or `FORCE_COLOR` is already set, so that its tools keep their colour. Its stdin is the null device.
54
+
55
+ A failing step in the foreground still ends the sequence, but the composite waits for every background step that has started and prints its output. The composite fails if any step failed, and each step's own verdict line names its failure. While a background step runs, nmr forwards a SIGINT or SIGTERM that it receives to every running step.
56
+
57
+ Put a step in the background when it uses fewer cores than the machine has and the steps after it do not read what it writes. The default `check` and `check:strict` run their lint step this way, because type-aware lint runs on one core while the test fan-out leaves cores idle.
58
+
38
59
  #### Where trailing arguments go
39
60
 
40
61
  nmr passes trailing arguments to every step of a composite, so `nmr fix --dry-run` runs `nmr lint --dry-run`, then `nmr fmt --dry-run`. A step whose work the arguments cannot narrow declines them by taking the long form of an element, and runs unnarrowed:
@@ -56,9 +77,11 @@ A string value is spawned through a shell as one command. An `nmr` invocation in
56
77
 
57
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.
58
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
+
59
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.
60
83
 
61
- 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.
62
85
 
63
86
  ## Pre and post hooks
64
87
 
@@ -91,14 +114,15 @@ A minimal `.config/nmr.config.ts` is in the [README](../README.md#configuration)
91
114
 
92
115
  ### `defineConfig` fields
93
116
 
94
- | Field | Type | Description |
95
- | ------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
96
- | `build` | `{ extraIgnorePatterns?: string[] }` | Patterns added to the build's ignore set (package config only) |
97
- | `checkCache` | `{ enabled?: boolean; extraCommands?: string[]; excludeCommands?: string[] }` | Which commands the [check-result cache](check-cache.md#check-result-cache) may skip |
98
- | `workspaceScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the workspace registry (tier 2) |
99
- | `rootScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the root registry (tier 2) |
100
- | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
101
- | `output` | `{ commandVerbosity?: CommandVerbosity; extraAgentEnvVars?: string[] }` | How loudly runs report command output; see [configuring](reporting.md#configuring) |
117
+ | Field | Type | Description |
118
+ | ------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
119
+ | `build` | `{ extraIgnorePatterns?: string[] }` | Patterns added to the build's ignore set (package config only) |
120
+ | `checkCache` | `{ enabled?: boolean; extraCommands?: string[]; excludeCommands?: string[] }` | Which commands the [check-result cache](check-cache.md#check-result-cache) may skip |
121
+ | `workspaceScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the workspace registry (tier 2) |
122
+ | `rootScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the root registry (tier 2) |
123
+ | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
124
+ | `output` | `{ commandVerbosity?: CommandVerbosity; extraAgentEnvVars?: string[] }` | How loudly runs report command output; see [configuring](reporting.md#configuring) |
125
+ | `selfHostedRunner` | `{ exempt: string }` | Why a qualifying repo keeps CI off the self-hosted runners; stops [nmr's readyup kit](../README.md#conformance-checks) from recommending `CI_RUNS_ON` (root config only). The reason is structural: a circular dependency, a platform or capacity the runners lack, or network reach. A missing tool is installed by a workflow step instead |
102
126
 
103
127
  All fields are optional. [Script values](#script-values) describes `ScriptValue` and its step form, and both types are exported from `@williamthorsen/nmr/config`.
104
128
 
@@ -153,6 +177,8 @@ Neither is bound to a git hook. `prepush` is named for when a developer runs it,
153
177
 
154
178
  ### Check and quality
155
179
 
180
+ `check` and `check:strict` start their lint step first and [run it in the background](#running-a-step-in-the-background), alongside typecheck, the format check, and the tests.
181
+
156
182
  Neither gate computes coverage: Instrumentation multiplies the wall time of the run that they exist to keep short. Coverage is [`test:coverage`](vitest.md#test-selections), run as a step of its own; a repo that wants it inside the gate [overrides](#configuration) `check:strict`.
157
183
 
158
184
  ### Lint
package/docs/upgrades.md CHANGED
@@ -40,7 +40,7 @@ export default defineConfig({
40
40
  });
41
41
  ```
42
42
 
43
- `defineConfig` supplies nmr's shared upgrade policy, so a repo's config contains only what is specific to that repo. The policy is 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`. Any setting that a repo's config declares wins over nmr's default. Passing `undefined` clears a default rather than falling back to it; that is how a config hands the quarantine policy back to `pnpm-workspace.yaml`'s `minimumReleaseAge`. `mode` is the exception: The upgrade tool has a default of its own for it, and clearing nmr's leaves the tool without a valid range to search. Name a mode instead.
43
+ `defineConfig` supplies nmr's shared upgrade policy, so a repo's config contains only what is specific to that repo. The policy is 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`. Any setting that a repo's config declares wins over nmr's default. Passing `undefined` clears a default rather than falling back to it; that is how a config hands the quarantine policy back to `pnpm-workspace.yaml`'s `minimumReleaseAge`. Clearing `mode` hands the range back to the upgrade tool's own default, which follows the range that each dependency declares.
44
44
 
45
45
  Everything the [taze configuration](https://github.com/antfu-collective/taze#config-file) accepts is accepted here, with five exceptions that the upgrade tool does not read from any 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 a repo's config declares one, and [request timeout](#request-timeout) below shows how to set it instead.
46
46
 
package/docs/utilities.md CHANGED
@@ -47,9 +47,21 @@ 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
+
54
+ ### Cache
55
+
56
+ `nmr-fmt` passes Prettier's content-keyed cache (`--cache --cache-strategy content`), so a run reformats only the files whose content changed since a run that passed them. The cache files live in `node_modules/.cache/prettier/nmr-fmt/` at the repository root, one per directory from which `nmr-fmt` runs, because Prettier keys each entry by its path relative to that directory. A repository whose root does not contain a `node_modules` directory runs uncached rather than having one created.
57
+
58
+ A cached entry is reused only while the Prettier version, the Node version, the resolved Prettier options, and the repository's lockfile are all unchanged. Prettier's own key covers the first three. It does not cover plugin code, so each cache file's name also contains a fingerprint of the root's lockfile, and a dependency change, a plugin upgrade included, starts a fresh cache. `nmr-fmt` deletes cache files with an outdated fingerprint, and it discards a cache file that is not valid JSON rather than letting Prettier fail on it. A failure to prepare the cache prints a warning and leaves the run uncached; it never fails the run.
59
+
60
+ Pass `--no-cache`, or set `NMR_NO_CACHE=1`, to run uncached. `nmr --no-cache fmt:check` sets that variable, so it bypasses both nmr's [check cache](check-cache.md) and this one. Because the fingerprint makes a cache from an earlier lockfile harmless, CI can restore the whole `node_modules/.cache/prettier/nmr-fmt/` directory under a fallback key.
61
+
62
+ ```bash
63
+ nmr-fmt --check --no-cache
64
+ ```
53
65
 
54
66
  ## `ensure-prepublish-hooks`
55
67