@williamthorsen/nmr 0.37.0 → 0.39.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 (82) hide show
  1. package/.meta/changelog.json +114 -0
  2. package/.readyup/kits/default.js +92 -87
  3. package/.readyup/manifest.json +8 -8
  4. package/CHANGELOG.md +89 -0
  5. package/README.md +37 -28
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache.d.ts +4 -3
  8. package/dist/esm/check-cache.js +28 -28
  9. package/dist/esm/cli-build.js +3 -0
  10. package/dist/esm/cli-clean.js +3 -1
  11. package/dist/esm/cli-ensure-prepublish-hooks.js +7 -5
  12. package/dist/esm/cli-report-catalog.js +2 -1
  13. package/dist/esm/cli-report-overrides.js +2 -1
  14. package/dist/esm/commands/build-output.d.ts +2 -2
  15. package/dist/esm/commands/build-output.js +9 -9
  16. package/dist/esm/commands/build.d.ts +6 -1
  17. package/dist/esm/commands/build.js +73 -72
  18. package/dist/esm/commands/clean.d.ts +3 -2
  19. package/dist/esm/commands/clean.js +25 -24
  20. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +5 -4
  21. package/dist/esm/commands/ensure-prepublish-hooks.js +44 -42
  22. package/dist/esm/commands/fmt.js +33 -33
  23. package/dist/esm/commands/report-catalog.d.ts +2 -1
  24. package/dist/esm/commands/report-catalog.js +14 -11
  25. package/dist/esm/commands/report-overrides.d.ts +2 -1
  26. package/dist/esm/commands/report-overrides.js +11 -10
  27. package/dist/esm/config.js +39 -29
  28. package/dist/esm/context.js +8 -8
  29. package/dist/esm/default-scripts.d.ts +2 -2
  30. package/dist/esm/default-scripts.js +5 -5
  31. package/dist/esm/glyphs.d.ts +9 -0
  32. package/dist/esm/glyphs.js +9 -0
  33. package/dist/esm/help.d.ts +2 -2
  34. package/dist/esm/help.js +36 -30
  35. package/dist/esm/helpers/clampToBytes.js +3 -3
  36. package/dist/esm/helpers/createBoundedBuffer.d.ts +1 -1
  37. package/dist/esm/helpers/createBoundedBuffer.js +2 -2
  38. package/dist/esm/helpers/duration.d.ts +2 -2
  39. package/dist/esm/helpers/duration.js +7 -7
  40. package/dist/esm/helpers/filter-selection.js +12 -12
  41. package/dist/esm/helpers/findClosestName.js +15 -15
  42. package/dist/esm/helpers/package-json.d.ts +2 -2
  43. package/dist/esm/helpers/package-json.js +26 -25
  44. package/dist/esm/helpers/transcript.d.ts +2 -2
  45. package/dist/esm/helpers/transcript.js +14 -14
  46. package/dist/esm/output-style.d.ts +41 -0
  47. package/dist/esm/output-style.js +17 -0
  48. package/dist/esm/prettier.js +5 -5
  49. package/dist/esm/recording.d.ts +8 -5
  50. package/dist/esm/recording.js +27 -17
  51. package/dist/esm/report-format.d.ts +1 -1
  52. package/dist/esm/report-format.js +7 -7
  53. package/dist/esm/resolveBinStyles.d.ts +12 -0
  54. package/dist/esm/resolveBinStyles.js +16 -0
  55. package/dist/esm/resolver.d.ts +2 -2
  56. package/dist/esm/resolver.js +16 -16
  57. package/dist/esm/runCli.js +309 -221
  58. package/dist/esm/runner.d.ts +1 -1
  59. package/dist/esm/runner.js +18 -18
  60. package/dist/esm/steps.d.ts +17 -5
  61. package/dist/esm/steps.js +37 -28
  62. package/dist/esm/tests.d.ts +7 -7
  63. package/dist/esm/tests.js +20 -8
  64. package/dist/esm/tiers.d.ts +1 -1
  65. package/dist/esm/tiers.js +8 -8
  66. package/dist/esm/verbosity.d.ts +1 -1
  67. package/dist/esm/verbosity.js +7 -7
  68. package/dist/esm/verdict.d.ts +15 -4
  69. package/dist/esm/verdict.js +78 -59
  70. package/dist/esm/vitest-source-resolution.js +48 -46
  71. package/dist/esm/vitest.d.ts +2 -2
  72. package/dist/esm/vitest.js +74 -25
  73. package/dist/esm/workspace.d.ts +2 -18
  74. package/dist/esm/workspace.js +9 -67
  75. package/docs/check-cache.md +5 -5
  76. package/docs/reporting.md +20 -2
  77. package/docs/scripts.md +2 -2
  78. package/docs/vitest.md +6 -6
  79. package/docs/workspace.md +24 -1
  80. package/package.json +9 -10
  81. package/dist/esm/helpers/workspace-patterns.d.ts +0 -8
  82. package/dist/esm/helpers/workspace-patterns.js +0 -29
