@williamthorsen/nmr 0.37.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 (45) hide show
  1. package/.meta/changelog.json +30 -0
  2. package/.readyup/kits/default.js +9 -9
  3. package/.readyup/manifest.json +5 -5
  4. package/CHANGELOG.md +19 -0
  5. package/README.md +17 -34
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache.d.ts +2 -1
  8. package/dist/esm/check-cache.js +4 -4
  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 +3 -1
  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.d.ts +6 -1
  15. package/dist/esm/commands/build.js +9 -10
  16. package/dist/esm/commands/clean.d.ts +3 -2
  17. package/dist/esm/commands/clean.js +15 -14
  18. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +3 -2
  19. package/dist/esm/commands/ensure-prepublish-hooks.js +8 -6
  20. package/dist/esm/commands/report-catalog.d.ts +2 -1
  21. package/dist/esm/commands/report-catalog.js +5 -4
  22. package/dist/esm/commands/report-overrides.d.ts +2 -1
  23. package/dist/esm/commands/report-overrides.js +5 -4
  24. package/dist/esm/config.js +14 -4
  25. package/dist/esm/default-scripts.d.ts +2 -2
  26. package/dist/esm/default-scripts.js +5 -5
  27. package/dist/esm/glyphs.d.ts +9 -0
  28. package/dist/esm/glyphs.js +9 -0
  29. package/dist/esm/help.js +15 -9
  30. package/dist/esm/output-style.d.ts +41 -0
  31. package/dist/esm/output-style.js +17 -0
  32. package/dist/esm/recording.d.ts +3 -0
  33. package/dist/esm/recording.js +6 -3
  34. package/dist/esm/resolveBinStyles.d.ts +12 -0
  35. package/dist/esm/resolveBinStyles.js +16 -0
  36. package/dist/esm/resolver.js +2 -2
  37. package/dist/esm/runCli.js +82 -60
  38. package/dist/esm/steps.d.ts +5 -5
  39. package/dist/esm/steps.js +6 -2
  40. package/dist/esm/verdict.d.ts +6 -2
  41. package/dist/esm/verdict.js +16 -10
  42. package/docs/check-cache.md +5 -5
  43. package/docs/reporting.md +14 -0
  44. package/docs/scripts.md +2 -2
  45. package/package.json +8 -8
@@ -1,8 +1,9 @@
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";
@@ -11,6 +12,7 @@ import { findClosestName } from "./helpers/findClosestName.js";
11
12
  import { isHookName } from "./helpers/hook-name.js";
12
13
  import { resolvePackageJsonPath } from "./helpers/package-json.js";
13
14
  import { composeTranscript } from "./helpers/transcript.js";
15
+ import { isTerminalStream, OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG, resolveOutputStyles } from "./output-style.js";
14
16
  import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
15
17
  import { assembleReplay } from "./replay-assembly.js";
16
18
  import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
@@ -38,11 +40,17 @@ export async function runCli(options) {
38
40
  return { exitCode: 1 };
39
41
  }
40
42
  const { parsed } = parseResult;
41
- const inherited = readPresentationEnv(env);
43
+ const inherited = readPresentation({
44
+ env,
45
+ flagValue: parsed.outputStyle,
46
+ stderrIsTty: isTerminalStream(stderr),
47
+ stdoutIsTty: isTerminalStream(stdout),
48
+ });
42
49
  if (!inherited.ok) {
43
50
  reportError(inherited.error, stderr);
44
51
  return { exitCode: 1 };
45
52
  }
53
+ const { styles } = inherited;
46
54
  if (parsed.version) {
47
55
  stdout.write(`${VERSION}\n`);
48
56
  return { exitCode: 0 };
@@ -72,6 +80,7 @@ export async function runCli(options) {
72
80
  monorepoRoot: context.monorepoRoot,
73
81
  passthrough: parsed.passthrough,
74
82
  stderr,
83
+ style: styles.stderr,
75
84
  });
76
85
  const noCache = parsed.noCache || env[NO_CACHE_ENV_VAR] === '1';
77
86
  const runId = resolveRunId(env);
@@ -82,6 +91,7 @@ export async function runCli(options) {
82
91
  passthrough: parsed.passthrough,
83
92
  runId,
84
93
  snapshot,
94
+ styles,
85
95
  verbosity,
86
96
  });
