@williamthorsen/nmr 0.46.2 → 0.48.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.
@@ -14,9 +14,12 @@ import { readFilterSelection } from "./helpers/filter-selection.js";
14
14
  import { findClosestName } from "./helpers/findClosestName.js";
15
15
  import { isHookName } from "./helpers/hook-name.js";
16
16
  import { resolvePackageJsonPath } from "./helpers/package-json.js";
17
+ import { quoteForShell } from "./helpers/quoteForShell.js";
17
18
  import { composeTranscript } from "./helpers/transcript.js";
19
+ import { acquireMachineLock, formatMisplacedNoLockWarning, HEAVY_COMMANDS, LOCK_TOKEN_ENV_VAR, NO_LOCK_ENV_VAR, } from "./machine-lock.js";
18
20
  import { isTerminalStream, OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG, resolveOutputStyles } from "./output-style.js";
19
21
  import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
22
+ import { createSnapshot, DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATION_FAILURE_EXIT_CODE, formatMisplacedDelegationWarning, NO_DELEGATE_FLAG, resolveDelegationTarget, runRemotely, } from "./remote-run.js";
20
23
  import { assembleReplay } from "./replay-assembly.js";
21
24
  import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
22
25
  import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, findChainedSelfReference, readPackageJsonScripts, resolveScript, } from "./resolver.js";
@@ -37,6 +40,10 @@ export async function runCli(options) {
37
40
  return { exitCode: established.result.exitCode };
38
41
  }
39
42
  const invocation = established.value;
43
+ const delegated = await runOnDelegateHost(invocation);
44
+ if (delegated !== undefined) {
45
+ return delegated;
46
+ }
40
47
  const planned = await planRun(invocation);
41
48
  const { exitCode, isUnknownCommand = false } = planned.settled ? planned.result : await executePlan(planned.value);
