@williamthorsen/nmr 0.36.0 → 0.38.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 (62) hide show
  1. package/.meta/changelog.json +113 -0
  2. package/.readyup/kits/default.js +98 -10
  3. package/.readyup/manifest.json +7 -7
  4. package/CHANGELOG.md +82 -0
  5. package/README.md +107 -1135
  6. package/agents/README.md +1 -1
  7. package/agents/guidance/rulebooks/nmr.md +4 -4
  8. package/dist/esm/check-cache.d.ts +2 -1
  9. package/dist/esm/check-cache.js +4 -4
  10. package/dist/esm/cli-build.js +3 -0
  11. package/dist/esm/cli-clean.js +3 -1
  12. package/dist/esm/cli-ensure-prepublish-hooks.js +3 -1
  13. package/dist/esm/cli-report-catalog.js +2 -1
  14. package/dist/esm/cli-report-overrides.js +2 -1
  15. package/dist/esm/commands/build.d.ts +6 -1
  16. package/dist/esm/commands/build.js +9 -10
  17. package/dist/esm/commands/clean.d.ts +3 -2
  18. package/dist/esm/commands/clean.js +15 -14
  19. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +3 -2
  20. package/dist/esm/commands/ensure-prepublish-hooks.js +8 -6
  21. package/dist/esm/commands/report-catalog.d.ts +2 -1
  22. package/dist/esm/commands/report-catalog.js +5 -4
  23. package/dist/esm/commands/report-overrides.d.ts +2 -1
  24. package/dist/esm/commands/report-overrides.js +5 -4
  25. package/dist/esm/config.js +14 -4
  26. package/dist/esm/context.d.ts +2 -0
  27. package/dist/esm/context.js +1 -0
  28. package/dist/esm/default-scripts.d.ts +2 -2
  29. package/dist/esm/default-scripts.js +5 -5
  30. package/dist/esm/glyphs.d.ts +9 -0
  31. package/dist/esm/glyphs.js +9 -0
  32. package/dist/esm/help.js +15 -9
  33. package/dist/esm/helpers/filter-selection.d.ts +42 -0
  34. package/dist/esm/helpers/filter-selection.js +61 -0
  35. package/dist/esm/output-style.d.ts +41 -0
  36. package/dist/esm/output-style.js +17 -0
  37. package/dist/esm/recording.d.ts +3 -0
  38. package/dist/esm/recording.js +6 -3
  39. package/dist/esm/resolveBinStyles.d.ts +12 -0
  40. package/dist/esm/resolveBinStyles.js +16 -0
  41. package/dist/esm/resolver.js +2 -2
  42. package/dist/esm/runCli.js +152 -62
  43. package/dist/esm/runner.js +6 -5
  44. package/dist/esm/steps.d.ts +8 -4
  45. package/dist/esm/steps.js +6 -2
  46. package/dist/esm/verdict.d.ts +7 -3
  47. package/dist/esm/verdict.js +16 -10
  48. package/dist/esm/vitest-source-resolution.d.ts +37 -0
  49. package/dist/esm/vitest-source-resolution.js +205 -0
  50. package/dist/esm/vitest.d.ts +3 -2
  51. package/dist/esm/vitest.js +7 -6
  52. package/dist/esm/workspace.d.ts +9 -0
  53. package/dist/esm/workspace.js +17 -0
  54. package/docs/check-cache.md +176 -0
  55. package/docs/prettier.md +47 -0
  56. package/docs/reporting.md +104 -0
  57. package/docs/scripts.md +168 -0
  58. package/docs/upgrades.md +110 -0
  59. package/docs/utilities.md +64 -0
  60. package/docs/vitest.md +233 -0
  61. package/docs/workspace.md +11 -0
  62. package/package.json +11 -9
@@ -1,14 +1,18 @@
1
1
  import path from 'node:path';