87
97
  const runOptions = {
@@ -114,7 +124,7 @@ export async function runCli(options) {
114
124
  const resolvedCommand = renderChain(resolved.steps);
115
125
  const noOpReason = findNoOpReason(resolvedCommand);
116
126
  if (noOpReason !== undefined && !parsed.log) {
117
- reportVerdict({ command, scope, outcome: 'no-op', reason: noOpReason }, stdout, format);
127
+ reportVerdict({ command, scope, outcome: 'no-op', reason: noOpReason }, stdout, format, styles.stdout);
118
128
  return { exitCode: 0 };
119
129
  }
120
130
  const substitutedSteps = applyDevBinToSteps(resolved.steps, context.config.devBin, context.monorepoRoot);
@@ -137,6 +147,7 @@ export async function runCli(options) {
137
147
  registry,
138
148
  resolved,
139
149
  stderr,
150
+ style: styles.stderr,
140
151
  useRoot,
141
152
  workspaceRoot: parsed.workspaceRoot,
142
153
  });
@@ -163,6 +174,7 @@ export async function runCli(options) {
163
174
  snapshot,
164
175
  stderr,
165
176
  stdout,
177
+ styles,
166
178
  });
167
179
  }
168
180
  const { exitCode, outcome } = await runGated({
@@ -175,7 +187,7 @@ export async function runCli(options) {
175
187
  key,
176
188
  monorepoRoot: context.monorepoRoot,
177
189
  noCache,
178
- overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, quiet),
190
+ overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, quiet, styles.stdout),
179
191
  ownSteps: mainSteps,
180
192
  runId,
181
193
  runOptions,
@@ -183,33 +195,49 @@ export async function runCli(options) {
183
195
  stderr,
184
196
  stdout,
185
197
  });
186
- reportVerdict({ command, scope, ...outcome }, stdout, format);
198
+ reportVerdict({ command, scope, ...outcome }, stdout, format, styles.stdout);
187
199
  return { exitCode };
188
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
+ ]);
189
216
  function buildChildEnv(options) {
190
- const { env, format, noCache, passthrough, runId, snapshot, verbosity } = options;
217
+ const { env, format, noCache, passthrough, runId, snapshot, styles, verbosity } = options;
191
218
  const bypassesCache = noCache || passthrough.length > 0;
192
219
  return {
193
220
  ...env,
194
221
  ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
195
222
  ...(bypassesCache && { [NO_CACHE_ENV_VAR]: '1' }),
196
223
  [COMMAND_VERBOSITY_ENV_VAR]: verbosity,
224
+ [OUTPUT_STYLE_ENV_VAR]: styles.stdout,
197
225
  [REPORT_FORMAT_ENV_VAR]: format,
198
226
  [RUN_ID_ENV_VAR]: runId,
199
227
  };
200
228
  }