42
49
  if (exitCode !== 0 && !isUnknownCommand && isTopLevelInvocation(options.env)) {
@@ -91,6 +98,7 @@ async function establishInvocation(options) {
91
98
  anchorDir,
92
99
  command: parsedArgs.command,
93
100
  context,
101
+ cwd,
94
102
  env,
95
103
  format,
96
104
  parsedArgs,
@@ -121,6 +129,7 @@ async function planRun(invocation) {
121
129
  env,
122
130
  format: invocation.format,
123
131
  shouldBypassCache,
132
+ shouldBypassLock: parsedArgs.shouldBypassLock,
124
133
  passthrough: parsedArgs.passthrough,
125
134
  runId,
126
135
  snapshot,
@@ -135,7 +144,15 @@ async function planRun(invocation) {
135
144
  };
136
145
  const delegation = composeDelegation({ childEnv, command, parsedArgs });
137
146
  if (delegation !== undefined) {
138
- const result = await runDelegation({ context, delegation, parsedArgs, runOptions, stderr });
147
+ const lock = await takeMachineLock(invocation);
148
+ let result;
149
+ try {
150
+ const heldDelegation = { ...delegation, env: addLockEnv(delegation.env, lock) };
151
+ result = await runDelegation({ context, delegation: heldDelegation, parsedArgs, runOptions, stderr });
152
+ }
153
+ finally {
154
+ await lock?.release();
155
+ }
139
156
  return {
140
157
  settled: true,
141
158
  result: { ...result, isUnknownCommand: result.exitCode !== 0 && !isDefinedInAnyScope(command, context) },
@@ -243,6 +260,77 @@ async function executePlan(plan) {
243
260
  reportVerdict({ command, scope, ...outcome }, stdout, format, styles.stdout);
244
261
  return { exitCode };
245
262
  }
263
+ async function runOnDelegateHost(invocation) {
264
+ const { command, context, cwd, env, format, parsedArgs, scope, stderr, stdout, styles } = invocation;
265
+ if (parsedArgs.passthrough.some(isDelegationFlag)) {
266
+ stderr.write(`${formatMisplacedDelegationWarning(command, styles.stderr)}
267
+ `);
268
+ }
269
+ const target = resolveDelegationTarget({
270
+ command,
271
+ env,
272
+ flagHost: parsedArgs.delegateHost,
273
+ isOptedOut: parsedArgs.shouldRunLocally,
274
+ isReading: parsedArgs.shouldPrintLog,
275
+ });
276
+ if (target.kind === 'local') {
277
+ return undefined;
278
+ }
279
+ if (target.kind === 'refused') {
280
+ reportError(target.error, stderr);
281
+ return { exitCode: 1 };
282
+ }
283
+ const { host } = target;
284
+ const startedAtMs = Date.now();
285
+ const snapshot = createSnapshot(cwd, env);
286
+ if (!snapshot.ok && snapshot.isUnsupported) {
287
+ if (target.source === 'flag') {
288
+ reportError(`Cannot delegate to ${host}: ${snapshot.reason}`, stderr);
289
+ return { exitCode: 1 };
290
+ }
291
+ stderr.write(`nmr: running ${command} here rather than on ${host}: ${snapshot.reason}\n`);
292
+ return undefined;
293
+ }
294
+ const result = snapshot.ok
295
+ ? await runRemotely({
296
+ cwd,
297
+ env,
298
+ host,
299
+ monorepoRoot: context.monorepoRoot,
300
+ nmrArgs: composeForwardedArgs(command, parsedArgs),
301
+ remoteEnv: {
302
+ [COMMAND_VERBOSITY_ENV_VAR]: invocation.verbosity,
303
+ [OUTPUT_STYLE_ENV_VAR]: styles.stdout,
304
+ [REPORT_FORMAT_ENV_VAR]: format,
305
+ },
306
+ snapshot,
307
+ stderr,
308
+ stdout,
309
+ })
310
+ : { ok: false, message: 'could not snapshot the working tree', output: snapshot.reason };
311
+ const durationMs = Date.now() - startedAtMs;
312
+ if (!result.ok) {
313
+ if (result.output.trim() !== '') {
314
+ stderr.write(result.output.endsWith('\n') ? result.output : `${result.output}\n`);
315
+ }
316
+ const verdict = {
317
+ command,
318
+ detail: result.message,
319
+ durationMs,
320
+ exitCode: DELEGATION_FAILURE_EXIT_CODE,
321
+ host,
322
+ outcome: 'failed',
323
+ scope,
324
+ };
325
+ reportVerdict(verdict, stdout, format, styles.stdout);
326
+ return { exitCode: DELEGATION_FAILURE_EXIT_CODE };
327
+ }
328
+ const outcome = result.exitCode === 0
329
+ ? { outcome: 'passed', durationMs }
330
+ : { outcome: 'failed', durationMs, exitCode: result.exitCode };
331
+ reportVerdict({ command, host, scope, ...outcome }, stdout, format, styles.stdout);
332
+ return { exitCode: result.exitCode };
333
+ }
246
334
  const FILTER_FLAGS = new Set(['-F', '--filter']);
247
335
  const BOOLEAN_FLAGS = new Map([
248
336
  ['-?', 'shouldShowHelp'],
@@ -250,6 +338,8 @@ const BOOLEAN_FLAGS = new Map([
250
338
  ['--json', 'shouldEmitJson'],
251
339
  ['--log', 'shouldPrintLog'],
252
340
  ['--no-cache', 'shouldBypassCache'],
341
+ [NO_DELEGATE_FLAG, 'shouldRunLocally'],
342
+ ['--no-lock', 'shouldBypassLock'],
253
343
  ['-q', 'quiet'],
254
344
  ['--quiet', 'quiet'],
255
345
  ['-R', 'recursive'],
@@ -260,18 +350,29 @@ const BOOLEAN_FLAGS = new Map([
260
350
  ['--workspace-root', 'isWorkspaceRoot'],
261
351
  ]);
262
352
  function buildChildEnv(options) {
263
- const { env, format, passthrough, runId, shouldBypassCache, snapshot, styles, verbosity } = options;
353
+ const { env, format, passthrough, runId, shouldBypassCache, shouldBypassLock, snapshot, styles, verbosity } = options;
264
354
  const bypassesCache = shouldBypassCache || passthrough.length > 0;
355
+ const { [DELEGATE_ENV_VAR]: _delegateHost, ...inheritedEnv } = env;
265
356
  return {
266
- ...env,
357
+ ...inheritedEnv,
267
358
  ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
268
359
  ...(bypassesCache && { [NO_CACHE_ENV_VAR]: '1' }),
360
+ ...(shouldBypassLock && { [NO_LOCK_ENV_VAR]: '1' }),
269
361
  [COMMAND_VERBOSITY_ENV_VAR]: verbosity,
270
362
  [OUTPUT_STYLE_ENV_VAR]: styles.stdout,
271
363
  [REPORT_FORMAT_ENV_VAR]: format,
272
364
  [RUN_ID_ENV_VAR]: runId,
273
365
  };
274
366
  }
367
+ function addLockEnv(env, lock) {
368
+ if (lock?.token !== undefined) {
369
+ return { ...env, [LOCK_TOKEN_ENV_VAR]: lock.token };
370
+ }
371
+ if (lock?.isChainUnlocked === true) {
372
+ return { ...env, [NO_LOCK_ENV_VAR]: '1' };
373
+ }
374
+ return env;
375
+ }
275
376
  function acceptsArguments(step) {
276
377
  return step.kind === 'opaque' || step.shouldDeclineArguments !== true;
277
378
  }
@@ -319,6 +420,19 @@ function composeDelegation(options) {
319
420
  },
320
421
  };
321
422
  }
423
+ function composeForwardedArgs(command, parsedArgs) {
424
+ return [
425
+ ...(parsedArgs.filter === undefined ? [] : ['--filter', parsedArgs.filter]),
426
+ ...(parsedArgs.recursive ? ['--recursive'] : []),
427
+ ...(parsedArgs.isWorkspaceRoot ? ['--workspace-root'] : []),
428
+ ...(parsedArgs.quiet ? ['--quiet'] : []),
429
+ ...(parsedArgs.shouldEmitJson ? ['--json'] : []),
430
+ ...(parsedArgs.shouldBypassCache ? ['--no-cache'] : []),
431
+ ...(parsedArgs.shouldBypassLock ? ['--no-lock'] : []),
432
+ command,
433
+ ...parsedArgs.passthrough,
434
+ ];
435
+ }
322
436
  async function composeRetention(options) {
323
437
  const { anchorDir, command, key, retainedOutput, runId } = options;
324
438
  if (retainedOutput !== undefined) {
@@ -638,8 +752,10 @@ function parseArgs(args) {
638
752
  quiet: false,
639
753
  recursive: false,
640
754
  shouldBypassCache: false,
755
+ shouldBypassLock: false,
641
756
  shouldEmitJson: false,
642
757
  shouldPrintLog: false,
758
+ shouldRunLocally: false,
643
759
  shouldShowHelp: false,
644
760
  shouldShowVersion: false,
645
761
  passthrough: [],
@@ -674,6 +790,15 @@ function parseArgs(args) {
674
790
  index += styleArgument.consumedCount;
675
791
  continue;
676
792
  }
793
+ const delegateArgument = readDelegateArgument(args, index);
794
+ if (delegateArgument !== undefined) {
795
+ if (!delegateArgument.ok) {
796
+ return delegateArgument;
797
+ }
798
+ parsedArgs.delegateHost = delegateArgument.value;
799
+ index += delegateArgument.consumedCount;
800
+ continue;
801
+ }
677
802
  parsedArgs.command = arg;
678
803
  parsedArgs.passthrough = args.slice(index + 1);
679
804
  break;
@@ -720,18 +845,24 @@ function readPresentation(options) {
720
845
  }
721
846
  return { ok: true, format: format.format, styles, verbosity: verbosity.verbosity };
722
847
  }
723
- function readOutputStyleArgument(args, index) {
848
+ function readDelegateArgument(args, index) {
849
+ return readFlagValue(args, index, DELEGATE_FLAG, 'a host argument');
850
+ }
851
+ function readFlagValue(args, index, flag, requirement) {
724
852
  const arg = args[index] ?? '';
725
- const assignment = arg.startsWith(`${OUTPUT_STYLE_FLAG}=`) ? arg.slice(OUTPUT_STYLE_FLAG.length + 1) : undefined;
726
- if (arg !== OUTPUT_STYLE_FLAG && assignment === undefined) {
853
+ const assignment = arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : undefined;
854
+ if (arg !== flag && assignment === undefined) {
727
855
  return undefined;
728
856
  }
729
857
  const value = assignment ?? args[index + 1];
730
858
  if (!value) {
731
- return { ok: false, error: `${OUTPUT_STYLE_FLAG} requires a value argument: auto, plain, or rich` };
859
+ return { ok: false, error: `${flag} requires ${requirement}` };
732
860
  }
733
861
  return { ok: true, value, consumedCount: assignment === undefined ? 2 : 1 };
734
862
  }
863
+ function readOutputStyleArgument(args, index) {
864
+ return readFlagValue(args, index, OUTPUT_STYLE_FLAG, 'a value argument: auto, plain, or rich');
865
+ }
735
866
  function readRunnableSteps(resolvedScript, workspacePackageDirs) {
736
867
  if (workspacePackageDirs.length > 0) {
737
868
  return { isEmptiedByWorkspace: false, steps: resolvedScript.steps };
@@ -764,6 +895,46 @@ function resolveReportingVerbosity(options) {
764
895
  const { format, ...ladder } = options;
765
896
  return format === 'json' ? 'quiet' : resolveVerbosity(ladder);
766
897
  }
898
+ async function readGate(plan) {
899
+ const { key, snapshot } = plan;
900
+ const { context, env, stderr, stdout } = plan.invocation;
901
+ if (key === undefined || snapshot === undefined) {
902
+ return undefined;
903
+ }
904
+ const quiet = plan.runOptions.quiet === true;
905
+ return {
906
+ buildOutputBefore: await readBuildOutputState(context.monorepoRoot, context.config),
907
+ key,
908
+ retentionKey: computeRetentionKey({
909
+ channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
910
+ env,
911
+ passKey: key,
912
+ }),
913
+ snapshot,
914
+ };
915
+ }
916
+ async function recallPass(plan, gate) {
917
+ const { anchorDir, command, context, env, stderr } = plan.invocation;
918
+ const { monorepoRoot } = context;
919
+ const recalledPass = await lookUpRecordedPass({
920
+ anchorDir,
921
+ buildOutput: gate.buildOutputBefore,
922
+ command,
923
+ env,
924
+ key: gate.key,
925
+ monorepoRoot,
926
+ retentionKey: gate.retentionKey,
927
+ stderr,
928
+ });
929
+ if (recalledPass === undefined) {
930
+ return undefined;
931
+ }
932
+ const { entry, ...recall } = recalledPass;
933
+ if (recall.replay !== undefined) {
934
+ await certifyRetention({ anchorDir, command, entry, env, monorepoRoot, runId: plan.runId, stderr });
935
+ }
936
+ return { exitCode: 0, outcome: { outcome: 'recalled', ...recall } };
937
+ }
767
938
  async function recordPass(options) {
768
939
  const { anchorDir, command, env, monorepoRoot, snapshot, stderr } = options;
769
940
  const currentRead = resolveTreeSnapshot({ monorepoRoot, env: {} });
@@ -862,70 +1033,61 @@ async function runDelegation(options) {
862
1033
  return runSteps([step], context.monorepoRoot, { ...runOptions, env: delegation.env });
863
1034
  }
864
1035
  async function runGated(plan) {
865
- const { commandString, key, snapshot } = plan;
1036
+ const { commandString, key } = plan;
866
1037
  const { anchorDir, command, context, env, stderr, stdout } = plan.invocation;
867
1038
  const { config, monorepoRoot } = context;
868
- const quiet = plan.runOptions.quiet === true;
869
- const gate = key !== undefined && snapshot !== undefined
870
- ? {
871
- buildOutputBefore: await readBuildOutputState(monorepoRoot, config),
872
- key,
873
- retentionKey: computeRetentionKey({
874
- channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
875
- env,
876
- passKey: key,
877
- }),
878
- snapshot,
879
- }
880
- : undefined;
1039
+ let gate = await readGate(plan);
881
1040
  if (gate !== undefined && !plan.shouldBypassCache) {
882
- const recalledPass = await lookUpRecordedPass({
883
- anchorDir,
884
- buildOutput: gate.buildOutputBefore,
885
- command,
886
- env,
887
- key: gate.key,
888
- monorepoRoot,
889
- retentionKey: gate.retentionKey,
890
- stderr,
891
- });
892
- if (recalledPass !== undefined) {
893
- const { entry, ...recall } = recalledPass;
894
- if (recall.replay !== undefined) {
895
- await certifyRetention({ anchorDir, command, entry, env, monorepoRoot, runId: plan.runId, stderr });
896
- }
897
- return { exitCode: 0, outcome: { outcome: 'recalled', ...recall } };
1041
+ const recalled = await recallPass(plan, gate);
1042
+ if (recalled !== undefined) {
1043
+ return recalled;
898
1044
  }
899
1045
  }
900
- if (plan.overrideNotice !== undefined) {
901
- stdout.write(plan.overrideNotice);
1046
+ const lock = await takeMachineLock(plan.invocation);
1047
+ try {
1048
+ if (lock?.waited === true && key !== undefined) {
1049
+ gate = await readGate(plan);
1050
+ if (gate !== undefined && !plan.shouldBypassCache) {
1051
+ const recalled = await recallPass(plan, gate);
1052
+ if (recalled !== undefined) {
1053
+ return recalled;
1054
+ }
1055
+ }
1056
+ }
1057
+ if (plan.overrideNotice !== undefined) {
1058
+ stdout.write(plan.overrideNotice);
1059
+ }
1060
+ const runOptions = { ...plan.runOptions, env: addLockEnv(plan.runOptions.env ?? env, lock) };
1061
+ const startedAtMs = Date.now();
1062
+ const { exitCode, retainedOutput } = await runSteps(plan.steps, anchorDir, runOptions);
1063
+ const durationMs = Date.now() - startedAtMs;
1064
+ if (exitCode === 0 && gate !== undefined) {
1065
+ await recordPass({
1066
+ anchorDir,
1067
+ buildOutputBefore: gate.buildOutputBefore,
1068
+ command,
1069
+ commandString,
1070
+ config,
1071
+ durationMs,
1072
+ env,
1073
+ key: gate.key,
1074
+ monorepoRoot,
1075
+ ownSteps: plan.ownSteps,
1076
+ retainedOutput,
1077
+ retentionKey: gate.retentionKey,
1078
+ runId: plan.runId,
1079
+ snapshot: gate.snapshot,
1080
+ stderr,
1081
+ });
1082
+ }
1083
+ return {
1084
+ exitCode,
1085
+ outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
1086
+ };
902
1087
  }
903
- const startedAtMs = Date.now();
904
- const { exitCode, retainedOutput } = await runSteps(plan.steps, anchorDir, plan.runOptions);
905
- const durationMs = Date.now() - startedAtMs;
906
- if (exitCode === 0 && gate !== undefined) {
907
- await recordPass({
908
- anchorDir,
909
- buildOutputBefore: gate.buildOutputBefore,
910
- command,
911
- commandString,
912
- config,
913
- durationMs,
914
- env,
915
- key: gate.key,
916
- monorepoRoot,
917
- ownSteps: plan.ownSteps,
918
- retainedOutput,
919
- retentionKey: gate.retentionKey,
920
- runId: plan.runId,
921
- snapshot: gate.snapshot,
922
- stderr,
923
- });
1088
+ finally {
1089
+ await lock?.release();
924
1090
  }
925
- return {
926
- exitCode,
927
- outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
928
- };
929
1091
  }
930
1092
  function formatMisplacedFlagHint(args, passthrough, style) {
931
1093
  const { flagNames, movedTokens, remainingTokens } = splitMisplacedFlags(passthrough);
@@ -951,10 +1113,28 @@ function isDefinedInAnyScope(command, context) {
951
1113
  }
952
1114
  return [context.monorepoRoot, ...context.workspacePackageDirs].some((dir) => Object.hasOwn(readPackageJsonScripts(dir) ?? {}, command));
953
1115
  }
1116
+ function isDelegationFlag(arg) {
1117
+ return arg === DELEGATE_FLAG || arg === NO_DELEGATE_FLAG || arg.startsWith(`${DELEGATE_FLAG}=`);
1118
+ }
954
1119
  function isTopLevelInvocation(env) {
955
1120
  const runId = env[RUN_ID_ENV_VAR];
956
1121
  return runId === undefined || runId === '';
957
1122
  }
1123
+ async function takeMachineLock(invocation) {
1124
+ const { anchorDir, command, env, parsedArgs, stderr, styles } = invocation;
1125
+ if (!HEAVY_COMMANDS.has(command) || parsedArgs.shouldPrintLog) {
1126
+ return undefined;
1127
+ }
1128
+ if (parsedArgs.passthrough.includes('--no-lock')) {
1129
+ stderr.write(`${formatMisplacedNoLockWarning(command, styles.stderr)}\n`);
1130
+ }
1131
+ return acquireMachineLock({
1132
+ command,
1133
+ dir: anchorDir,
1134
+ env: parsedArgs.shouldBypassLock ? { ...env, [NO_LOCK_ENV_VAR]: '1' } : env,
1135
+ stderr,
1136
+ });
1137
+ }
958
1138
  function readMisplacedFlagLength(args, index) {
959
1139
  const arg = args[index] ?? '';
960
1140
  if (FILTER_FLAGS.has(arg)) {
@@ -964,8 +1144,8 @@ function readMisplacedFlagLength(args, index) {
964
1144
  if (booleanFlag !== undefined) {
965
1145
  return booleanFlag === 'shouldShowHelp' || booleanFlag === 'shouldShowVersion' ? undefined : 1;
966
1146
  }
967
- const styleArgument = readOutputStyleArgument(args, index);
968
- return styleArgument?.ok === true ? styleArgument.consumedCount : undefined;
1147
+ const valueArgument = readOutputStyleArgument(args, index) ?? readDelegateArgument(args, index);
1148
+ return valueArgument?.ok === true ? valueArgument.consumedCount : undefined;
969
1149
  }
970
1150
  function splitMisplacedFlags(passthrough) {
971
1151
  const flagNames = new Set();
@@ -984,15 +1164,12 @@ function splitMisplacedFlags(passthrough) {
984
1164
  index++;
985
1165
  continue;
986
1166
  }
987
- flagNames.add(arg.startsWith(`${OUTPUT_STYLE_FLAG}=`) ? OUTPUT_STYLE_FLAG : arg);
1167
+ flagNames.add(arg.split('=', 1)[0] ?? arg);
988
1168
  movedTokens.push(...passthrough.slice(index, index + flagLength));
989
1169
  index += flagLength;
990
1170
  }
991
1171
  return { flagNames: [...flagNames], movedTokens, remainingTokens };
992
1172
  }
993
- function quoteForShell(arg) {
994
- return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
995
- }
996
1173
  function wrapWithHooks(command, mainSteps, registry, anchorDir, isWorkspaceRoot) {
997
1174
  const steps = [];
998
1175
  if (hasRunnableHook(`${command}:pre`, registry, anchorDir, isWorkspaceRoot)) {
@@ -29,6 +29,8 @@ export type NoOpReason = 'empty-override' | 'empty-workspace' | 'noop-override';
29
29
  */
30
30
  export type Verdict = {
31
31
  command: string;
32
+ /** The host on which a delegated command ran, which a command that ran here does not have. */
33
+ host?: string;
32
34
  scope: string;
33
35
  } & VerdictOutcome;
34
36
  /**
@@ -23,13 +23,14 @@ const MIN_CUT_BYTES = Buffer.byteLength(TRUNCATION_MARK);
23
23
  const REPLAY_MARKER = 'replayed:';
24
24
  function describeOutcome(verdict, style) {
25
25
  const statuses = STATUS_GLYPHS[style];
26
+ const onHost = verdict.host === undefined ? '' : ` on ${verdict.host}`;
26
27
  switch (verdict.outcome) {
27
28
  case 'passed':
28
- return { marker: statuses.passed.text, phrase: `passed in ${formatDuration(verdict.durationMs)}` };
29
+ return { marker: statuses.passed.text, phrase: `passed${onHost} in ${formatDuration(verdict.durationMs)}` };
29
30
  case 'failed':
30
31
  return {
31
32
  marker: statuses.failed.text,
32
- phrase: `failed in ${formatDuration(verdict.durationMs)} (exit ${verdict.exitCode})`,
33
+ phrase: `failed${onHost} in ${formatDuration(verdict.durationMs)} (exit ${verdict.exitCode})`,
33
34
  };
34
35
  case 'recalled': {
35
36
  const saving = formatSaving(verdict.savedMs);
@@ -0,0 +1,11 @@
1
+ export interface MachineCapacity {
2
+ coreCount: number;
3
+ totalMemoryBytes: number;
4
+ }
5
+ /** Returns the worker count for a machine: one per `GIB_PER_WORKER` of memory, bounded by one fewer than the cores. */
6
+ export declare function deriveDefaultMaxWorkers({ coreCount, totalMemoryBytes }: MachineCapacity): number;
7
+ /**
8
+ * Returns the value of the last `--maxWorkers` argument: a number, or a percentage string that Vitest resolves against
9
+ * the core count. An argument whose value is neither counts as absent, which leaves Vitest to report it.
10
+ */
11
+ export declare function readMaxWorkersFlag(args: readonly string[]): number | string | undefined;
@@ -0,0 +1,35 @@
1
+ const SMALL_MACHINE_MAX_CORES = 2;
2
+ const SMALL_MACHINE_MAX_WORKERS = 3;
3
+ const GIB_PER_WORKER = 4;
4
+ const BYTES_PER_GIB = 2 ** 30;
5
+ const MAX_WORKERS_FLAGS = ['--maxWorkers', '--max-workers'];
6
+ const PERCENTAGE_PATTERN = /^\d+%$/;
7
+ const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
8
+ export function deriveDefaultMaxWorkers({ coreCount, totalMemoryBytes }) {
9
+ if (coreCount <= SMALL_MACHINE_MAX_CORES)
10
+ return SMALL_MACHINE_MAX_WORKERS;
11
+ const memoryBound = Math.max(1, Math.floor(totalMemoryBytes / (GIB_PER_WORKER * BYTES_PER_GIB)));
12
+ return Math.min(coreCount - 1, memoryBound);
13
+ }
14
+ export function readMaxWorkersFlag(args) {
15
+ let rawValue;
16
+ args.forEach((arg, index) => {
17
+ if (MAX_WORKERS_FLAGS.includes(arg)) {
18
+ rawValue = args[index + 1];
19
+ return;
20
+ }
21
+ const flag = MAX_WORKERS_FLAGS.find((name) => arg.startsWith(`${name}=`));
22
+ if (flag !== undefined)
23
+ rawValue = arg.slice(flag.length + 1);
24
+ });
25
+ return parseMaxWorkersValue(rawValue);
26
+ }
27
+ function parseMaxWorkersValue(rawValue) {
28
+ if (rawValue === undefined)
29
+ return undefined;
30
+ if (POSITIVE_INTEGER_PATTERN.test(rawValue))
31
+ return Number(rawValue);
32
+ if (PERCENTAGE_PATTERN.test(rawValue))
33
+ return rawValue;
34
+ return undefined;
35
+ }
@@ -135,10 +135,9 @@ function matchSubpath(exportsValue, subpath) {
135
135
  continue;
136
136
  const base = key.slice(0, star);
137
137
  const suffix = key.slice(star + 1);
138
- if (!subpath.startsWith(base) || !subpath.endsWith(suffix))
139
- continue;
140
- if (subpath.length < base.length + suffix.length)
138
+ if (!subpath.startsWith(base) || !subpath.endsWith(suffix) || subpath.length < base.length + suffix.length) {
141
139
  continue;
140
+ }
142
141
  if (bestMatch !== undefined && bestMatch.base.length >= base.length)
143
142
  continue;
144
143
  bestMatch = { base, value, wildcard: subpath.slice(base.length, subpath.length - suffix.length) };
@@ -5,14 +5,13 @@ import { defaultExclude, mergeConfig } from 'vitest/config';
5
5
  import { listGitIgnoredPaths } from "./git-ignored-paths.js";
6
6
  import { isObject } from "./helpers/type-guards.js";
7
7
  import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
8
+ import { deriveDefaultMaxWorkers, readMaxWorkersFlag } from "./vitest-max-workers.js";
8
9
  import { createSourceResolutionPlugin } from "./vitest-source-resolution.js";
9
10
  import { getWorkspacePackageDirs } from "./workspace.js";
10
11
  import { toWorkspacePackageGlobs } from "./workspace-globs.js";
11
12
  const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
12
13
  const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
13
14
  const TIER_TIMEOUT_MS = 30_000;
14
- const SMALL_MACHINE_MAX_CORES = 2;
15
- const SMALL_MACHINE_MAX_WORKERS = 3;
16
15
  const GIT_ISOLATION_SETUP_FILE = resolveGitIsolationSetupFile();
17
16
  const CLIENT_CONDITIONS = ['module', 'browser', 'development|production'];
18
17
  const SERVER_CONDITIONS = ['module', 'node', 'development|production'];
@@ -99,7 +98,11 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
99
98
  })),
100
99
  ];
101
100
  const collectionExclude = buildCollectionExclude(layers, ignoredPathsRoot);
102
- const maxWorkers = resolveDefaultMaxWorkers();
101
+ const defaultMaxWorkers = deriveDefaultMaxWorkers({
102
+ coreCount: os.availableParallelism(),
103
+ totalMemoryBytes: os.totalmem(),
104
+ });
105
+ const flagMaxWorkers = readMaxWorkersFlag(process.argv.slice(2));
103
106
  const shouldIsolateGit = resolveFlag(layers, 'shouldIsolateGit', true);
104
107
  return projectTiers.map(({ exclude, include, name, timeoutMs }) => {
105
108
  const project = {
@@ -108,7 +111,7 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
108
111
  test: {
109
112
  exclude: [...collectionExclude, ...exclude, ...extraExclude],
110
113
  include,
111
- ...(maxWorkers !== undefined && { maxWorkers }),
114
+ maxWorkers: defaultMaxWorkers,
112
115
  name,
113
116
  ...(shouldIsolateGit && { setupFiles: [GIT_ISOLATION_SETUP_FILE] }),
114
117
  ...(timeoutMs !== undefined && { hookTimeout: timeoutMs, testTimeout: timeoutMs }),
@@ -118,7 +121,9 @@ function buildProjects(layers, extraExclude, projectRoot, ignoredPathsRoot) {
118
121
  for (const layer of layers) {
119
122
  mergedProject = applyLayer(mergedProject, layer, name);
120
123
  }
121
- return mergedProject;
124
+ return flagMaxWorkers === undefined
125
+ ? mergedProject
126
+ : mergeConfig(mergedProject, { test: { maxWorkers: flagMaxWorkers } });
122
127
  });
123
128
  }
124
129
  function applyLayer(project, layer, tier) {
@@ -175,9 +180,6 @@ function formatOptionKeys(keys) {
175
180
  .map((key) => `\`${key}\``)
176
181
  .join(', ');
177
182
  }
178
- function resolveDefaultMaxWorkers() {
179
- return os.availableParallelism() <= SMALL_MACHINE_MAX_CORES ? SMALL_MACHINE_MAX_WORKERS : undefined;
180
- }
181
183
  function resolveFlag(layers, name, isOnByDefault) {
182
184
  let resolvedFlag = isOnByDefault;
183
185
  for (const layer of layers) {
@@ -93,7 +93,7 @@ $ nmr check
93
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
- 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 to which it fans out, and a constituent that did not record an 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. `ci` replays what `check:strict` earned and nothing for `build`.
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 fan-out expands into the scopes that it selects, and a constituent that did not record an 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. `ci` replays what `check:strict` earned and nothing for `build`.
97
97
 
98
98
  Every excerpt in the assembly was certified during a single run, on a single tree, because nmr assembles it when the composite records its pass rather than when it skips. `NMR_RUN_ID` makes that provable; see [reserved environment variables](#reserved-environment-variables).
99
99
 
@@ -133,7 +133,7 @@ $ nmr --log test
133
133
  🟠 nmr: test: no recording; the last pass was 3m ago, on a tree that this is not
134
134
  ```
135
135
 
136
- **`--log` applies to the scopes that `-F` and `-R` select.** nmr passes the flag to the delegate. Each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: Partial coverage is normal for a survey, and failing on the first gap would hide every scope that had something to show. A selection that does not include any scope is the other case and fails, as it does for a run: An empty selection is not a partial survey.
136
+ **`--log` applies to the scopes that `-F` and `-R` select.** nmr passes the flag to each scope that it selects. Each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: Partial coverage is normal for a survey, and failing on the first gap would hide every scope that had something to show. A selection that does not include any scope is the other case and fails, as it does for a run: An empty selection is not a partial survey.
137
137
 
138
138
  **Retention has a ceiling of 256 KiB per recorded pass**, distinct from the 512 bytes to which a verdict line is held. nmr keeps 128 KiB from each end of a transcript that overruns it, because a run's opening and its closing statement each contain what the other does not, and it marks the drop with a line that names the dropped bytes, rather than cutting silently.
139
139