@williamthorsen/nmr 0.29.0 → 0.31.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 (66) hide show
  1. package/.readyup/kits/default.js +33 -22
  2. package/.readyup/manifest.json +2 -2
  3. package/README.md +327 -78
  4. package/agents/guidance/rulebooks/nmr.md +4 -2
  5. package/bin/nmr-report-catalog.js +11 -0
  6. package/dist/esm/UserError.d.ts +3 -0
  7. package/dist/esm/UserError.js +6 -0
  8. package/dist/esm/check-cache.d.ts +36 -1
  9. package/dist/esm/check-cache.js +93 -24
  10. package/dist/esm/cli-build.js +2 -1
  11. package/dist/esm/cli-clean.js +2 -1
  12. package/dist/esm/cli-ensure-prepublish-hooks.js +2 -1
  13. package/dist/esm/cli-report-catalog.d.ts +1 -0
  14. package/dist/esm/cli-report-catalog.js +11 -0
  15. package/dist/esm/cli-report-overrides.js +2 -1
  16. package/dist/esm/cli-taze.js +2 -1
  17. package/dist/esm/cli.js +2 -1
  18. package/dist/esm/commands/clean.js +9 -5
  19. package/dist/esm/commands/report-catalog.d.ts +1 -0
  20. package/dist/esm/commands/report-catalog.js +34 -0
  21. package/dist/esm/commands/report-overrides.js +22 -7
  22. package/dist/esm/commands/taze.js +2 -1
  23. package/dist/esm/config.d.ts +1 -0
  24. package/dist/esm/config.js +81 -16
  25. package/dist/esm/default-scripts.d.ts +5 -1
  26. package/dist/esm/default-scripts.js +19 -16
  27. package/dist/esm/defineConfig.d.ts +1 -1
  28. package/dist/esm/help.js +3 -1
  29. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  30. package/dist/esm/helpers/clampToBytes.js +20 -0
  31. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  32. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  33. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  34. package/dist/esm/helpers/duration.js +16 -9
  35. package/dist/esm/helpers/package-json.d.ts +11 -4
  36. package/dist/esm/helpers/package-json.js +51 -22
  37. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  38. package/dist/esm/helpers/readStringValues.js +8 -0
  39. package/dist/esm/helpers/transcript.d.ts +5 -0
  40. package/dist/esm/helpers/transcript.js +59 -0
  41. package/dist/esm/recording.d.ts +65 -0
  42. package/dist/esm/recording.js +96 -0
  43. package/dist/esm/replay-assembly.d.ts +9 -0
  44. package/dist/esm/replay-assembly.js +26 -0
  45. package/dist/esm/report-format.d.ts +18 -0
  46. package/dist/esm/report-format.js +25 -0
  47. package/dist/esm/reportCliFailure.d.ts +2 -0
  48. package/dist/esm/reportCliFailure.js +11 -0
  49. package/dist/esm/resolve-scripts.d.ts +1 -1
  50. package/dist/esm/resolver.d.ts +13 -3
  51. package/dist/esm/resolver.js +34 -19
  52. package/dist/esm/runCli.d.ts +1 -0
  53. package/dist/esm/runCli.js +372 -70
  54. package/dist/esm/runner.d.ts +20 -1
  55. package/dist/esm/runner.js +72 -12
  56. package/dist/esm/scripts.d.ts +1 -1
  57. package/dist/esm/steps.d.ts +18 -0
  58. package/dist/esm/steps.js +144 -0
  59. package/dist/esm/types.d.ts +11 -2
  60. package/dist/esm/verbosity.d.ts +22 -0
  61. package/dist/esm/verbosity.js +37 -0
  62. package/dist/esm/verdict.d.ts +29 -0
  63. package/dist/esm/verdict.js +150 -0
  64. package/dist/esm/workspace.d.ts +2 -0
  65. package/dist/esm/workspace.js +20 -3
  66. package/package.json +12 -5
@@ -1,12 +1,24 @@
1
1
  import path from 'node:path';
2
2
  import { readPackageVersion, reportError } from '@williamthorsen/nmr-core';
3
- import { computeCacheKey, CURRENT_RUNTIME, encodeTreeSnapshot, findStaleBuildOutput, formatMisplacedNoCacheWarning, formatSkipLine, NO_CACHE_ENV_VAR, readBuildOutputState, readCheckCacheEntry, resolveCacheableCommands, resolveTreeSnapshot, TREE_SNAPSHOT_ENV_VAR, writeCheckCacheEntry, writeDebugNote, } from "./check-cache.js";
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
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
+ import { resolveConfigPath } from "./config.js";
4
6
  import { resolveContext } from "./context.js";