2
- import { readPackageVersion, reportError } from '@williamthorsen/nmr-core';
2
+ import { describeInvalidOutputStyle, formatGlyphLine, readPackageVersion, reportError, STATUS_GLYPHS, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { certifyRetention, computeCacheKey, computeRetentionKey, CURRENT_RUNTIME, encodeTreeSnapshot, findStaleBuildOutput, formatMisplacedNoCacheWarning, isCacheableCommand, NO_CACHE_ENV_VAR, readBuildOutputState, readCheckCacheEntry, recordTranscript, resolveRunId, resolveTreeSnapshot, RUN_ID_ENV_VAR, TREE_SNAPSHOT_ENV_VAR, writeCheckCacheEntry, writeDebugNote, } from "./check-cache.js";
5
5
  import { resolveContext } from "./context.js";
6
+ import { NMR_GLYPHS } from "./glyphs.js";
6
7
  import { generateHelp } from "./help.js";
7
8
  import { resolveConfigPath } from "./helpers/config-path.js";
8
9
  import { deriveExcerpt } from "./helpers/deriveExcerpt.js";
10
+ import { readFilterSelection } from "./helpers/filter-selection.js";
11
+ import { findClosestName } from "./helpers/findClosestName.js";
9
12
  import { isHookName } from "./helpers/hook-name.js";
10
13
  import { resolvePackageJsonPath } from "./helpers/package-json.js";
11
14
  import { composeTranscript } from "./helpers/transcript.js";
15
+ import { isTerminalStream, OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG, resolveOutputStyles } from "./output-style.js";
12
16
  import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
13
17
  import { assembleReplay } from "./replay-assembly.js";
14
18
  import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
@@ -18,8 +22,10 @@ import { composeNmrStep, findNmrCrossing, renderChain } from "./steps.js";
18
22
  import { UserError } from "./UserError.js";
19
23
  import { COMMAND_VERBOSITY_ENV_VAR, readVerbosityEnv, resolveVerbosity, } from "./verbosity.js";
20
24
  import { writeVerdict } from "./verdict.js";
25
+ import { readWorkspacePackageNames } from "./workspace.js";
21
26
  const VERSION = readPackageVersion(import.meta.url);
22
27
  const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
28
+ const NAME_CEILING = 10;
23
29
  const NAMED_ESCAPES = new Map([
24
30
  ['\n', String.raw `\n`],
25
31
  ['\r', String.raw `\r`],
@@ -34,11 +40,17 @@ export async function runCli(options) {
34
40
  return { exitCode: 1 };
35
41
  }
36
42
  const { parsed } = parseResult;
37
- const inherited = readPresentationEnv(env);
43
+ const inherited = readPresentation({
44
+ env,
45
+ flagValue: parsed.outputStyle,
46
+ stderrIsTty: isTerminalStream(stderr),
47
+ stdoutIsTty: isTerminalStream(stdout),
48
+ });
38
49
  if (!inherited.ok) {
39
50
  reportError(inherited.error, stderr);
40
51
  return { exitCode: 1 };
41
52
  }
53
+ const { styles } = inherited;
42
54
  if (parsed.version) {
43
55
  stdout.write(`${VERSION}\n`);
44
56
  return { exitCode: 0 };
@@ -68,6 +80,7 @@ export async function runCli(options) {
68
80
  monorepoRoot: context.monorepoRoot,
69
81
  passthrough: parsed.passthrough,
70
82
  stderr,
83
+ style: styles.stderr,
71
84
  });
72
85
  const noCache = parsed.noCache || env[NO_CACHE_ENV_VAR] === '1';
73
86
  const runId = resolveRunId(env);
@@ -78,6 +91,7 @@ export async function runCli(options) {
78
91
  passthrough: parsed.passthrough,
79
92
  runId,
80
93
  snapshot,
94
+ styles,
81
95
  verbosity,
82
96
  });
83
97
  const runOptions = {
@@ -88,7 +102,7 @@ export async function runCli(options) {
88
102
  };
89
103
  const delegation = composeDelegation({ childEnv, command, parsed });
90
104
  if (delegation !== undefined) {
91
- return runSteps([delegation.step], context.monorepoRoot, { ...runOptions, env: delegation.env });
105
+ return runDelegation({ context, delegation, parsed, runOptions, stderr });
92
106
  }
93
107
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
94
108
  assertNoSelfReference({
@@ -110,7 +124,7 @@ export async function runCli(options) {
110
124
  const resolvedCommand = renderChain(resolved.steps);
111
125
  const noOpReason = findNoOpReason(resolvedCommand);
112
126
  if (noOpReason !== undefined && !parsed.log) {
113
- reportVerdict({ command, scope, outcome: 'no-op', reason: noOpReason }, stdout, format);
127
+ reportVerdict({ command, scope, outcome: 'no-op', reason: noOpReason }, stdout, format, styles.stdout);
114
128
  return { exitCode: 0 };
115
129
  }
116
130
  const substitutedSteps = applyDevBinToSteps(resolved.steps, context.config.devBin, context.monorepoRoot);
@@ -133,6 +147,7 @@ export async function runCli(options) {
133
147
  registry,
134
148
  resolved,
135
149
  stderr,
150
+ style: styles.stderr,
136
151
  useRoot,
137
152
  workspaceRoot: parsed.workspaceRoot,
138
153
  });
@@ -159,6 +174,7 @@ export async function runCli(options) {
159
174
  snapshot,
160
175
  stderr,
161
176
  stdout,
177
+ styles,
162
178
  });
163
179
  }
164
180
  const { exitCode, outcome } = await runGated({
@@ -171,7 +187,7 @@ export async function runCli(options) {
171
187
  key,
172
188
  monorepoRoot: context.monorepoRoot,
173
189
  noCache,
174
- overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, quiet),
190
+ overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, quiet, styles.stdout),
175
191
  ownSteps: mainSteps,
176
192
  runId,
177
193
  runOptions,
@@ -179,33 +195,49 @@ export async function runCli(options) {
179
195
  stderr,
180
196
  stdout,
181
197
  });
182
- reportVerdict({ command, scope, ...outcome }, stdout, format);
198
+ reportVerdict({ command, scope, ...outcome }, stdout, format, styles.stdout);
183
199
  return { exitCode };
184
200
  }
201
+ const BOOLEAN_FLAGS = new Map([
202
+ ['-?', 'help'],
203
+ ['--help', 'help'],
204
+ ['--json', 'json'],
205
+ ['--log', 'log'],
206
+ ['--no-cache', 'noCache'],
207
+ ['-q', 'quiet'],
208
+ ['--quiet', 'quiet'],
209
+ ['-R', 'recursive'],
210
+ ['--recursive', 'recursive'],
211
+ ['-V', 'version'],
212
+ ['--version', 'version'],
213
+ ['-w', 'workspaceRoot'],
214
+ ['--workspace-root', 'workspaceRoot'],
215
+ ]);
185
216
  function buildChildEnv(options) {
186
- const { env, format, noCache, passthrough, runId, snapshot, verbosity } = options;
217
+ const { env, format, noCache, passthrough, runId, snapshot, styles, verbosity } = options;
187
218
  const bypassesCache = noCache || passthrough.length > 0;
188
219
  return {
189
220
  ...env,
190
221
  ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
191
222
  ...(bypassesCache && { [NO_CACHE_ENV_VAR]: '1' }),
192
223
  [COMMAND_VERBOSITY_ENV_VAR]: verbosity,
224
+ [OUTPUT_STYLE_ENV_VAR]: styles.stdout,
193
225
  [REPORT_FORMAT_ENV_VAR]: format,
194
226
  [RUN_ID_ENV_VAR]: runId,
195
227
  };
196
228
  }
197
- function acceptsArgs(step) {
198
- return step.kind === 'opaque' || step.declinesArgs !== true;
229
+ function acceptsArguments(step) {
230
+ return step.kind === 'opaque' || step.shouldDeclineArguments !== true;
199
231
  }
200
232
  function bindPassthrough(steps, passthrough, command) {
201
233
  if (passthrough.length === 0) {
202
234
  return { ok: true, steps };
203
235
  }
204
- if (!steps.some(acceptsArgs)) {
205
- return { ok: false, error: formatUnroutableArgsError(command) };
236
+ if (!steps.some(acceptsArguments)) {
237
+ return { ok: false, error: formatUnroutableArgumentsError(command) };
206
238
  }
207
239
  const boundSteps = steps.map((step) => {
208
- if (!acceptsArgs(step)) {
240
+ if (!acceptsArguments(step)) {
209
241
  return step;
210
242
  }
211
243
  return step.kind === 'structural'
@@ -214,7 +246,7 @@ function bindPassthrough(steps, passthrough, command) {
214
246
  });
215
247
  return { ok: true, steps: boundSteps };
216
248
  }
217
- function formatUnroutableArgsError(command) {
249
+ function formatUnroutableArgumentsError(command) {
218
250
  return (`\`${command}\` takes no trailing arguments: every step of its chain declines them. ` +
219
251
  'Running it unnarrowed is not what the arguments asked for, so nothing ran.');
220
252
  }
@@ -309,15 +341,16 @@ function escapeControlCharacters(text) {
309
341
  }
310
342
  function formatNmrCrossingWarning(options) {
311
343
  const { remedy, subject } = describeCrossingRemedy(options);
312
- return (`⚠️ ${subject} reaches nmr through a shell (\`${escapeControlCharacters(options.crossing)}\`), ` +
313
- `${CROSSING_CONSEQUENCE} ${remedy}`);
344
+ return (`${STATUS_GLYPHS[options.style].warning.text} ${subject} reaches nmr through a shell ` +
345
+ `(\`${escapeControlCharacters(options.crossing)}\`), ${CROSSING_CONSEQUENCE} ${remedy}`);
314
346
  }
315
- function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
347
+ function formatOverrideNotice(resolved, registry, command, anchorDir, quiet, style) {
316
348
  const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
317
349
  if (quiet || resolved.origin.tier !== 'package' || registryEntry === undefined) {
318
350
  return undefined;
319
351
  }
320
- return `📦 ${path.basename(anchorDir)}: Using override script: ${renderChain(resolved.steps)}\n`;
352
+ const notice = `${path.basename(anchorDir)}: Using override script: ${renderChain(resolved.steps)}`;
353
+ return `${formatGlyphLine(NMR_GLYPHS, style, 'package', notice)}\n`;
321
354
  }
322
355
  function formatSelfReferenceRemedy(options) {
323
356
  const { command, monorepoRoot, registry, script, workspaceRoot } = options;
@@ -346,6 +379,58 @@ function findArgError(parsed) {
346
379
  }
347
380
  return undefined;
348
381
  }
382
+ function findEmptySelectionRefusal(options) {
383
+ const { context, parsed, selection } = options;
384
+ if (parsed.filter !== undefined) {
385
+ if (selection !== 'empty') {
386
+ return undefined;
387
+ }
388
+ return formatEmptyFilterError(parsed.filter, readWorkspacePackageNames(context.workspacePackageDirs));
389
+ }
390
+ return context.workspacePackageDirs.length === 0 ? formatEmptyWorkspaceError() : undefined;
391
+ }
392
+ function formatEmptyFilterError(pattern, names) {
393
+ const rejection = `-F/--filter matched no workspace: \`${pattern}\`.`;
394
+ if (pattern.startsWith('!')) {
395
+ return `${rejection} A pattern beginning with \`!\` excludes what it matches, and this one leaves no package standing.`;
396
+ }
397
+ if (pattern.includes('[')) {
398
+ return `${rejection} A pattern carrying \`[<ref>]\` selects the packages changed since a git ref, and none has changed.`;
399
+ }
400
+ if (pattern.startsWith('.') || pattern.startsWith('/') || pattern.startsWith('{')) {
401
+ return (`${rejection} ` +
402
+ 'A pattern written `./dir`, `/dir`, or `{dir}` selects the packages under a directory, ' +
403
+ 'and no package sits under this one.');
404
+ }
405
+ return (`${rejection} A pattern matches a package's manifest \`name\`, ` +
406
+ `not its directory name.${suggestWorkspaceNames(pattern, names)}`);
407
+ }
408
+ function formatEmptyWorkspaceError() {
409
+ return ('-R/--recursive matched no workspace: pnpm-workspace.yaml declares no package directory, ' +
410
+ 'so there is no scope to fan out to.');
411
+ }
412
+ function suggestWorkspaceNames(pattern, names) {
413
+ if (names.length === 0) {
414
+ return '';
415
+ }
416
+ const containing = names.filter((name) => name.toLowerCase().includes(pattern.toLowerCase()));
417
+ if (containing.length > 0) {
418
+ return ` Did you mean ${renderNames(containing)}?`;
419
+ }
420
+ const closest = findClosestName(pattern, names);
421
+ if (closest !== undefined) {
422
+ return ` Did you mean \`${closest}\`?`;
423
+ }
424
+ return ` The workspace declares ${renderNames(names)}.`;
425
+ }
426
+ function renderNames(names) {
427
+ const shown = names
428
+ .slice(0, NAME_CEILING)
429
+ .map((name) => `\`${name}\``)
430
+ .join(', ');
431
+ const remainder = names.length - NAME_CEILING;
432
+ return remainder > 0 ? `${shown}, and ${remainder} more` : shown;
433
+ }
349
434
  function findNoOpReason(resolvedCommand) {
350
435
  if (resolvedCommand === '') {
351
436
  return 'empty-override';
@@ -408,12 +493,12 @@ async function lookUpRecordedPass(options) {
408
493
  };
409
494
  }
410
495
  function openGate(options) {
411
- const { command, config, env, monorepoRoot, passthrough, stderr } = options;
496
+ const { command, config, env, monorepoRoot, passthrough, stderr, style } = options;
412
497
  if (!isCacheableCommand(config.checkCache, command)) {
413
498
  return undefined;
414
499
  }
415
500
  if (passthrough.includes('--no-cache')) {
416
- stderr.write(`${formatMisplacedNoCacheWarning(command)}\n`);
501
+ stderr.write(`${formatMisplacedNoCacheWarning(command, style)}\n`);
417
502
  }
418
503
  if (passthrough.length > 0) {
419
504
  writeDebugNote(`gate disabled: ${command} was passed arguments`, env, stderr);
@@ -446,51 +531,26 @@ function parseArgs(args) {
446
531
  if (arg === '-F' || arg === '--filter') {
447
532
  i++;
448
533
  const filterValue = args[i];
449
- if (filterValue === undefined) {
534
+ if (!filterValue) {
450
535
  return { ok: false, error: '-F/--filter requires a pattern argument' };
451
536
  }
452
537
  parsed.filter = filterValue;
453
538
  i++;
454
539
  continue;
455
540
  }
456
- if (arg === '-R' || arg === '--recursive') {
457
- parsed.recursive = true;
458
- i++;
459
- continue;
460
- }
461
- if (arg === '-w' || arg === '--workspace-root') {
462
- parsed.workspaceRoot = true;
463
- i++;
464
- continue;
465
- }
466
- if (arg === '-?' || arg === '--help') {
467
- parsed.help = true;
541
+ const booleanFlag = BOOLEAN_FLAGS.get(arg);
542
+ if (booleanFlag !== undefined) {
543
+ parsed[booleanFlag] = true;
468
544
  i++;
469
545
  continue;
470
546
  }
471
- if (arg === '-V' || arg === '--version') {
472
- parsed.version = true;
473
- i++;
474
- continue;
475
- }
476
- if (arg === '-q' || arg === '--quiet') {
477
- parsed.quiet = true;
478
- i++;
479
- continue;
480
- }
481
- if (arg === '--json') {
482
- parsed.json = true;
483
- i++;
484
- continue;
485
- }
486
- if (arg === '--log') {
487
- parsed.log = true;
488
- i++;
489
- continue;
490
- }
491
- if (arg === '--no-cache') {
492
- parsed.noCache = true;
493
- i++;
547
+ const styleArgument = readOutputStyleArgument(args, i);
548
+ if (styleArgument !== undefined) {
549
+ if (!styleArgument.ok) {
550
+ return styleArgument;
551
+ }
552
+ parsed.outputStyle = styleArgument.value;
553
+ i += styleArgument.consumed;
494
554
  continue;
495
555
  }
496
556
  parsed.command = arg;
@@ -501,7 +561,7 @@ function parseArgs(args) {
501
561
  return error === undefined ? { ok: true, parsed } : { ok: false, error };
502
562
  }
503
563
  async function reportRecording(options) {
504
- const { command, config, scope } = options;
564
+ const { command, config, scope, styles } = options;
505
565
  const lookup = await resolveRecording({
506
566
  anchorDir: options.anchorDir,
507
567
  command,
@@ -516,13 +576,14 @@ async function reportRecording(options) {
516
576
  monorepoRoot: options.monorepoRoot,
517
577
  });
518
578
  if (!lookup.ok) {
519
- options.stderr.write(`${renderRefusal({ command, refusal: lookup.refusal, scope })}\n`);
579
+ options.stderr.write(`${renderRefusal({ command, refusal: lookup.refusal, scope, style: styles.stderr })}\n`);
520
580
  return { exitCode: options.env[RUN_IF_PRESENT_ENV_VAR] === '1' ? 0 : 1 };
521
581
  }
522
- options.stdout.write(renderRecording({ command, recording: lookup.recording, scope }));
582
+ options.stdout.write(renderRecording({ command, recording: lookup.recording, scope, style: styles.stdout }));
523
583
  return { exitCode: 0 };
524
584
  }
525
- function readPresentationEnv(env) {
585
+ function readPresentation(options) {
586
+ const { env } = options;
526
587
  const verbosity = readVerbosityEnv(env);
527
588
  if (!verbosity.ok) {
528
589
  return verbosity;
@@ -531,7 +592,23 @@ function readPresentationEnv(env) {
531
592
  if (!format.ok) {
532
593
  return format;
533
594
  }
534
- return { ok: true, format: format.format, verbosity: verbosity.verbosity };
595
+ const { invalid, styles } = resolveOutputStyles(options);
596
+ if (invalid !== undefined) {
597
+ return { ok: false, error: describeInvalidOutputStyle(invalid) };
598
+ }
599
+ return { ok: true, format: format.format, styles, verbosity: verbosity.verbosity };
600
+ }
601
+ function readOutputStyleArgument(args, index) {
602
+ const arg = args[index] ?? '';
603
+ const assignment = arg.startsWith(`${OUTPUT_STYLE_FLAG}=`) ? arg.slice(OUTPUT_STYLE_FLAG.length + 1) : undefined;
604
+ if (arg !== OUTPUT_STYLE_FLAG && assignment === undefined) {
605
+ return undefined;
606
+ }
607
+ const value = assignment ?? args[index + 1];
608
+ if (!value) {
609
+ return { ok: false, error: `${OUTPUT_STYLE_FLAG} requires a value argument: auto, plain, or rich` };
610
+ }
611
+ return { ok: true, value, consumed: assignment === undefined ? 2 : 1 };
535
612
  }
536
613
  function reportNmrCrossing(options) {
537
614
  const crossing = findNmrCrossing(options.resolved.steps);
@@ -543,15 +620,16 @@ function reportNmrCrossing(options) {
543
620
  monorepoRoot: options.monorepoRoot,
544
621
  origin: describeOrigin(options.resolved.origin, options.config, options.useRoot),
545
622
  registry: options.registry,
623
+ style: options.style,
546
624
  workspaceRoot: options.workspaceRoot,
547
625
  });
548
626
  options.stderr.write(`${warning}\n`);
549
627
  }
550
- function reportVerdict(verdict, stdout, format) {
628
+ function reportVerdict(verdict, stdout, format, style) {
551
629
  if (isHookName(verdict.command)) {
552
630
  return;
553
631
  }
554
- writeVerdict(verdict, stdout, format);
632
+ writeVerdict(verdict, stdout, format, style);
555
633
  }
556
634
  function resolveReportingVerbosity(options) {
557
635
  const { format, ...ladder } = options;
@@ -642,6 +720,18 @@ function resolveCacheKey(options) {
642
720
  }
643
721
  return result.key;
644
722
  }
723
+ async function runDelegation(options) {
724
+ const { context, delegation, parsed, runOptions, stderr } = options;
725
+ const selection = parsed.filter === undefined ? undefined : readFilterSelection(parsed.filter, context.monorepoRoot);
726
+ const refusal = findEmptySelectionRefusal({ context, parsed, selection });
727
+ if (refusal !== undefined) {
728
+ reportError(refusal, stderr);
729
+ return { exitCode: 1 };
730
+ }
731
+ const shouldWithholdInput = parsed.filter === undefined || selection === 'multiple';
732
+ const step = shouldWithholdInput ? { ...delegation.step, shouldWithholdInput } : delegation.step;
733
+ return runSteps([step], context.monorepoRoot, { ...runOptions, env: delegation.env });
734
+ }
645
735
  async function runGated(options) {
646
736
  const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
647
737
  const quiet = options.runOptions.quiet === true;
@@ -39,16 +39,16 @@ 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, useShell: true }, cwd, options);
42
+ return runSpawned({ args: [], file: command, input: 'inherit', useShell: true }, cwd, options);
43
43
  }
44
- async function runSpawned({ args, file, useShell }, cwd, options) {
44
+ async function runSpawned({ args, file, input, useShell }, 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
50
  shell: useShell,
51
- stdio: ['inherit', options.channels.stdout, options.channels.stderr],
51
+ stdio: [input, options.channels.stdout, options.channels.stderr],
52
52
  cwd,
53
53
  env,
54
54
  });
@@ -78,13 +78,14 @@ async function runSpawned({ args, file, 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, useShell: true }, cwd, {
81
+ return runSpawned({ args: [], file: step.command, input: 'inherit', useShell: 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
- return runSpawned({ args, file, useShell: false }, cwd, {
87
+ const input = step.shouldWithholdInput === true ? 'ignore' : 'inherit';
88
+ return runSpawned({ args, file, input, useShell: false }, cwd, {
88
89
  ...options,
89
90
  quiet: false,
90
91
  channels: { stderr: resolveInheritedChannel(stderr), stdout: resolveInheritedChannel(stdout) },
@@ -4,9 +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
+ *
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 where it holds and leaves the rendered chain unchanged.
10
13
  */
11
14
  export type Step = {
12
15
  kind: 'opaque';
@@ -14,7 +17,8 @@ export type Step = {
14
17
  } | {
15
18
  kind: 'structural';
16
19
  argv: readonly [string, ...(readonly string[])];
17
- declinesArgs?: boolean;
20
+ shouldDeclineArguments?: boolean;
21
+ shouldWithholdInput?: boolean;
18
22
  };
19
23
  /** What a structural step asks of the nmr process it spawns. */
20
24
  export interface NmrStepTarget {
@@ -35,10 +39,10 @@ export type SelfReference = 'chained' | 'sole';
35
39
  * The element tokenizes on whitespace, so it may carry nmr's own flags but cannot carry a space-bearing token.
36
40
  * `-w` is prepended as its own token, so the child selects the root registry on its own.
37
41
  *
38
- * `declinesArgs` is set only where it holds, so a step that takes the trailing arguments renders and compares
42
+ * `shouldDeclineArguments` is set only where it holds, so a step that takes the trailing arguments renders and compares
39
43
  * exactly as it did before any element declared anything.
40
44
  */
41
- export declare function composeNmrStep(element: string, workspaceRoot: boolean, declinesArgs?: boolean): Step;
45
+ export declare function composeNmrStep(element: string, workspaceRoot: boolean, shouldDeclineArguments?: boolean): Step;
42
46
  /**
43
47
  * Returns the text of the first opaque step that reaches nmr through a shell, or `undefined` when none does.
44
48
  *
package/dist/esm/steps.js CHANGED
@@ -11,9 +11,13 @@ 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) {
14
+ export function composeNmrStep(element, workspaceRoot, shouldDeclineArguments = false) {
15
15
  const flags = workspaceRoot ? ['-w'] : [];
16
- return { kind: 'structural', argv: ['nmr', ...flags, ...tokenize(element)], ...(declinesArgs && { declinesArgs }) };
16
+ return {
17
+ kind: 'structural',
18
+ argv: ['nmr', ...flags, ...tokenize(element)],
19
+ ...(shouldDeclineArguments && { shouldDeclineArguments }),
20
+ };
17
21
  }
18
22
  export function findNmrCrossing(steps) {
19
23
  for (const step of steps) {
@@ -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
  /**
@@ -54,15 +55,18 @@ export type VerdictOutcome = {
54
55
  * The line ends without terminal punctuation and reserves its tail for `detail`, so a later change appends to
55
56
  * the grammar rather than rewriting it. A detail carrying nothing but line breaks takes its whole clause with
56
57
  * it, rather than leaving a separator pointing at nothing.
58
+ *
59
+ * The marker takes no padding column: every plain marker a verdict reaches for is four characters, so the
60
+ * lines align by construction. `formatStatusLine` pads to a column that `BLOCK` widens, and no verdict is one.
57
61
  */
58
- export declare function renderVerdict(verdict: Verdict): string;
62
+ export declare function renderVerdict(verdict: Verdict, style: OutputStyle): string;
59
63
  /**
60
64
  * Renders a verdict as the JSON object a machine consumer reads, without the newline that terminates it.
61
65
  *
62
66
  * Held to the ceiling the prose line is held to, so one write still reaches a pipe whole where concurrent
63
67
  * scopes share a descriptor. Cuts land inside the record's text rather than across its structure, which is
64
68
  * what leaves the line parseable, and a record is fitted by rungs, each shedding what a reader can better
65
- * spare than the rung below it. What the ceiling costs is [documented](../README.md#reporting-for-a-machine)
69
+ * spare than the rung below it. What the ceiling costs is [documented](../docs/reporting.md#reporting-for-a-machine)
66
70
  * in the same order.
67
71
  */
68
72
  export declare function serializeVerdict(verdict: Verdict): string;
@@ -71,4 +75,4 @@ export declare function serializeVerdict(verdict: Verdict): string;
71
75
  * share one descriptor. Both renderings spend the one record, so neither can come to report what the other
72
76
  * does not.
73
77
  */
74
- export declare function writeVerdict(verdict: Verdict, stream: Writable, format: ReportFormat): void;
78
+ export declare function writeVerdict(verdict: Verdict, stream: Writable, format: ReportFormat, style: OutputStyle): void;
@@ -1,41 +1,47 @@
1
+ import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
2
+ import { NMR_GLYPHS } from "./glyphs.js";
1
3
  import { clampToBytes, TRUNCATION_MARK } from "./helpers/clampToBytes.js";
2
4
  import { formatDuration, formatSaving } from "./helpers/duration.js";
3
5
  export const VERDICT_LINE_LIMIT = 512;
4
- export function renderVerdict(verdict) {
5
- const { icon, phrase } = describeOutcome(verdict);
6
+ export function renderVerdict(verdict, style) {
7
+ const { marker, phrase } = describeOutcome(verdict, style);
6
8
  const detail = flattenDetail(verdict.detail ?? renderReplay(verdict) ?? '');
7
9
  const detailClause = detail === '' ? '' : ` — ${detail}`;
8
- return clampToBytes(`${icon} ${verdict.scope}: ${verdict.command}: ${phrase}${detailClause}`, LINE_BUDGET_BYTES);
10
+ return clampToBytes(`${marker} ${verdict.scope}: ${verdict.command}: ${phrase}${detailClause}`, LINE_BUDGET_BYTES);
9
11
  }
10
12
  export function serializeVerdict(verdict) {
11
13
  const rendered = JSON.stringify(verdict);
12
14
  return isWithinBudget(rendered) ? rendered : renderWithinBudget(verdict);
13
15
  }
14
- export function writeVerdict(verdict, stream, format) {
15
- const line = format === 'json' ? serializeVerdict(verdict) : renderVerdict(verdict);
16
+ export function writeVerdict(verdict, stream, format, style) {
17
+ const line = format === 'json' ? serializeVerdict(verdict) : renderVerdict(verdict, style);
16
18
  stream.write(`${line}\n`);
17
19
  }
18
20
  const NEWLINE_BYTES = 1;
19
21
  const LINE_BUDGET_BYTES = VERDICT_LINE_LIMIT - NEWLINE_BYTES;
20
22
  const MIN_CUT_BYTES = Buffer.byteLength(TRUNCATION_MARK);
21
23
  const REPLAY_MARKER = 'replayed:';
22
- function describeOutcome(verdict) {
24
+ function describeOutcome(verdict, style) {
25
+ const statuses = STATUS_GLYPHS[style];
23
26
  switch (verdict.outcome) {
24
27
  case 'passed':
25
- return { icon: '✅', phrase: `passed in ${formatDuration(verdict.durationMs)}` };
28
+ return { marker: statuses.passed.text, phrase: `passed in ${formatDuration(verdict.durationMs)}` };
26
29
  case 'failed':
27
30
  return {
28
- icon: '❌',
31
+ marker: statuses.failed.text,
29
32
  phrase: `failed in ${formatDuration(verdict.durationMs)} (exit ${verdict.exitCode})`,
30
33
  };
31
34
  case 'recalled': {
32
35
  const saving = formatSaving(verdict.savedMs);
33
36
  const savingClause = saving === undefined ? '' : `, ${saving}`;
34
- return { icon: '⏭️', phrase: `passed ${formatDuration(verdict.ageMs)} ago on this tree${savingClause}` };
37
+ return {
38
+ marker: statuses.skipped.text,
39
+ phrase: `passed ${formatDuration(verdict.ageMs)} ago on this tree${savingClause}`,
40
+ };
35
41
  }
36
42
  case 'no-op':
37
43
  return {
38
- icon: '⛔',
44
+ marker: NMR_GLYPHS[style].noop.text,
39
45
  phrase: `skipped, the override is ${verdict.reason === 'empty-override' ? 'empty' : 'a no-op'}`,
40
46
  };
41
47
  default: {
@@ -0,0 +1,37 @@
1
+ import type { Plugin } from 'vite';
2
+ /**
3
+ * Builds the plugin that resolves a package's `source` export condition, for a package whose files sit outside
4
+ * every `node_modules`.
5
+ *
6
+ * The condition cannot be emitted as a `resolve.conditions` entry instead: Vitest turns the server environment's
7
+ * conditions into `--conditions` flags on the worker process, where Node applies them to every package it
8
+ * resolves natively. Node refuses to strip types from a file under `node_modules`, so a dependency declaring a
9
+ * TypeScript `source` entry fails to load as soon as the condition reaches that far.
10
+ *
11
+ * Resolving here instead keeps the condition inside Vite, which transforms what it resolves. A package under
12
+ * `node_modules` resolves through its remaining conditions, whatever its `exports` map declares.
13
+ */
14
+ export declare function createSourceResolutionPlugin(): Plugin;
15
+ /** What a resolution needs beyond the specifier and its importer: the resolving environment, and the caches. */
16
+ export interface SourceResolutionContext {
17
+ /** The Vite environment resolving the specifier, which decides the environment-specific condition. */
18
+ environmentName: string;
19
+ /** Parsed manifests, keyed by package directory. Absent from a caller that resolves once. */
20
+ manifests?: Map<string, unknown>;
21
+ /** Package directories, keyed by specifier and importer directory. Absent from a caller that resolves once. */
22
+ packageDirs?: Map<string, string | undefined>;
23
+ }
24
+ /**
25
+ * Resolves a bare specifier to the file that its package's `source` condition names, or nothing where the condition
26
+ * does not reach it.
27
+ *
28
+ * Answers a bare specifier naming a package whose real directory sits outside every `node_modules`. Every other
29
+ * specifier returns nothing -- such as a relative or absolute path, a virtual module, a builtin, or a package under
30
+ * `node_modules` -- so Vite resolves the specifier through its own conditions. Throws where the condition reaches no
31
+ * file, because falling through to the build output is the staleness that resolving from source exists to prevent,
32
+ * and nothing in a run reports it.
33
+ *
34
+ * A query or hash suffix is split off before the `exports` lookup and re-appended to the resolved path, as Vite
35
+ * does, so `pkg/icon.svg?raw` matches the `./icon.svg` entry and still reaches the plugin that reads the suffix.
36
+ */
37
+ export declare function resolveSourceTarget(specifier: string, importer: string | undefined, context: SourceResolutionContext): string | undefined;