201
- function acceptsArgs(step) {
202
- return step.kind === 'opaque' || step.declinesArgs !== true;
229
+ function acceptsArguments(step) {
230
+ return step.kind === 'opaque' || step.shouldDeclineArguments !== true;
203
231
  }
204
232
  function bindPassthrough(steps, passthrough, command) {
205
233
  if (passthrough.length === 0) {
206
234
  return { ok: true, steps };
207
235
  }
208
- if (!steps.some(acceptsArgs)) {
209
- return { ok: false, error: formatUnroutableArgsError(command) };
236
+ if (!steps.some(acceptsArguments)) {
237
+ return { ok: false, error: formatUnroutableArgumentsError(command) };
210
238
  }
211
239
  const boundSteps = steps.map((step) => {
212
- if (!acceptsArgs(step)) {
240
+ if (!acceptsArguments(step)) {
213
241
  return step;
214
242
  }
215
243
  return step.kind === 'structural'
@@ -218,7 +246,7 @@ function bindPassthrough(steps, passthrough, command) {
218
246
  });
219
247
  return { ok: true, steps: boundSteps };
220
248
  }
221
- function formatUnroutableArgsError(command) {
249
+ function formatUnroutableArgumentsError(command) {
222
250
  return (`\`${command}\` takes no trailing arguments: every step of its chain declines them. ` +
223
251
  'Running it unnarrowed is not what the arguments asked for, so nothing ran.');
224
252
  }
@@ -313,15 +341,16 @@ function escapeControlCharacters(text) {
313
341
  }
314
342
  function formatNmrCrossingWarning(options) {
315
343
  const { remedy, subject } = describeCrossingRemedy(options);
316
- return (`⚠️ ${subject} reaches nmr through a shell (\`${escapeControlCharacters(options.crossing)}\`), ` +
317
- `${CROSSING_CONSEQUENCE} ${remedy}`);
344
+ return (`${STATUS_GLYPHS[options.style].warning.text} ${subject} reaches nmr through a shell ` +
345
+ `(\`${escapeControlCharacters(options.crossing)}\`), ${CROSSING_CONSEQUENCE} ${remedy}`);
318
346
  }
319
- function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
347
+ function formatOverrideNotice(resolved, registry, command, anchorDir, quiet, style) {
320
348
  const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
321
349
  if (quiet || resolved.origin.tier !== 'package' || registryEntry === undefined) {
322
350
  return undefined;
323
351
  }
324
- 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`;
325
354
  }
326
355
  function formatSelfReferenceRemedy(options) {
327
356
  const { command, monorepoRoot, registry, script, workspaceRoot } = options;
@@ -464,12 +493,12 @@ async function lookUpRecordedPass(options) {
464
493
  };
465
494
  }
466
495
  function openGate(options) {
467
- const { command, config, env, monorepoRoot, passthrough, stderr } = options;
496
+ const { command, config, env, monorepoRoot, passthrough, stderr, style } = options;
468
497
  if (!isCacheableCommand(config.checkCache, command)) {
469
498
  return undefined;
470
499
  }
471
500
  if (passthrough.includes('--no-cache')) {
472
- stderr.write(`${formatMisplacedNoCacheWarning(command)}\n`);
501
+ stderr.write(`${formatMisplacedNoCacheWarning(command, style)}\n`);
473
502
  }
474
503
  if (passthrough.length > 0) {
475
504
  writeDebugNote(`gate disabled: ${command} was passed arguments`, env, stderr);
@@ -509,44 +538,19 @@ function parseArgs(args) {
509
538
  i++;
510
539
  continue;
511
540
  }
512
- if (arg === '-R' || arg === '--recursive') {
513
- parsed.recursive = true;
514
- i++;
515
- continue;
516
- }
517
- if (arg === '-w' || arg === '--workspace-root') {
518
- parsed.workspaceRoot = true;
519
- i++;
520
- continue;
521
- }
522
- if (arg === '-?' || arg === '--help') {
523
- parsed.help = true;
524
- i++;
525
- continue;
526
- }
527
- if (arg === '-V' || arg === '--version') {
528
- parsed.version = true;
529
- i++;
530
- continue;
531
- }
532
- if (arg === '-q' || arg === '--quiet') {
533
- parsed.quiet = true;
534
- i++;
535
- continue;
536
- }
537
- if (arg === '--json') {
538
- parsed.json = true;
539
- i++;
540
- continue;
541
- }
542
- if (arg === '--log') {
543
- parsed.log = true;
541
+ const booleanFlag = BOOLEAN_FLAGS.get(arg);
542
+ if (booleanFlag !== undefined) {
543
+ parsed[booleanFlag] = true;
544
544
  i++;
545
545
  continue;
546
546
  }
547
- if (arg === '--no-cache') {
548
- parsed.noCache = true;
549
- 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;
550
554
  continue;
551
555
  }
552
556
  parsed.command = arg;
@@ -557,7 +561,7 @@ function parseArgs(args) {
557
561
  return error === undefined ? { ok: true, parsed } : { ok: false, error };
558
562
  }
559
563
  async function reportRecording(options) {
560
- const { command, config, scope } = options;
564
+ const { command, config, scope, styles } = options;
561
565
  const lookup = await resolveRecording({
562
566
  anchorDir: options.anchorDir,
563
567
  command,
@@ -572,13 +576,14 @@ async function reportRecording(options) {
572
576
  monorepoRoot: options.monorepoRoot,
573
577
  });
574
578
  if (!lookup.ok) {
575
- options.stderr.write(`${renderRefusal({ command, refusal: lookup.refusal, scope })}\n`);
579
+ options.stderr.write(`${renderRefusal({ command, refusal: lookup.refusal, scope, style: styles.stderr })}\n`);
576
580
  return { exitCode: options.env[RUN_IF_PRESENT_ENV_VAR] === '1' ? 0 : 1 };
577
581
  }
578
- options.stdout.write(renderRecording({ command, recording: lookup.recording, scope }));
582
+ options.stdout.write(renderRecording({ command, recording: lookup.recording, scope, style: styles.stdout }));
579
583
  return { exitCode: 0 };
580
584
  }
581
- function readPresentationEnv(env) {
585
+ function readPresentation(options) {
586
+ const { env } = options;
582
587
  const verbosity = readVerbosityEnv(env);
583
588
  if (!verbosity.ok) {
584
589
  return verbosity;
@@ -587,7 +592,23 @@ function readPresentationEnv(env) {
587
592
  if (!format.ok) {
588
593
  return format;
589
594
  }
590
- 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 };
591
612
  }
592
613
  function reportNmrCrossing(options) {
593
614
  const crossing = findNmrCrossing(options.resolved.steps);
@@ -599,15 +620,16 @@ function reportNmrCrossing(options) {
599
620
  monorepoRoot: options.monorepoRoot,
600
621
  origin: describeOrigin(options.resolved.origin, options.config, options.useRoot),
601
622
  registry: options.registry,
623
+ style: options.style,
602
624
  workspaceRoot: options.workspaceRoot,
603
625
  });
604
626
  options.stderr.write(`${warning}\n`);
605
627
  }
606
- function reportVerdict(verdict, stdout, format) {
628
+ function reportVerdict(verdict, stdout, format, style) {
607
629
  if (isHookName(verdict.command)) {
608
630
  return;
609
631
  }
610
- writeVerdict(verdict, stdout, format);
632
+ writeVerdict(verdict, stdout, format, style);
611
633
  }
612
634
  function resolveReportingVerbosity(options) {
613
635
  const { format, ...ladder } = options;
@@ -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. */
@@ -39,10 +39,10 @@ export type SelfReference = 'chained' | 'sole';
39
39
  * The element tokenizes on whitespace, so it may carry nmr's own flags but cannot carry a space-bearing token.
40
40
  * `-w` is prepended as its own token, so the child selects the root registry on its own.
41
41
  *
42
- * `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
43
43
  * exactly as it did before any element declared anything.
44
44
  */
45
- export declare function composeNmrStep(element: string, workspaceRoot: boolean, declinesArgs?: boolean): Step;
45
+ export declare function composeNmrStep(element: string, workspaceRoot: boolean, shouldDeclineArguments?: boolean): Step;
46
46
  /**
47
47
  * Returns the text of the first opaque step that reaches nmr through a shell, or `undefined` when none does.
48
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,8 +55,11 @@ 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
  *
@@ -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: {
@@ -77,7 +77,7 @@ $ nmr test
77
77
  # ... the suite runs ...
78
78
 
79
79
  $ nmr test
80
- ⏭️ nmr: test: passed 2m ago on this tree, saved ~12s — replayed: Test Files 6 passed (6) Tests 41 passed (41)
80
+ ⏩ nmr: test: passed 2m ago on this tree, saved ~12s — replayed: Test Files 6 passed (6) Tests 41 passed (41)
81
81
  ```
82
82
 
83
83
  The excerpt is the last blank-line-delimited block of what the command wrote, which is the closing statement a tool separates from its progress output. It is flattened onto one line, with escape sequences stripped, table rules dropped, and a redrawn progress line reduced to what a reader was left looking at. Nothing is parsed and no figure is computed: nmr replays bytes it recorded. A block wider than a few kilobytes is cut and marked with `…`, so a command whose closing statement is one long line cannot carry its whole output into the cache.
@@ -90,7 +90,7 @@ The excerpt and the whole transcript it is drawn from are written by the same op
90
90
 
91
91
  ```console
92
92
  $ nmr check
93
- ⏭️ nmr-core: check: passed 3m ago on this tree, saved ~48s — replayed: nmr-core: fmt:check: All matched files use Prettier code style!; nmr-core: test: Test Files 6 passed (6) Tests 41 passed (41)
93
+ ⏩ nmr-core: check: passed 3m ago on this tree, saved ~48s — replayed: nmr-core: fmt:check: All matched files use Prettier code style!; nmr-core: test: Test Files 6 passed (6) Tests 41 passed (41)
94
94
  ```
95
95
 
96
96
  The assembly is flat: a constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test` rather than one opaque `check:strict` line. A delegate expands into the scopes it fans out to, and a constituent that recorded no excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all, so `ci` replays what `check:strict` earned and nothing for `build`.
@@ -99,7 +99,7 @@ It is assembled when the composite records its pass rather than when it skips, s
99
99
 
100
100
  **A skip certifies what it replays.** Recalling a pass proves the excerpt describes this tree, and a matching retention key proves it describes this presentation environment, so a skip restamps the recalled entry with the run replaying it. A constituent already warm when a composite ran therefore still contributes its line: `nmr check` after `nmr test` replays the test summary. The restamp touches nothing else -- the instant and the duration belong to the run that earned the pass.
101
101
 
102
- **A replay is held to more than the pass is.** Retention carries its own key: the [pass key](#what-the-key-is-made-of) folded with the channel each output stream ran on and with `CI`, `COLUMNS`, `FORCE_COLOR`, `NO_COLOR`, and `TERM`. A recording made under a different one is recalled as a pass all the same; its excerpt is simply not replayed, and the verdict prints alone, as it does for a pass that retained nothing.
102
+ **A replay is held to more than the pass is.** Retention carries its own key: the [pass key](#what-the-key-is-made-of) folded with the channel each output stream ran on and with `CI`, `COLUMNS`, `FORCE_COLOR`, `NO_COLOR`, and `TERM`. `NMR_OUTPUT_STYLE` is in neither key, so a rich run and a plain one share a recorded pass and replay each other's excerpts as recorded. A recording made under a different one is recalled as a pass all the same; its excerpt is simply not replayed, and the verdict prints alone, as it does for a pass that retained nothing.
103
103
 
104
104
  The whole line, excerpt included, is held to the same [512-byte ceiling](reporting.md#what-nmr-reports) every verdict is, so one write still carries it whole under concurrent fan-out. An assembly wide enough to overrun it is cut and marked with `…` rather than spread over several lines.
105
105
 
@@ -109,7 +109,7 @@ The excerpt is a line; the run behind it is kept whole. `nmr --log <command>` pr
109
109
 
110
110
  ```console
111
111
  $ nmr --log test
112
- 📼 nmr: test — recorded 2026-08-12T15:04:05.412Z (12m ago), ran in 12.4s
112
+ 💾 nmr: test — recorded 2026-08-12T15:04:05.412Z (12m ago), ran in 12.4s
113
113
  $ pnpm exec vitest --project unit --project tool
114
114
 
115
115
  ✓ src/__tests__/resolver.unit.test.ts (18 tests) 12ms
@@ -130,7 +130,7 @@ The header is what presents the body as a recording rather than as this invocati
130
130
 
131
131
  ```console
132
132
  $ nmr --log test
133
- 📭 nmr: test: no recording; the last pass was 3m ago, on a tree this is not
133
+ 🟠 nmr: test: no recording; the last pass was 3m ago, on a tree this is not
134
134
  ```
135
135
 
136
136
  **`--log` reaches the scopes `-F` and `-R` select.** The flag rides the delegate, so each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: partial coverage is a survey's normal shape, and failing on the first gap would hide every scope that had something. A selection of no scope at all is the other case and fails, as it does for a run: there is no survey to be partial.
package/docs/reporting.md CHANGED
@@ -57,8 +57,22 @@ The format is nmr's own reporting and not a mode the commands it runs are told a
57
57
 
58
58
  `-q` sets it for the run and outranks an inherited value. Both values are spelled out so either direction is expressible: export `NMR_COMMAND_VERBOSITY=quiet` for a quiet shell, and set `full` on one invocation to opt back out.
59
59
 
60
+ `NMR_OUTPUT_STYLE` is nmr's own as well, and carries the style every marker nmr prints is rendered in; see [output style](#output-style) below.
61
+
60
62
  `NMR_REPORT_FORMAT` is nmr's own as well, and carries how nmr renders its own verdicts. Its values are `text` and `json`; any other non-empty value is reported 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 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. Two sources, then, where a verbosity has four: no repo-level default and no harness detection, since detection would hand every agent JSON in place of the prose the [shipped guidance](../README.md#agent-guidance) teaches. See [reporting for a machine](#reporting-for-a-machine) for what the objects carry.
61
63
 
64
+ ### Output style
65
+
66
+ `--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 that exits with code 1, whichever source named it. The flag outranks the variable, and both name `auto` as well as a style, so a shell exporting one style is overridable on one invocation in either direction.
67
+
68
+ Detection decides stdout and stderr separately. A stream is plain when `CI` is set to anything other than an empty string or `false`, when the stream is not a terminal, or when `TERM` is `linux`; otherwise it is rich. `--json` output carries no marker in either style.
69
+
70
+ **The flag belongs to `nmr`.** A standalone bin -- `nmr-clean`, `nmr-compile`, `nmr-report-catalog`, `nmr-report-overrides`, `nmr-ensure-prepublish-hooks` -- takes the variable alone, and passing it the flag gets that bin's unknown-option error. `nmr <command>` exports the style it resolved, so a bin run through nmr renders as nmr does.
71
+
72
+ **A nested run follows the one that spawned it.** The resolved style, never `auto`, travels down as `NMR_OUTPUT_STYLE`, so a child whose own stdout is a pipe still renders rich where the parent resolved rich. The style exported is the one stdout resolved to, which is where the verdicts and nearly every other line go; a parent at a terminal whose stderr alone is redirected therefore hands its children `rich`.
73
+
74
+ The style is out of the [pass key](check-cache.md#what-the-key-is-made-of) and the [retention key](check-cache.md#replaying-a-skipped-runs-output) alike, as the verbosity is: it changes how a run renders and never what a command concludes. Selecting it neither invalidates a recorded pass nor costs a replay, and an excerpt recorded rich replays as recorded in a plain run.
75
+
62
76
  ### Where a verbosity comes from
63
77
 
64
78
  Four sources, in precedence order. Each is read once, at the top of a chain: the resolved value travels down as `NMR_COMMAND_VERBOSITY`, so a nested process reads a decision already made rather than making its own.
package/docs/scripts.md CHANGED
@@ -40,11 +40,11 @@ Each array element is a command name, optionally preceded by nmr's own flags, sp
40
40
  Trailing arguments reach 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:
41
41
 
42
42
  ```ts
43
- // "ci": [{ run: "build", declinesArgs: true }, "check:strict"]
43
+ // "ci": [{ run: "build", shouldDeclineArguments: true }, "check:strict"]
44
44
  // nmr ci src/foo.ts -> nmr build && nmr check:strict src/foo.ts
45
45
  ```
46
46
 
47
- `{ run }` is the bare string element with room for the declaration; `declinesArgs` defaults to `false`, and an element that accepts is written as the string. Decline where the step is a prerequisite the narrowed steps run against, or where the tool it reaches would be misled — `tsgo --noEmit foo.ts` abandons the tsconfig, which is why every default `typecheck` step declines. A leaf tool that merely ignores what it is handed is not a reason to decline: nmr forwards, and the tool decides.
47
+ `{ run }` is the bare string element with room for the declaration; `shouldDeclineArguments` defaults to `false`, and an element that accepts is written as the string. Decline where the step is a prerequisite the narrowed steps run against, or where the tool it reaches would be misled — `tsgo --noEmit foo.ts` abandons the tsconfig, which is why every default `typecheck` step declines. A leaf tool that merely ignores what it is handed is not a reason to decline: nmr forwards, and the tool decides.
48
48
 
49
49
  Where no step of a command accepts them, nmr rejects the invocation and runs nothing, rather than running the whole command unnarrowed. That is what `nmr typecheck src/foo.ts` gets at the root, whose two steps both decline.
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -77,22 +77,22 @@
77
77
  "!docs/*.v*.md"
78
78
  ],
79
79
  "dependencies": {
80
- "@williamthorsen/nmr-core": "0.11.1",
81
- "@williamthorsen/toolbelt.errors": "0.7.0",
80
+ "@williamthorsen/nmr-core": "0.12.0",
81
+ "@williamthorsen/toolbelt.errors": "0.8.1",
82
82
  "glob": "13.0.6",
83
83
  "prettier-plugin-sh": "0.19.0",
84
84
  "taze": "21.1.0",
85
85
  "yaml": "2.9.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@williamthorsen/toolbelt.filesystem": "0.8.3",
89
- "@williamthorsen/toolbelt.testing": "0.5.1",
90
- "@williamthorsen/toolbelt.vitest": "0.9.0",
88
+ "@williamthorsen/toolbelt.filesystem": "0.9.1",
89
+ "@williamthorsen/toolbelt.testing": "0.6.1",
90
+ "@williamthorsen/toolbelt.vitest": "0.10.1",
91
91
  "prettier": "3.9.6",
92
- "readyup": "0.36.0",
92
+ "readyup": "0.38.0",
93
93
  "tinyglobby": "0.2.17",
94
94
  "typescript": "6.0.3",
95
- "vite": "8.2.2",
95
+ "vite": "8.3.0",
96
96
  "vitest": "4.1.11"
97
97
  },
98
98
  "peerDependencies": {