5
7
  import { generateHelp } from "./help.js";
8
+ import { deriveExcerpt } from "./helpers/deriveExcerpt.js";
6
9
  import { isHookName } from "./helpers/hook-name.js";
7
- import { applyDevBin, buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
8
- import { runCommand } from "./runner.js";
10
+ import { composeTranscript } from "./helpers/transcript.js";
11
+ import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
12
+ import { assembleReplay } from "./replay-assembly.js";
13
+ import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
14
+ import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, resolveScript, } from "./resolver.js";
15
+ import { resolveChannel, runSteps } from "./runner.js";
16
+ import { composeNmrStep, findNmrCrossing, renderChain } from "./steps.js";
17
+ import { COMMAND_VERBOSITY_ENV_VAR, readVerbosityEnv, resolveVerbosity } from "./verbosity.js";
18
+ import { writeVerdict } from "./verdict.js";
9
19
  const VERSION = readPackageVersion(import.meta.url);
20
+ const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
21
+ export const RUN_IF_PRESENT_ENV_VAR = 'NMR_RUN_IF_PRESENT';
10
22
  export async function runCli(options) {
11
23
  const { args, cwd, env, stdout, stderr } = options;
12
24
  const parseResult = parseArgs(args);
@@ -15,11 +27,25 @@ export async function runCli(options) {
15
27
  return { exitCode: 1 };
16
28
  }
17
29
  const { parsed } = parseResult;
30
+ const inherited = readPresentationEnv(env);
31
+ if (!inherited.ok) {
32
+ reportError(inherited.error, stderr);
33
+ return { exitCode: 1 };
34
+ }
18
35
  if (parsed.version) {
19
36
  stdout.write(`${VERSION}\n`);
20
37
  return { exitCode: 0 };
21
38
  }
22
39
  const context = await resolveContext(cwd);
40
+ const format = resolveReportFormat({ envFormat: inherited.format, jsonFlag: parsed.json });
41
+ const verbosity = resolveReportingVerbosity({
42
+ env,
43
+ envVerbosity: inherited.verbosity,
44
+ format,
45
+ output: context.config.output,
46
+ quietFlag: parsed.quiet,
47
+ });
48
+ const quiet = verbosity === 'quiet';
23
49
  const useRoot = parsed.workspaceRoot || context.isRoot;
24
50
  const anchorDir = useRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
25
51
  if (parsed.help || !parsed.command) {
@@ -27,7 +53,7 @@ export async function runCli(options) {
27
53
  return { exitCode: 0 };
28
54
  }
29
55
  const { command } = parsed;
30
- const passthrough = parsed.passthrough.length > 0 ? ' ' + parsed.passthrough.map(shellQuote).join(' ') : '';
56
+ const scope = path.basename(anchorDir);
31
57
  const snapshot = openGate({
32
58
  command,
33
59
  config: context.config,
@@ -37,38 +63,64 @@ export async function runCli(options) {
37
63
  stderr,
38
64
  });
39
65
  const noCache = parsed.noCache || env[NO_CACHE_ENV_VAR] === '1';
40
- const childEnv = buildChildEnv(env, snapshot, noCache);
41
- const runOptions = { quiet: parsed.quiet, stdout, stderr, env: childEnv };
42
- if (parsed.filter) {
43
- const delegateCmd = `pnpm --filter ${shellQuote(parsed.filter)} exec nmr ${command}${passthrough}`;
44
- const { exitCode } = await runCommand(delegateCmd, context.monorepoRoot, runOptions);
45
- return { exitCode };
46
- }
47
- if (parsed.recursive) {
48
- const delegateEnv = { ...childEnv, NMR_RUN_IF_PRESENT: '1' };
49
- const delegateCmd = `pnpm --recursive exec nmr ${command}${passthrough}`;
50
- const { exitCode } = await runCommand(delegateCmd, context.monorepoRoot, { ...runOptions, env: delegateEnv });
51
- return { exitCode };
66
+ const runId = resolveRunId(env);
67
+ const childEnv = buildChildEnv({
68
+ env,
69
+ format,
70
+ noCache,
71
+ passthrough: parsed.passthrough,
72
+ runId,
73
+ snapshot,
74
+ verbosity,
75
+ });
76
+ const runOptions = {
77
+ quiet,
78
+ stdout,
79
+ stderr,
80
+ env: childEnv,
81
+ };
82
+ const delegation = composeDelegation({ childEnv, command, parsed });
83
+ if (delegation !== undefined) {
84
+ return runSteps([delegation.step], context.monorepoRoot, { ...runOptions, env: delegation.env });
52
85
  }
53
86
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
54
87
  const resolved = resolveScript(command, registry, anchorDir, parsed.workspaceRoot);
55
88
  if (!resolved) {
56
- if (env['NMR_RUN_IF_PRESENT'] === '1') {
89
+ if (env[RUN_IF_PRESENT_ENV_VAR] === '1') {
57
90
  return { exitCode: 0 };
58
91
  }
59
92
  reportError(`Unknown command: ${command}`, stderr);
60
93
  return { exitCode: 1 };
61
94
  }
62
- const skipExitCode = handleSkipMessage(resolved.command, anchorDir, parsed.quiet, stdout);
63
- if (skipExitCode !== undefined) {
64
- return { exitCode: skipExitCode };
95
+ const resolvedCommand = renderChain(resolved.steps);
96
+ const noOpReason = findNoOpReason(resolvedCommand);
97
+ if (noOpReason !== undefined && !parsed.log) {
98
+ reportVerdict({ command, scope, outcome: 'no-op', reason: noOpReason }, stdout, format);
99
+ return { exitCode: 0 };
100
+ }
101
+ const substitutedSteps = applyDevBinToSteps(resolved.steps, context.config.devBin, context.monorepoRoot);
102
+ const substitutedCommand = renderChain(substitutedSteps);
103
+ const bound = bindPassthrough(substitutedSteps, parsed.passthrough, command);
104
+ if (!bound.ok) {
105
+ reportError(bound.error, stderr);
106
+ return { exitCode: 1 };
65
107
  }
66
- const substitutedCommand = applyDevBin(resolved.command, context.config.devBin, context.monorepoRoot);
67
- const mainCommand = substitutedCommand + passthrough;
108
+ const mainSteps = bound.steps;
68
109
  const isHookInvocation = isHookName(command);
69
- const fullCommand = isHookInvocation
70
- ? mainCommand
71
- : wrapWithHooks(command, mainCommand, registry, anchorDir, parsed.workspaceRoot);
110
+ const fullSteps = isHookInvocation
111
+ ? mainSteps
112
+ : wrapWithHooks(command, mainSteps, registry, anchorDir, parsed.workspaceRoot);
113
+ const fullCommand = renderChain(fullSteps);
114
+ reportNmrCrossing({
115
+ config: context.config,
116
+ isReading: parsed.log,
117
+ monorepoRoot: context.monorepoRoot,
118
+ registry,
119
+ resolved,
120
+ stderr,
121
+ useRoot,
122
+ workspaceRoot: parsed.workspaceRoot,
123
+ });
72
124
  const key = resolveCacheKey({
73
125
  anchorDir,
74
126
  command,
@@ -77,52 +129,193 @@ export async function runCli(options) {
77
129
  monorepoRoot: context.monorepoRoot,
78
130
  snapshot,
79
131
  stderr,
80
- substitution: substitutedCommand === resolved.command ? undefined : substitutedCommand,
132
+ substitution: substitutedCommand === resolvedCommand ? undefined : substitutedCommand,
81
133
  });
82
- const exitCode = await runGated({
134
+ if (parsed.log) {
135
+ return reportRecording({
136
+ anchorDir,
137
+ command,
138
+ commandString: fullCommand,
139
+ config: context.config,
140
+ env,
141
+ key,
142
+ monorepoRoot: context.monorepoRoot,
143
+ scope,
144
+ snapshot,
145
+ stderr,
146
+ stdout,
147
+ });
148
+ }
149
+ const { exitCode, outcome } = await runGated({
83
150
  anchorDir,
84
151
  command,
85
152
  commandString: fullCommand,
86
153
  config: context.config,
154
+ steps: fullSteps,
87
155
  env,
88
156
  key,
89
157
  monorepoRoot: context.monorepoRoot,
90
158
  noCache,
91
- overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, parsed.quiet),
92
- quiet: parsed.quiet,
159
+ overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, quiet),
160
+ ownSteps: mainSteps,
161
+ runId,
93
162
  runOptions,
94
163
  snapshot,
95
164
  stderr,
96
165
  stdout,
97
166
  });
167
+ reportVerdict({ command, scope, ...outcome }, stdout, format);
98
168
  return { exitCode };
99
169
  }
100
- function buildChildEnv(env, snapshot, noCache) {
170
+ function buildChildEnv(options) {
171
+ const { env, format, noCache, passthrough, runId, snapshot, verbosity } = options;
172
+ const bypassesCache = noCache || passthrough.length > 0;
101
173
  return {
102
174
  ...env,
103
175
  ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
104
- ...(noCache && { [NO_CACHE_ENV_VAR]: '1' }),
176
+ ...(bypassesCache && { [NO_CACHE_ENV_VAR]: '1' }),
177
+ [COMMAND_VERBOSITY_ENV_VAR]: verbosity,
178
+ [REPORT_FORMAT_ENV_VAR]: format,
179
+ [RUN_ID_ENV_VAR]: runId,
180
+ };
181
+ }
182
+ function acceptsArgs(step) {
183
+ return step.kind === 'opaque' || step.declinesArgs !== true;
184
+ }
185
+ function bindPassthrough(steps, passthrough, command) {
186
+ if (passthrough.length === 0) {
187
+ return { ok: true, steps };
188
+ }
189
+ if (!steps.some(acceptsArgs)) {
190
+ return { ok: false, error: formatUnroutableArgsError(command) };
191
+ }
192
+ const boundSteps = steps.map((step) => {
193
+ if (!acceptsArgs(step)) {
194
+ return step;
195
+ }
196
+ return step.kind === 'structural'
197
+ ? { ...step, argv: [...step.argv, ...passthrough] }
198
+ : { kind: 'opaque', command: `${step.command} ${passthrough.map(shellQuote).join(' ')}` };
199
+ });
200
+ return { ok: true, steps: boundSteps };
201
+ }
202
+ function formatUnroutableArgsError(command) {
203
+ return (`\`${command}\` takes no trailing arguments: every step of its chain declines them. ` +
204
+ 'Running it unnarrowed is not what the arguments asked for, so nothing ran.');
205
+ }
206
+ function composeDelegation(options) {
207
+ const { childEnv, command, parsed } = options;
208
+ let scope;
209
+ let runIfPresent = parsed.log;
210
+ if (parsed.filter) {
211
+ scope = ['--filter', parsed.filter];
212
+ }
213
+ else if (parsed.recursive) {
214
+ scope = ['--recursive'];
215
+ runIfPresent = true;
216
+ }
217
+ if (scope === undefined) {
218
+ return undefined;
219
+ }
220
+ const flags = parsed.log ? ['--log'] : [];
221
+ return {
222
+ env: runIfPresent ? { ...childEnv, [RUN_IF_PRESENT_ENV_VAR]: '1' } : childEnv,
223
+ step: {
224
+ kind: 'structural',
225
+ argv: ['pnpm', ...scope, 'exec', 'nmr', ...flags, command, ...parsed.passthrough],
226
+ },
105
227
  };
106
228
  }
229
+ async function composeRetention(options) {
230
+ const { anchorDir, command, key, retained, runId } = options;
231
+ if (retained !== undefined) {
232
+ const excerpt = deriveExcerpt(retained.stdout.toString('utf8')) ?? deriveExcerpt(retained.stderr.toString('utf8'));
233
+ if (excerpt === undefined) {
234
+ return undefined;
235
+ }
236
+ return { key, replay: [{ command, excerpt, scope: path.basename(anchorDir) }], runId };
237
+ }
238
+ const replay = await assembleReplay({
239
+ anchorDir,
240
+ monorepoRoot: options.monorepoRoot,
241
+ runId,
242
+ steps: options.steps,
243
+ treeHash: options.treeHash,
244
+ });
245
+ return replay.length === 0 ? undefined : { key, replay, runId };
246
+ }
247
+ function describeCrossingRemedy(options) {
248
+ const { crossing, monorepoRoot, origin, registry, workspaceRoot } = options;
249
+ const configSite = path.relative(monorepoRoot, resolveConfigPath(monorepoRoot));
250
+ switch (origin.tier) {
251
+ case 'default':
252
+ return {
253
+ remedy: 'A built-in default reaching nmr through a shell is an nmr defect: please report it.',
254
+ subject: `nmr built-in \`${origin.key}\``,
255
+ };
256
+ case 'config':
257
+ return {
258
+ remedy: `Write the nmr steps as a step list, and move any others to a \`${origin.key}:pre\` or ` +
259
+ `\`${origin.key}:post\` script.`,
260
+ subject: `${configSite}: \`${origin.field}.${origin.key}\``,
261
+ };
262
+ case 'package':
263
+ return {
264
+ remedy: formatPackageRemedy({ configSite, crossing, key: origin.key, registry, workspaceRoot }),
265
+ subject: `${path.relative(monorepoRoot, origin.file)}: \`scripts.${origin.key}\``,
266
+ };
267
+ default: {
268
+ const unhandled = origin;
269
+ throw new Error(`Unhandled script origin: ${JSON.stringify(unhandled)}`);
270
+ }
271
+ }
272
+ }
273
+ function describeOrigin(origin, config, useRoot) {
274
+ if (origin.tier === 'package') {
275
+ return origin;
276
+ }
277
+ const field = useRoot ? 'rootScripts' : 'workspaceScripts';
278
+ const configScripts = config[field];
279
+ return configScripts !== undefined && Object.hasOwn(configScripts, origin.key)
280
+ ? { tier: 'config', field, key: origin.key }
281
+ : { tier: 'default', key: origin.key };
282
+ }
283
+ function formatNmrCrossingWarning(options) {
284
+ const { remedy, subject } = describeCrossingRemedy(options);
285
+ return `⚠️ ${subject} reaches nmr through a shell (\`${options.crossing}\`), ${CROSSING_CONSEQUENCE} ${remedy}`;
286
+ }
107
287
  function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
108
288
  const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
109
- if (quiet || resolved.source !== 'package' || registryEntry === undefined) {
289
+ if (quiet || resolved.origin.tier !== 'package' || registryEntry === undefined) {
110
290
  return undefined;
111
291
  }
112
- return `📦 ${path.basename(anchorDir)}: Using override script: ${resolved.command}\n`;
292
+ return `📦 ${path.basename(anchorDir)}: Using override script: ${renderChain(resolved.steps)}\n`;
293
+ }
294
+ function formatPackageRemedy(options) {
295
+ const { configSite, crossing, key, registry, workspaceRoot } = options;
296
+ const registryEntry = Object.hasOwn(registry, key) ? registry[key] : undefined;
297
+ if (registryEntry === undefined) {
298
+ return (`A \`package.json\` script holds no step list: define \`${key}\` in \`${configSite}\` and move the ` +
299
+ `package-specific steps to a \`${key}:pre\` or \`${key}:post\` script.`);
300
+ }
301
+ const registryChain = renderChain(expandScript(registryEntry, workspaceRoot));
302
+ if (registryChain === crossing) {
303
+ return `Delete the entry: nmr's own \`${key}\` already runs \`${registryChain}\`.`;
304
+ }
305
+ return `Delete the entry and move the steps it adds to a \`${key}:pre\` or \`${key}:post\` script.`;
306
+ }
307
+ function findArgError(parsed) {
308
+ if (parsed.log && parsed.command === undefined && !parsed.help && !parsed.version) {
309
+ return '--log requires a command name: `nmr --log <command>`';
310
+ }
311
+ return undefined;
113
312
  }
114
- function handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout) {
313
+ function findNoOpReason(resolvedCommand) {
115
314
  if (resolvedCommand === '') {
116
- if (!quiet) {
117
- stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is defined but empty. Skipping.\n`);
118
- }
119
- return 0;
315
+ return 'empty-override';
120
316
  }
121
317
  if (resolvedCommand === ':') {
122
- if (!quiet) {
123
- stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is a no-op. Skipping.\n`);
124
- }
125
- return 0;
318
+ return 'noop-override';
126
319
  }
127
320
  return undefined;
128
321
  }
@@ -130,7 +323,8 @@ function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
130
323
  const resolved = resolveScript(hookName, registry, anchorDir, workspaceRoot);
131
324
  if (!resolved)
132
325
  return false;
133
- return resolved.command !== '' && resolved.command !== ':';
326
+ const chain = renderChain(resolved.steps);
327
+ return chain !== '' && chain !== ':';
134
328
  }
135
329
  async function lookUpRecordedPass(options) {
136
330
  const { anchorDir, buildOutput, command, env, key, monorepoRoot, stderr } = options;
@@ -153,20 +347,23 @@ async function lookUpRecordedPass(options) {
153
347
  writeDebugNote(`running ${command}: ${stale}'s build output came from a different tree`, env, stderr);
154
348
  return undefined;
155
349
  }
156
- return formatSkipLine(command, entry, Date.now());
350
+ return {
351
+ ageMs: Math.max(0, Date.now() - Date.parse(entry.recordedAt)),
352
+ entry,
353
+ savedMs: entry.durationMs,
354
+ ...(entry.retention?.key === options.retentionKey && { replay: entry.retention.replay }),
355
+ };
157
356
  }
158
357
  function openGate(options) {
159
358
  const { command, config, env, monorepoRoot, passthrough, stderr } = options;
160
- const covered = !isHookName(command) &&
161
- config.checkCache?.enabled !== false &&
162
- resolveCacheableCommands(config.checkCache).has(command);
163
- if (!covered) {
359
+ if (!isCacheableCommand(config.checkCache, command)) {
164
360
  return undefined;
165
361
  }
166
362
  if (passthrough.includes('--no-cache')) {
167
363
  stderr.write(`${formatMisplacedNoCacheWarning(command)}\n`);
168
364
  }
169
365
  if (passthrough.length > 0) {
366
+ writeDebugNote(`gate disabled: ${command} was passed arguments`, env, stderr);
170
367
  return undefined;
171
368
  }
172
369
  const snapshot = resolveTreeSnapshot({ monorepoRoot, env });
@@ -178,6 +375,8 @@ function openGate(options) {
178
375
  }
179
376
  function parseArgs(args) {
180
377
  const parsed = {
378
+ json: false,
379
+ log: false,
181
380
  noCache: false,
182
381
  quiet: false,
183
382
  recursive: false,
@@ -226,6 +425,16 @@ function parseArgs(args) {
226
425
  i++;
227
426
  continue;
228
427
  }
428
+ if (arg === '--json') {
429
+ parsed.json = true;
430
+ i++;
431
+ continue;
432
+ }
433
+ if (arg === '--log') {
434
+ parsed.log = true;
435
+ i++;
436
+ continue;
437
+ }
229
438
  if (arg === '--no-cache') {
230
439
  parsed.noCache = true;
231
440
  i++;
@@ -235,7 +444,65 @@ function parseArgs(args) {
235
444
  parsed.passthrough = args.slice(i + 1);
236
445
  break;
237
446
  }
238
- return { ok: true, parsed };
447
+ const error = findArgError(parsed);
448
+ return error === undefined ? { ok: true, parsed } : { ok: false, error };
449
+ }
450
+ async function reportRecording(options) {
451
+ const { command, config, scope } = options;
452
+ const lookup = await resolveRecording({
453
+ anchorDir: options.anchorDir,
454
+ command,
455
+ current: {
456
+ commandString: options.commandString,
457
+ nmrVersion: VERSION,
458
+ nodeVersion: CURRENT_RUNTIME.nodeVersion,
459
+ treeHash: options.snapshot?.hash,
460
+ },
461
+ isCacheable: isCacheableCommand(config.checkCache, command),
462
+ key: options.key,
463
+ monorepoRoot: options.monorepoRoot,
464
+ });
465
+ if (!lookup.ok) {
466
+ options.stderr.write(`${renderRefusal({ command, refusal: lookup.refusal, scope })}\n`);
467
+ return { exitCode: options.env[RUN_IF_PRESENT_ENV_VAR] === '1' ? 0 : 1 };
468
+ }
469
+ options.stdout.write(renderRecording({ command, recording: lookup.recording, scope }));
470
+ return { exitCode: 0 };
471
+ }
472
+ function readPresentationEnv(env) {
473
+ const verbosity = readVerbosityEnv(env);
474
+ if (!verbosity.ok) {
475
+ return verbosity;
476
+ }
477
+ const format = readReportFormatEnv(env);
478
+ if (!format.ok) {
479
+ return format;
480
+ }
481
+ return { ok: true, format: format.format, verbosity: verbosity.verbosity };
482
+ }
483
+ function reportNmrCrossing(options) {
484
+ const crossing = findNmrCrossing(options.resolved.steps);
485
+ if (options.isReading || crossing === undefined) {
486
+ return;
487
+ }
488
+ const warning = formatNmrCrossingWarning({
489
+ crossing,
490
+ monorepoRoot: options.monorepoRoot,
491
+ origin: describeOrigin(options.resolved.origin, options.config, options.useRoot),
492
+ registry: options.registry,
493
+ workspaceRoot: options.workspaceRoot,
494
+ });
495
+ options.stderr.write(`${warning}\n`);
496
+ }
497
+ function reportVerdict(verdict, stdout, format) {
498
+ if (isHookName(verdict.command)) {
499
+ return;
500
+ }
501
+ writeVerdict(verdict, stdout, format);
502
+ }
503
+ function resolveReportingVerbosity(options) {
504
+ const { format, ...ladder } = options;
505
+ return format === 'json' ? 'quiet' : resolveVerbosity(ladder);
239
506
  }
240
507
  async function recordPass(options) {
241
508
  const { anchorDir, command, env, monorepoRoot, snapshot, stderr } = options;
@@ -259,11 +526,22 @@ async function recordPass(options) {
259
526
  writeDebugNote(`not recording ${command}: ${changed}'s build output changed while it ran`, env, stderr);
260
527
  return;
261
528
  }
529
+ const retention = await composeRetention({
530
+ anchorDir,
531
+ command,
532
+ key: options.retentionKey,
533
+ monorepoRoot,
534
+ retained: options.retained,
535
+ runId: options.runId,
536
+ steps: options.ownSteps,
537
+ treeHash: snapshot.hash,
538
+ });
539
+ const ref = { anchorDir, command, monorepoRoot };
540
+ const transcript = options.retained === undefined ? undefined : composeTranscript(options.retained);
262
541
  try {
542
+ await recordTranscript(ref, transcript);
263
543
  await writeCheckCacheEntry({
264
- anchorDir,
265
- command,
266
- monorepoRoot,
544
+ ...ref,
267
545
  entry: {
268
546
  key: options.key,
269
547
  treeHash: snapshot.hash,
@@ -274,12 +552,17 @@ async function recordPass(options) {
274
552
  durationMs: options.durationMs,
275
553
  recordedAt: new Date().toISOString(),
276
554
  buildDigests: output.digests,
555
+ ...(retention !== undefined && { retention }),
277
556
  },
278
557
  });
279
558
  }
280
559
  catch (error) {
281
- const message = error instanceof Error ? error.message : String(error);
282
- writeDebugNote(`could not record ${command}: ${message}`, env, stderr);
560
+ try {
561
+ await recordTranscript(ref, undefined);
562
+ }
563
+ catch {
564
+ }
565
+ writeDebugNote(`could not record ${command}: ${describeError(error)}`, env, stderr);
283
566
  }
284
567
  }
285
568
  function resolveCacheKey(options) {
@@ -308,31 +591,44 @@ function resolveCacheKey(options) {
308
591
  }
309
592
  async function runGated(options) {
310
593
  const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
594
+ const quiet = options.runOptions.quiet === true;
311
595
  const gate = key !== undefined && snapshot !== undefined
312
- ? { buildOutputBefore: await readBuildOutputState(monorepoRoot, options.config), key, snapshot }
596
+ ? {
597
+ buildOutputBefore: await readBuildOutputState(monorepoRoot, options.config),
598
+ key,
599
+ retentionKey: computeRetentionKey({
600
+ channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
601
+ env,
602
+ passKey: key,
603
+ }),
604
+ snapshot,
605
+ }
313
606
  : undefined;
314
607
  if (gate !== undefined && !options.noCache) {
315
- const skipLine = await lookUpRecordedPass({
608
+ const recalled = await lookUpRecordedPass({
316
609
  anchorDir,
317
610
  buildOutput: gate.buildOutputBefore,
318
611
  command,
319
612
  env,
320
613
  key: gate.key,
321
614
  monorepoRoot,
615
+ retentionKey: gate.retentionKey,
322
616
  stderr,
323
617
  });
324
- if (skipLine !== undefined) {
325
- if (!options.quiet) {
326
- stdout.write(`${skipLine}\n`);
618
+ if (recalled !== undefined) {
619
+ const { entry, ...recall } = recalled;
620
+ if (recall.replay !== undefined) {
621
+ await certifyRetention({ anchorDir, command, entry, env, monorepoRoot, runId: options.runId, stderr });
327
622
  }
328
- return 0;
623
+ return { exitCode: 0, outcome: { outcome: 'recalled', ...recall } };
329
624
  }
330
625
  }
331
626
  if (options.overrideNotice !== undefined) {
332
627
  stdout.write(options.overrideNotice);
333
628
  }
334
629
  const startedAt = Date.now();
335
- const { exitCode } = await runCommand(commandString, anchorDir, options.runOptions);
630
+ const { exitCode, retained } = await runSteps(options.steps, anchorDir, options.runOptions);
631
+ const durationMs = Date.now() - startedAt;
336
632
  if (exitCode === 0 && gate !== undefined) {
337
633
  await recordPass({
338
634
  anchorDir,
@@ -340,28 +636,34 @@ async function runGated(options) {
340
636
  command,
341
637
  commandString,
342
638
  config: options.config,
343
- durationMs: Date.now() - startedAt,
639
+ durationMs,
344
640
  env,
345
641
  key: gate.key,
346
642
  monorepoRoot,
643
+ ownSteps: options.ownSteps,
644
+ retained,
645
+ retentionKey: gate.retentionKey,
646
+ runId: options.runId,
347
647
  snapshot: gate.snapshot,
348
648
  stderr,
349
649
  });
350
650
  }
351
- return exitCode;
651
+ return {
652
+ exitCode,
653
+ outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
654
+ };
352
655
  }
353
656
  function shellQuote(arg) {
354
657
  return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
355
658
  }
356
- function wrapWithHooks(command, mainCommand, registry, anchorDir, workspaceRoot) {
357
- const segments = [];
358
- const flag = workspaceRoot ? '-w ' : '';
659
+ function wrapWithHooks(command, mainSteps, registry, anchorDir, workspaceRoot) {
660
+ const steps = [];
359
661
  if (hasRunnableHook(`${command}:pre`, registry, anchorDir, workspaceRoot)) {
360
- segments.push(`nmr ${flag}${command}:pre`);
662
+ steps.push(composeNmrStep(`${command}:pre`, workspaceRoot));
361
663
  }
362
- segments.push(mainCommand);
664
+ steps.push(...mainSteps);
363
665
  if (hasRunnableHook(`${command}:post`, registry, anchorDir, workspaceRoot)) {
364
- segments.push(`nmr ${flag}${command}:post`);
666
+ steps.push(composeNmrStep(`${command}:post`, workspaceRoot));
365
667
  }
366
- return segments.join(' && ');
668
+ return steps;
367
669
  }
@@ -1,10 +1,26 @@
1
1
  import type { Writable } from 'node:stream';
2
+ import type { Step } from './steps.js';
3
+ export type OutputChannel = number | 'pipe';
4
+ export interface OutputChannels {
5
+ stderr: OutputChannel;
6
+ stdout: OutputChannel;
7
+ }
2
8
  export interface RunCommandOptions {
9
+ channels: OutputChannels;
3
10
  quiet?: boolean;
4
11
  stdout?: Writable;
5
12
  stderr?: Writable;
6
13
  env?: NodeJS.ProcessEnv;
7
14
  }
15
+ export type RunStepsOptions = Omit<RunCommandOptions, 'channels'>;
16
+ export interface RetainedOutput {
17
+ stderr: Buffer;
18
+ stdout: Buffer;
19
+ }
20
+ export interface RunStepsResult {
21
+ exitCode: number;
22
+ retained?: RetainedOutput;
23
+ }
8
24
  type CommandCompletion = {
9
25
  outcome: 'exited';
10
26
  exitCode: number;
@@ -21,5 +37,8 @@ export type RunCommandResult = CommandCompletion & {
21
37
  stdout: Buffer | undefined;
22
38
  stderr: Buffer | undefined;
23
39
  };
24
- export declare function runCommand(command: string, cwd?: string, options?: RunCommandOptions): Promise<RunCommandResult>;
40
+ export declare function resolveChannel(stream: Writable, quiet: boolean): OutputChannel;
41
+ export declare function resolveInheritedChannel(stream: Writable): OutputChannel;
42
+ export declare function runSteps(steps: readonly Step[], cwd: string | undefined, options: RunStepsOptions): Promise<RunStepsResult>;
43
+ export declare function runCommand(command: string, cwd: string | undefined, options: RunCommandOptions): Promise<RunCommandResult>;
25
44
  export {};