@@ -32,7 +32,7 @@ export interface RunStepsResult {
32
32
  * What the list's opaque steps produced, absent unless every one of them was captured whole and there was
33
33
  * at least one to capture.
34
34
  */
35
- retained?: RetainedOutput;
35
+ retainedOutput?: RetainedOutput;
36
36
  }
37
37
  /** How a command ended, and the exit code that carries that ending to nmr's own caller. */
38
38
  type CommandCompletion = {
@@ -14,7 +14,7 @@ export function resolveInheritedChannel(stream) {
14
14
  return hasDescriptor(stream) ? stream.fd : 'pipe';
15
15
  }
16
16
  export async function runSteps(steps, cwd, options) {
17
- const resolved = {
17
+ const resolvedOptions = {
18
18
  ...options,
19
19
  quiet: options.quiet === true,
20
20
  stdout: options.stdout ?? process.stdout,
@@ -23,7 +23,7 @@ export async function runSteps(steps, cwd, options) {
23
23
  const parts = [];
24
24
  let isCaptureWhole = true;
25
25
  for (const step of steps) {
26
- const result = await runStep(step, cwd, resolved);
26
+ const result = await runStep(step, cwd, resolvedOptions);
27
27
  if (step.kind === 'opaque') {
28
28
  if (result.stderr === undefined || result.stdout === undefined) {
29
29
  isCaptureWhole = false;
@@ -39,31 +39,31 @@ export async function runSteps(steps, cwd, options) {
39
39
  return { exitCode: 0, ...composeRetainedOutput(parts, isCaptureWhole) };
40
40
  }
41
41
  export async function runCommand(command, cwd, options) {
42
- return runSpawned({ args: [], file: command, input: 'inherit', useShell: true }, cwd, options);
42
+ return runSpawned({ args: [], file: command, input: 'inherit', shouldUseShell: true }, cwd, options);
43
43
  }
44
- async function runSpawned({ args, file, input, useShell }, cwd, options) {
44
+ async function runSpawned({ args, file, input, shouldUseShell }, cwd, options) {
45
45
  const quiet = options.quiet === true;
46
46
  const stdout = options.stdout ?? process.stdout;
47
47
  const stderr = options.stderr ?? process.stderr;
48
48
  const env = options.env ?? process.env;
49
49
  const child = spawn(file, args, {
50
- shell: useShell,
50
+ shell: shouldUseShell,
51
51
  stdio: [input, options.channels.stdout, options.channels.stderr],
52
52
  cwd,
53
53
  env,
54
54
  });
55
55
  const stdoutCapture = child.stdout === null ? undefined : captureStream(child.stdout, quiet ? undefined : stdout);
56
56
  const stderrCapture = child.stderr === null ? undefined : captureStream(child.stderr, quiet ? undefined : stderr);
57
- const completion = await awaitCompletion(child, () => {
57
+ const commandCompletion = await awaitCompletion(child, () => {
58
58
  stdoutCapture?.stopReading();
59
59
  stderrCapture?.stopReading();
60
60
  });
61
61
  stdoutCapture?.detach();
62
62
  stderrCapture?.detach();
63
63
  const result = {
64
- ...completion,
65
- stdout: stdoutCapture?.toBuffer(),
66
- stderr: stderrCapture?.toBuffer(),
64
+ ...commandCompletion,
65
+ stdout: stdoutCapture?.readBuffer(),
66
+ stderr: stderrCapture?.readBuffer(),
67
67
  };
68
68
  if (result.outcome === 'spawn-failed') {
69
69
  reportError(result.error.message, stderr);
@@ -78,14 +78,14 @@ async function runSpawned({ args, file, input, useShell }, cwd, options) {
78
78
  function runStep(step, cwd, options) {
79
79
  const { quiet, stderr, stdout } = options;
80
80
  if (step.kind === 'opaque') {
81
- return runSpawned({ args: [], file: step.command, input: 'inherit', useShell: true }, cwd, {
81
+ return runSpawned({ args: [], file: step.command, input: 'inherit', shouldUseShell: true }, cwd, {
82
82
  ...options,
83
83
  channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
84
84
  });
85
85
  }
86
86
  const [file, ...args] = step.argv;
87
87
  const input = step.shouldWithholdInput === true ? 'ignore' : 'inherit';
88
- return runSpawned({ args, file, input, useShell: false }, cwd, {
88
+ return runSpawned({ args, file, input, shouldUseShell: false }, cwd, {
89
89
  ...options,
90
90
  quiet: false,
91
91
  channels: { stderr: resolveInheritedChannel(stderr), stdout: resolveInheritedChannel(stdout) },
@@ -96,14 +96,14 @@ function composeRetainedOutput(parts, isCaptureWhole) {
96
96
  return {};
97
97
  }
98
98
  return {
99
- retained: {
99
+ retainedOutput: {
100
100
  stderr: Buffer.concat(parts.map((part) => part.stderr)),
101
101
  stdout: Buffer.concat(parts.map((part) => part.stdout)),
102
102
  },
103
103
  };
104
104
  }
105
105
  function captureStream(source, destination) {
106
- const retained = createBoundedBuffer();
106
+ const retainedCopy = createBoundedBuffer();
107
107
  let isComplete = true;
108
108
  function stopReading() {
109
109
  if (destination !== undefined)
@@ -119,9 +119,9 @@ function captureStream(source, destination) {
119
119
  destination.on('error', abandon);
120
120
  source.pipe(destination, { end: false });
121
121
  }
122
- source.on('data', (chunk) => retained.append(chunk));
122
+ source.on('data', (chunk) => retainedCopy.append(chunk));
123
123
  return {
124
- toBuffer: () => (isComplete ? retained.toBuffer() : undefined),
124
+ readBuffer: () => (isComplete ? retainedCopy.readBuffer() : undefined),
125
125
  detach: () => {
126
126
  destination?.off('error', abandon);
127
127
  },
@@ -145,11 +145,11 @@ function awaitCompletion(child, stopReading) {
145
145
  settle({ outcome: 'spawn-failed', exitCode: 1, error });
146
146
  });
147
147
  child.on('exit', (code, signal) => {
148
- const completion = describeExit(code, signal);
149
- exitCompletion = completion;
148
+ const exitDescription = describeExit(code, signal);
149
+ exitCompletion = exitDescription;
150
150
  graceTimer = setTimeout(() => {
151
151
  stopReading();
152
- settle(completion);
152
+ settle(exitDescription);
153
153
  }, PIPE_DRAIN_GRACE_MS);
154
154
  });
155
155
  child.on('close', () => {
@@ -4,12 +4,12 @@
4
4
  *
5
5
  * A structural step's argv leads with the file to spawn, so the runner has one to hand `spawn` without a shell.
6
6
  *
7
- * `declinesArgs` travels from the composite element that composed the step to the one reader that acts on it,
7
+ * `shouldDeclineArguments` travels from the composite element that composed the step to the one reader that acts on it,
8
8
  * the binding of the invocation's trailing arguments. Every stage between the two -- the devBin substitution,
9
9
  * the chain rendering, the replay assembly, the runner -- passes it through and asks nothing of it.
10
10
  *
11
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 `declinesArgs`, it is set only where it holds and leaves the rendered chain unchanged.
12
+ * than nmr's own. Like `shouldDeclineArguments`, it is set only where it holds and leaves the rendered chain unchanged.
13
13
  */
14
14
  export type Step = {
15
15
  kind: 'opaque';
@@ -17,7 +17,7 @@ export type Step = {
17
17
  } | {
18
18
  kind: 'structural';
19
19
  argv: readonly [string, ...(readonly string[])];
20
- declinesArgs?: boolean;
20
+ shouldDeclineArguments?: boolean;
21
21
  shouldWithholdInput?: boolean;
22
22
  };
23
23
  /** What a structural step asks of the nmr process it spawns. */
@@ -25,6 +25,11 @@ export interface NmrStepTarget {
25
25
  command: string;
26
26
  /** Whether the command is fanned out to other scopes, where a `-R` or `-F` sends it. */
27
27
  isDelegate: boolean;
28
+ /**
29
+ * Whether the command is fanned out to every package, where a `-R` sends it. A separate field rather than a
30
+ * narrowing of `isDelegate`, whose reader surveys the scopes a `-F` may also have reached.
31
+ */
32
+ isRecursive: boolean;
28
33
  /** Whether the command runs against the root registry, where a `-w` anchors it. */
29
34
  isWorkspaceRoot: boolean;
30
35
  }
@@ -39,10 +44,17 @@ export type SelfReference = 'chained' | 'sole';
39
44
  * The element tokenizes on whitespace, so it may carry nmr's own flags but cannot carry a space-bearing token.
40
45
  * `-w` is prepended as its own token, so the child selects the root registry on its own.
41
46
  *
42
- * `declinesArgs` is set only where it holds, so a step that takes the trailing arguments renders and compares
47
+ * `shouldDeclineArguments` is set only where it holds, so a step that takes the trailing arguments renders and compares
43
48
  * exactly as it did before any element declared anything.
44
49
  */
45
- export declare function composeNmrStep(element: string, workspaceRoot: boolean, declinesArgs?: boolean): Step;
50
+ export declare function composeNmrStep(element: string, isWorkspaceRoot: boolean, shouldDeclineArguments?: boolean): Step;
51
+ /**
52
+ * Returns the steps that still run in a workspace holding no package: every step but the ones a `-R` fans out.
53
+ *
54
+ * A `-F` step is left standing. It names one package, and its absence is a real error whoever composed the
55
+ * step, where a `-R` asked for every package and a workspace with none has nothing for it to do.
56
+ */
57
+ export declare function dropRecursiveSteps(steps: readonly Step[]): Step[];
46
58
  /**
47
59
  * Returns the text of the first opaque step that reaches nmr through a shell, or `undefined` when none does.
48
60
  *
package/dist/esm/steps.js CHANGED
@@ -11,9 +11,16 @@ 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, workspaceRoot, declinesArgs = false) {
15
- const flags = workspaceRoot ? ['-w'] : [];
16
- return { kind: 'structural', argv: ['nmr', ...flags, ...tokenize(element)], ...(declinesArgs && { declinesArgs }) };
14
+ export function composeNmrStep(element, isWorkspaceRoot, shouldDeclineArguments = false) {
15
+ const flags = isWorkspaceRoot ? ['-w'] : [];
16
+ return {
17
+ kind: 'structural',
18
+ argv: ['nmr', ...flags, ...tokenize(element)],
19
+ ...(shouldDeclineArguments && { shouldDeclineArguments }),
20
+ };
21
+ }
22
+ export function dropRecursiveSteps(steps) {
23
+ return steps.filter((step) => readNmrStep(step)?.isRecursive !== true);
17
24
  }
18
25
  export function findNmrCrossing(steps) {
19
26
  for (const step of steps) {
@@ -36,12 +43,12 @@ export function readNmrStep(step) {
36
43
  export function readSelfReference(options) {
37
44
  const { anchoredAtRoot, commandName, script } = options;
38
45
  const segments = splitSegments(script).filter((segment) => segment.trim() !== '');
39
- const reenters = segments.some((segment) => {
46
+ const doesReenter = segments.some((segment) => {
40
47
  const tail = readNmrTail(segment);
41
48
  const target = tail === undefined ? undefined : readNmrTarget(tail);
42
49
  return target?.command === commandName && !target.isDelegate && (anchoredAtRoot || !target.isWorkspaceRoot);
43
50
  });
44
- if (!reenters) {
51
+ if (!doesReenter) {
45
52
  return undefined;
46
53
  }
47
54
  return segments.length === 1 ? 'sole' : 'chained';
@@ -86,6 +93,7 @@ function readNmrTail(segment) {
86
93
  }
87
94
  function readNmrTarget(tokens) {
88
95
  let isDelegate = false;
96
+ let isRecursive = false;
89
97
  let isWorkspaceRoot = false;
90
98
  let index = 0;
91
99
  while (index < tokens.length) {
@@ -99,6 +107,7 @@ function readNmrTarget(tokens) {
99
107
  case '-R':
100
108
  case '--recursive':
101
109
  isDelegate = true;
110
+ isRecursive = true;
102
111
  index += 1;
103
112
  break;
104
113
  case '-w':
@@ -108,7 +117,7 @@ function readNmrTarget(tokens) {
108
117
  break;
109
118
  default:
110
119
  if (!token.startsWith('-')) {
111
- return { command: token, isDelegate, isWorkspaceRoot };
120
+ return { command: token, isDelegate, isRecursive, isWorkspaceRoot };
112
121
  }
113
122
  index += 1;
114
123
  }
@@ -120,49 +129,49 @@ function renderStep(step) {
120
129
  }
121
130
  function splitSegments(command) {
122
131
  const segments = [];
123
- let current = '';
132
+ let currentSegment = '';
124
133
  let quote;
125
134
  let index = 0;
126
135
  while (index < command.length) {
127
136
  const char = command[index] ?? '';
128
137
  if (quote !== undefined) {
129
138
  if (char === '\\' && quote === '"') {
130
- current += char + (command[index + 1] ?? '');
139
+ currentSegment += char + (command[index + 1] ?? '');
131
140
  index += 2;
132
141
  continue;
133
142
  }
134
- current += char;
143
+ currentSegment += char;
135
144
  if (char === quote)
136
145
  quote = undefined;
137
146
  index += 1;
138
147
  continue;
139
148
  }
140
149
  if (char === '\\') {
141
- current += char + (command[index + 1] ?? '');
150
+ currentSegment += char + (command[index + 1] ?? '');
142
151
  index += 2;
143
152
  continue;
144
153
  }
145
154
  if (QUOTES.has(char)) {
146
155
  quote = char;
147
- current += char;
156
+ currentSegment += char;
148
157
  index += 1;
149
158
  continue;
150
159
  }
151
160
  if (SEGMENT_SEPARATORS.has(char)) {
152
- if (isRedirectionOperator(char, current, command[index + 1])) {
153
- current += char;
161
+ if (isRedirectionOperator(char, currentSegment, command[index + 1])) {
162
+ currentSegment += char;
154
163
  index += 1;
155
164
  continue;
156
165
  }
157
166
  index += command[index + 1] === char ? 2 : 1;
158
- segments.push(current);
159
- current = '';
167
+ segments.push(currentSegment);
168
+ currentSegment = '';
160
169
  continue;
161
170
  }
162
- current += char;
171
+ currentSegment += char;
163
172
  index += 1;
164
173
  }
165
- segments.push(current);
174
+ segments.push(currentSegment);
166
175
  return segments;
167
176
  }
168
177
  function tokenize(element) {
@@ -170,47 +179,47 @@ function tokenize(element) {
170
179
  }
171
180
  function tokenizeSegment(segment) {
172
181
  const tokens = [];
173
- let current = '';
182
+ let currentToken = '';
174
183
  let quote;
175
184
  let index = 0;
176
185
  while (index < segment.length) {
177
186
  const char = segment[index] ?? '';
178
187
  if (quote !== undefined) {
179
188
  if (char === '\\' && quote === '"') {
180
- current += char + (segment[index + 1] ?? '');
189
+ currentToken += char + (segment[index + 1] ?? '');
181
190
  index += 2;
182
191
  continue;
183
192
  }
184
- current += char;
193
+ currentToken += char;
185
194
  if (char === quote)
186
195
  quote = undefined;
187
196
  index += 1;
188
197
  continue;
189
198
  }
190
199
  if (char === '\\') {
191
- current += char + (segment[index + 1] ?? '');
200
+ currentToken += char + (segment[index + 1] ?? '');
192
201
  index += 2;
193
202
  continue;
194
203
  }
195
204
  if (QUOTES.has(char)) {
196
205
  quote = char;
197
- current += char;
206
+ currentToken += char;
198
207
  index += 1;
199
208
  continue;
200
209
  }
201
210
  if (TOKEN_SEPARATORS.has(char)) {
202
- if (current !== '') {
203
- tokens.push(current);
211
+ if (currentToken !== '') {
212
+ tokens.push(currentToken);
204
213
  }
205
- current = '';
214
+ currentToken = '';
206
215
  index += 1;
207
216
  continue;
208
217
  }
209
- current += char;
218
+ currentToken += char;
210
219
  index += 1;
211
220
  }
212
- if (current !== '') {
213
- tokens.push(current);
221
+ if (currentToken !== '') {
222
+ tokens.push(currentToken);
214
223
  }
215
224
  return tokens;
216
225
  }
@@ -6,9 +6,9 @@
6
6
  * scopes what the sweep covers. Both halves are silent without it: `unit` is the residual project, so an untiered
7
7
  * file runs and passes, and a file outside `__tests__` is collected by nothing at all.
8
8
  *
9
- * `exclude` names directory basenames the sweep prunes at any depth, additive to the ones nmr always prunes. Pass
10
- * the same array to `defineVitestConfig`'s `testCollectionExclude`, or the sweep and the collection glob describe
11
- * different trees: a directory pruned here alone still runs the files this suite stopped reporting.
9
+ * `excludedBasenames` names directory basenames the sweep prunes at any depth, additive to the ones nmr always
10
+ * prunes. Pass the same array to `defineVitestConfig`'s `testCollectionExclude`, or the sweep and the collection
11
+ * glob describe different trees: a directory pruned here alone still runs the files this suite stopped reporting.
12
12
  */
13
13
  export declare function checkTestFileConventions(options?: TestFileConventionsOptions): void;
14
14
  /**
@@ -19,13 +19,13 @@ export declare function checkTestFileConventions(options?: TestFileConventionsOp
19
19
  *
20
20
  * @internal - Exported only to enable testing
21
21
  */
22
- export declare function reportTestFileConventions({ exclude, rootDir, }?: TestFileConventionsOptions): TestFileConventionsReport;
22
+ export declare function reportTestFileConventions(options?: TestFileConventionsOptions): TestFileConventionsReport;
23
23
  export interface TestFileConventionsOptions {
24
24
  /**
25
25
  * Directory basenames the sweep prunes at any depth, additive to the ones nmr always prunes. The array a repo
26
26
  * passes here is the array it passes to `defineVitestConfig`'s `testCollectionExclude`.
27
27
  */
28
- exclude?: readonly string[];
28
+ excludedBasenames?: readonly string[];
29
29
  /**
30
30
  * The directory to sweep. Defaults to the monorepo root found from the working directory, so one call covers the
31
31
  * whole repo from whichever package Vitest started in.
@@ -34,7 +34,7 @@ export interface TestFileConventionsOptions {
34
34
  }
35
35
  /** What one sweep found, and the root it found it in. Paths are relative to that root and POSIX-separated. */
36
36
  export interface TestFileConventionsReport {
37
- misplaced: string[];
37
+ misplacedFiles: string[];
38
38
  rootDir: string;
39
- untiered: string[];
39
+ untieredFiles: string[];
40
40
  }
package/dist/esm/tests.js CHANGED
@@ -1,25 +1,37 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { findMisplacedTestFiles, findUntieredTestFiles, TIER_NAMES } from "./tiers.js";
3
3
  import { findMonorepoRoot } from "./workspace.js";
4
- const MISPLACED_REMEDY = 'Every test file must sit under a `__tests__` directory, the only place the shared Vitest config collects from. Move each file below into one, or name its directory in `exclude`.';
4
+ const MISPLACED_REMEDY = 'Every test file must sit under a `__tests__` directory, the only place the shared Vitest config collects from. Move each file below into one, or name its directory in `excludedBasenames`.';
5
5
  const UNTIERED_REMEDY = `Every collected test file must name its isolation tier in the segment before \`.test.\`, one of: ${TIER_NAMES.join(', ')}. Rename each file below to <subject>[.<aspect>].<tier>.test.ts.`;
6
+ const RETIRED_OPTION_KEYS = new Map([['exclude', 'excludedBasenames']]);
6
7
  export function checkTestFileConventions(options = {}) {
8
+ assertNoRetiredOptions(options);
7
9
  describe('test file conventions', () => {
8
- const { misplaced, rootDir, untiered } = reportTestFileConventions(options);
10
+ const { misplacedFiles, rootDir, untieredFiles } = reportTestFileConventions(options);
9
11
  it('every collected test file names its tier', () => {
10
- expect(untiered, `${UNTIERED_REMEDY} Swept from ${rootDir}.`).toStrictEqual([]);
12
+ expect(untieredFiles, `${UNTIERED_REMEDY} Swept from ${rootDir}.`).toStrictEqual([]);
11
13
  });
12
14
  it('every test file sits under a __tests__ directory', () => {
13
- expect(misplaced, `${MISPLACED_REMEDY} Swept from ${rootDir}.`).toStrictEqual([]);
15
+ expect(misplacedFiles, `${MISPLACED_REMEDY} Swept from ${rootDir}.`).toStrictEqual([]);
14
16
  });
15
17
  });
16
18
  }
17
- export function reportTestFileConventions({ exclude, rootDir, } = {}) {
19
+ export function reportTestFileConventions(options = {}) {
20
+ assertNoRetiredOptions(options);
21
+ const { excludedBasenames, rootDir } = options;
18
22
  const sweptRoot = rootDir ?? findMonorepoRoot();
19
- const scanOptions = { ...(exclude !== undefined && { exclude }) };
23
+ const scanOptions = { ...(excludedBasenames !== undefined && { excludedBasenames }) };
20
24
  return {
21
- misplaced: findMisplacedTestFiles(sweptRoot, scanOptions),
25
+ misplacedFiles: findMisplacedTestFiles(sweptRoot, scanOptions),
22
26
  rootDir: sweptRoot,
23
- untiered: findUntieredTestFiles(sweptRoot, scanOptions),
27
+ untieredFiles: findUntieredTestFiles(sweptRoot, scanOptions),
24
28
  };
25
29
  }
30
+ function assertNoRetiredOptions(options) {
31
+ for (const key of Object.keys(options)) {
32
+ const replacement = RETIRED_OPTION_KEYS.get(key);
33
+ if (replacement !== undefined) {
34
+ throw new TypeError(`Invalid test-file-conventions options: \`${key}\` was renamed to \`${replacement}\`.`);
35
+ }
36
+ }
37
+ }
@@ -50,7 +50,7 @@ export interface TestFileScanOptions {
50
50
  * config's `testCollectionExclude`. A directory pruned from the sweep but still collected by Vitest is the
51
51
  * silence these sweeps exist to end.
52
52
  */
53
- exclude?: readonly string[];
53
+ excludedBasenames?: readonly string[];
54
54
  }
55
55
  /**
56
56
  * The isolation ladder, ordered by the furthest thing a test reaches. A tier names what a test reaches, never how it
package/dist/esm/tiers.js CHANGED
@@ -23,26 +23,26 @@ export function hasTierInfix(filePath) {
23
23
  }
24
24
  export const TEST_COLLECTION_EXCLUDE = ['.git', 'coverage', 'dist', 'node_modules'];
25
25
  export const TIER_NAMES = ['unit', 'tool', 'localhost', 'remote'];
26
- function collectTestFiles(dir, relativeDir, inTestDir, context) {
26
+ function collectTestFiles(dir, relativeDir, isInTestDir, context) {
27
27
  const entries = readdirSync(dir, { withFileTypes: true });
28
28
  for (const entry of entries) {
29
29
  const relativePath = relativeDir === '' ? entry.name : `${relativeDir}/${entry.name}`;
30
30
  if (entry.isDirectory()) {
31
31
  if (context.pruned.has(entry.name))
32
32
  continue;
33
- collectTestFiles(path.join(dir, entry.name), relativePath, inTestDir || entry.name === TEST_DIR, context);
33
+ collectTestFiles(path.join(dir, entry.name), relativePath, isInTestDir || entry.name === TEST_DIR, context);
34
34
  }
35
- else if (inTestDir !== context.misplaced && TEST_FILE_PATTERN.test(entry.name)) {
36
- context.found.push(relativePath);
35
+ else if (isInTestDir !== context.misplaced && TEST_FILE_PATTERN.test(entry.name)) {
36
+ context.foundPaths.push(relativePath);
37
37
  }
38
38
  }
39
39
  }
40
- function walkTestFiles(rootDir, { exclude = [] }, misplaced) {
40
+ function walkTestFiles(rootDir, { excludedBasenames = [] }, misplaced) {
41
41
  const context = {
42
- found: [],
42
+ foundPaths: [],
43
43
  misplaced,
44
- pruned: new Set([...TEST_COLLECTION_EXCLUDE, ...exclude]),
44
+ pruned: new Set([...TEST_COLLECTION_EXCLUDE, ...excludedBasenames]),
45
45
  };
46
46
  collectTestFiles(rootDir, '', false, context);
47
- return context.found.toSorted();
47
+ return context.foundPaths.toSorted();
48
48
  }
@@ -38,8 +38,8 @@ export interface ResolveVerbosityOptions {
38
38
  /** Read for the detection level, and for that alone: the variable this module owns arrives as `envVerbosity`. */
39
39
  env: NodeJS.ProcessEnv;
40
40
  envVerbosity: CommandVerbosity | undefined;
41
+ hasQuietFlag: boolean;
41
42
  output: OutputConfig | undefined;
42
- quietFlag: boolean;
43
43
  }
44
44
  /**
45
45
  * Resolves the verbosity this process runs at: the flag, then the environment, then the repo's config, then
@@ -9,18 +9,18 @@ export function isCommandVerbosity(value) {
9
9
  return names.includes(value);
10
10
  }
11
11
  export function readVerbosityEnv(env) {
12
- const raw = env[COMMAND_VERBOSITY_ENV_VAR];
13
- if (raw === undefined || raw === '') {
12
+ const rawValue = env[COMMAND_VERBOSITY_ENV_VAR];
13
+ if (rawValue === undefined || rawValue === '') {
14
14
  return { ok: true };
15
15
  }
16
- if (!isCommandVerbosity(raw)) {
17
- return { ok: false, error: formatVerbosityRejection(COMMAND_VERBOSITY_ENV_VAR, raw) };
16
+ if (!isCommandVerbosity(rawValue)) {
17
+ return { ok: false, error: formatVerbosityRejection(COMMAND_VERBOSITY_ENV_VAR, rawValue) };
18
18
  }
19
- return { ok: true, verbosity: raw };
19
+ return { ok: true, verbosity: rawValue };
20
20
  }
21
21
  export function resolveVerbosity(options) {
22
- const { env, envVerbosity, output, quietFlag } = options;
23
- if (quietFlag)
22
+ const { env, envVerbosity, hasQuietFlag, output } = options;
23
+ if (hasQuietFlag)
24
24
  return 'quiet';
25
25
  if (envVerbosity !== undefined)
26
26
  return envVerbosity;
@@ -1,4 +1,5 @@
1
1
  import type { Writable } from 'node:stream';
2
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
2
3
  import type { ReplayLine } from './check-cache.js';
3
4
  import type { ReportFormat } from './report-format.js';
4
5
  /**
@@ -10,7 +11,14 @@ import type { ReportFormat } from './report-format.js';
10
11
  * truncation point from one machine to the next. The ceiling governs pipes: a terminal offers no such
11
12
  * guarantee at any size.
12
13
  */
13
- export declare const VERDICT_LINE_LIMIT = 512;
14
+ export declare const VERDICT_LINE_LIMIT_BYTES = 512;
15
+ /**
16
+ * Why a command ran nothing, which the `no-op` outcome carries and `--json` serializes.
17
+ *
18
+ * `empty-workspace` and `empty-override` both leave a chain with no step in it, so the reason is what tells
19
+ * a command emptied by the workspace apart from one emptied by an override.
20
+ */
21
+ export type NoOpReason = 'empty-override' | 'empty-workspace' | 'noop-override';
14
22
  /**
15
23
  * What a command nmr ran came to, holding the facts a reporting line is rendered from rather than the line
16
24
  * itself, so a machine-readable rendering spends the same record a human-readable one does.
@@ -46,7 +54,7 @@ export type VerdictOutcome = {
46
54
  replay?: ReplayLine[];
47
55
  } | {
48
56
  outcome: 'no-op';
49
- reason: 'empty-override' | 'noop-override';
57
+ reason: NoOpReason;
50
58
  });
51
59
  /**
52
60
  * Renders a verdict as the line nmr reports it on, without the newline that terminates it.
@@ -54,8 +62,11 @@ export type VerdictOutcome = {
54
62
  * The line ends without terminal punctuation and reserves its tail for `detail`, so a later change appends to
55
63
  * the grammar rather than rewriting it. A detail carrying nothing but line breaks takes its whole clause with
56
64
  * it, rather than leaving a separator pointing at nothing.
65
+ *
66
+ * The marker takes no padding column: every plain marker a verdict reaches for is four characters, so the
67
+ * lines align by construction. `formatStatusLine` pads to a column that `BLOCK` widens, and no verdict is one.
57
68
  */
58
- export declare function renderVerdict(verdict: Verdict): string;
69
+ export declare function renderVerdict(verdict: Verdict, style: OutputStyle): string;
59
70
  /**
60
71
  * Renders a verdict as the JSON object a machine consumer reads, without the newline that terminates it.
61
72
  *
@@ -71,4 +82,4 @@ export declare function serializeVerdict(verdict: Verdict): string;
71
82
  * share one descriptor. Both renderings spend the one record, so neither can come to report what the other
72
83
  * does not.
73
84
  */
74
- export declare function writeVerdict(verdict: Verdict, stream: Writable, format: ReportFormat): void;
85
+ export declare function writeVerdict(verdict: Verdict, stream: Writable, format: ReportFormat, style: OutputStyle): void;