@williamthorsen/nmr 0.30.0 → 0.32.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 (73) hide show
  1. package/.readyup/kits/default.js +56 -16
  2. package/.readyup/manifest.json +34 -3
  3. package/README.md +265 -64
  4. package/agents/guidance/rulebooks/nmr.md +5 -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 +91 -23
  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 +4 -24
  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/build-output.d.ts +1 -0
  19. package/dist/esm/commands/build-output.js +25 -2
  20. package/dist/esm/commands/build.d.ts +5 -1
  21. package/dist/esm/commands/build.js +22 -8
  22. package/dist/esm/commands/clean.js +12 -0
  23. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +1 -0
  24. package/dist/esm/commands/ensure-prepublish-hooks.js +43 -0
  25. package/dist/esm/commands/report-catalog.d.ts +1 -0
  26. package/dist/esm/commands/report-catalog.js +40 -0
  27. package/dist/esm/commands/report-overrides.js +30 -7
  28. package/dist/esm/commands/taze.js +2 -1
  29. package/dist/esm/config.d.ts +1 -0
  30. package/dist/esm/config.js +66 -18
  31. package/dist/esm/default-scripts.d.ts +5 -1
  32. package/dist/esm/default-scripts.js +13 -10
  33. package/dist/esm/defineConfig.d.ts +1 -1
  34. package/dist/esm/help.js +3 -1
  35. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  36. package/dist/esm/helpers/clampToBytes.js +20 -0
  37. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  38. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  39. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  40. package/dist/esm/helpers/duration.js +16 -9
  41. package/dist/esm/helpers/package-json.d.ts +11 -4
  42. package/dist/esm/helpers/package-json.js +51 -22
  43. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  44. package/dist/esm/helpers/readStringValues.js +8 -0
  45. package/dist/esm/helpers/reportClosing.d.ts +1 -0
  46. package/dist/esm/helpers/reportClosing.js +3 -0
  47. package/dist/esm/helpers/transcript.d.ts +5 -0
  48. package/dist/esm/helpers/transcript.js +59 -0
  49. package/dist/esm/recording.d.ts +65 -0
  50. package/dist/esm/recording.js +96 -0
  51. package/dist/esm/replay-assembly.d.ts +9 -0
  52. package/dist/esm/replay-assembly.js +26 -0
  53. package/dist/esm/report-format.d.ts +18 -0
  54. package/dist/esm/report-format.js +25 -0
  55. package/dist/esm/reportCliFailure.d.ts +2 -0
  56. package/dist/esm/reportCliFailure.js +11 -0
  57. package/dist/esm/resolve-scripts.d.ts +1 -1
  58. package/dist/esm/resolver.d.ts +9 -1
  59. package/dist/esm/resolver.js +28 -18
  60. package/dist/esm/runCli.js +339 -74
  61. package/dist/esm/runner.d.ts +9 -3
  62. package/dist/esm/runner.js +25 -4
  63. package/dist/esm/scripts.d.ts +1 -1
  64. package/dist/esm/steps.d.ts +9 -2
  65. package/dist/esm/steps.js +117 -4
  66. package/dist/esm/types.d.ts +11 -2
  67. package/dist/esm/verbosity.d.ts +15 -4
  68. package/dist/esm/verbosity.js +32 -12
  69. package/dist/esm/verdict.d.ts +29 -0
  70. package/dist/esm/verdict.js +150 -0
  71. package/dist/esm/workspace.d.ts +2 -0
  72. package/dist/esm/workspace.js +20 -3
  73. package/package.json +14 -5
@@ -1,14 +1,23 @@
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 { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
8
- import { runSteps } from "./runner.js";
9
- import { composeNmrStep, findShelledNmrStep, renderChain } from "./steps.js";
10
- import { COMMAND_VERBOSITY_ENV_VAR, resolveVerbosity } from "./verbosity.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";
11
19
  const VERSION = readPackageVersion(import.meta.url);
20
+ const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
12
21
  export const RUN_IF_PRESENT_ENV_VAR = 'NMR_RUN_IF_PRESENT';
13
22
  export async function runCli(options) {
14
23
  const { args, cwd, env, stdout, stderr } = options;
@@ -18,17 +27,25 @@ export async function runCli(options) {
18
27
  return { exitCode: 1 };
19
28
  }
20
29
  const { parsed } = parseResult;
21
- const verbosity = resolveVerbosity(env, parsed.quiet);
22
- if (!verbosity.ok) {
23
- reportError(verbosity.error, stderr);
30
+ const inherited = readPresentationEnv(env);
31
+ if (!inherited.ok) {
32
+ reportError(inherited.error, stderr);
24
33
  return { exitCode: 1 };
25
34
  }
26
35
  if (parsed.version) {
27
36
  stdout.write(`${VERSION}\n`);
28
37
  return { exitCode: 0 };
29
38
  }
30
- const quiet = verbosity.verbosity === 'quiet';
31
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';
32
49
  const useRoot = parsed.workspaceRoot || context.isRoot;
33
50
  const anchorDir = useRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
34
51
  if (parsed.help || !parsed.command) {
@@ -36,6 +53,7 @@ export async function runCli(options) {
36
53
  return { exitCode: 0 };
37
54
  }
38
55
  const { command } = parsed;
56
+ const scope = path.basename(anchorDir);
39
57
  const snapshot = openGate({
40
58
  command,
41
59
  config: context.config,
@@ -45,21 +63,25 @@ export async function runCli(options) {
45
63
  stderr,
46
64
  });
47
65
  const noCache = parsed.noCache || env[NO_CACHE_ENV_VAR] === '1';
48
- const childEnv = buildChildEnv(env, snapshot, noCache, verbosity.verbosity);
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
+ });
49
76
  const runOptions = {
50
77
  quiet,
51
78
  stdout,
52
79
  stderr,
53
80
  env: childEnv,
54
81
  };
55
- if (parsed.filter) {
56
- const delegate = composeDelegate(['--filter', parsed.filter], command, parsed.passthrough);
57
- return runSteps([delegate], context.monorepoRoot, runOptions);
58
- }
59
- if (parsed.recursive) {
60
- const delegateEnv = { ...childEnv, [RUN_IF_PRESENT_ENV_VAR]: '1' };
61
- const delegate = composeDelegate(['--recursive'], command, parsed.passthrough);
62
- return runSteps([delegate], context.monorepoRoot, { ...runOptions, env: delegateEnv });
82
+ const delegation = composeDelegation({ childEnv, command, parsed });
83
+ if (delegation !== undefined) {
84
+ return runSteps([delegation.step], context.monorepoRoot, { ...runOptions, env: delegation.env });
63
85
  }
64
86
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
65
87
  const resolved = resolveScript(command, registry, anchorDir, parsed.workspaceRoot);
@@ -71,22 +93,34 @@ export async function runCli(options) {
71
93
  return { exitCode: 1 };
72
94
  }
73
95
  const resolvedCommand = renderChain(resolved.steps);
74
- const skipExitCode = handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout);
75
- if (skipExitCode !== undefined) {
76
- return { exitCode: skipExitCode };
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 };
77
100
  }
78
101
  const substitutedSteps = applyDevBinToSteps(resolved.steps, context.config.devBin, context.monorepoRoot);
79
102
  const substitutedCommand = renderChain(substitutedSteps);
80
- const mainSteps = appendPassthrough(substitutedSteps, parsed.passthrough);
103
+ const bound = bindPassthrough(substitutedSteps, parsed.passthrough, command);
104
+ if (!bound.ok) {
105
+ reportError(bound.error, stderr);
106
+ return { exitCode: 1 };
107
+ }
108
+ const mainSteps = bound.steps;
81
109
  const isHookInvocation = isHookName(command);
82
110
  const fullSteps = isHookInvocation
83
111
  ? mainSteps
84
112
  : wrapWithHooks(command, mainSteps, registry, anchorDir, parsed.workspaceRoot);
85
113
  const fullCommand = renderChain(fullSteps);
86
- const shelledStep = findShelledNmrStep(fullSteps);
87
- if (shelledStep !== undefined) {
88
- stderr.write(`${formatShelledNmrWarning(command, shelledStep)}\n`);
89
- }
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
+ });
90
124
  const key = resolveCacheKey({
91
125
  anchorDir,
92
126
  command,
@@ -97,7 +131,22 @@ export async function runCli(options) {
97
131
  stderr,
98
132
  substitution: substitutedCommand === resolvedCommand ? undefined : substitutedCommand,
99
133
  });
100
- 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({
101
150
  anchorDir,
102
151
  command,
103
152
  commandString: fullCommand,
@@ -108,58 +157,165 @@ export async function runCli(options) {
108
157
  monorepoRoot: context.monorepoRoot,
109
158
  noCache,
110
159
  overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, quiet),
111
- quiet,
160
+ ownSteps: mainSteps,
161
+ runId,
112
162
  runOptions,
113
163
  snapshot,
114
164
  stderr,
115
165
  stdout,
116
166
  });
167
+ reportVerdict({ command, scope, ...outcome }, stdout, format);
117
168
  return { exitCode };
118
169
  }
119
- function buildChildEnv(env, snapshot, noCache, verbosity) {
170
+ function buildChildEnv(options) {
171
+ const { env, format, noCache, passthrough, runId, snapshot, verbosity } = options;
172
+ const bypassesCache = noCache || passthrough.length > 0;
120
173
  return {
121
174
  ...env,
122
175
  ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
123
- ...(noCache && { [NO_CACHE_ENV_VAR]: '1' }),
176
+ ...(bypassesCache && { [NO_CACHE_ENV_VAR]: '1' }),
124
177
  [COMMAND_VERBOSITY_ENV_VAR]: verbosity,
178
+ [REPORT_FORMAT_ENV_VAR]: format,
179
+ [RUN_ID_ENV_VAR]: runId,
125
180
  };
126
181
  }
127
- function appendPassthrough(steps, passthrough) {
128
- const last = steps.at(-1);
129
- if (passthrough.length === 0 || last === undefined) {
130
- return steps;
131
- }
132
- const bound = last.kind === 'structural'
133
- ? { kind: 'structural', argv: [...last.argv, ...passthrough] }
134
- : { kind: 'opaque', command: `${last.command} ${passthrough.map(shellQuote).join(' ')}` };
135
- return [...steps.slice(0, -1), bound];
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
+ },
227
+ };
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 };
136
282
  }
137
- function composeDelegate(scope, command, passthrough) {
138
- return { kind: 'structural', argv: ['pnpm', ...scope, 'exec', 'nmr', command, ...passthrough] };
283
+ function formatNmrCrossingWarning(options) {
284
+ const { remedy, subject } = describeCrossingRemedy(options);
285
+ return `⚠️ ${subject} reaches nmr through a shell (\`${options.crossing}\`), ${CROSSING_CONSEQUENCE} ${remedy}`;
139
286
  }
140
287
  function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
141
288
  const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
142
- if (quiet || resolved.source !== 'package' || registryEntry === undefined) {
289
+ if (quiet || resolved.origin.tier !== 'package' || registryEntry === undefined) {
143
290
  return undefined;
144
291
  }
145
292
  return `📦 ${path.basename(anchorDir)}: Using override script: ${renderChain(resolved.steps)}\n`;
146
293
  }
147
- function formatShelledNmrWarning(command, step) {
148
- return (`⚠️ ${command}: \`${step}\` runs nmr behind a shell, so nmr handles its output as a tool's rather than a ` +
149
- `nested run's. A step list in \`.config/nmr.config.ts\` avoids it.`);
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;
150
312
  }
151
- function handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout) {
313
+ function findNoOpReason(resolvedCommand) {
152
314
  if (resolvedCommand === '') {
153
- if (!quiet) {
154
- stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is defined but empty. Skipping.\n`);
155
- }
156
- return 0;
315
+ return 'empty-override';
157
316
  }
158
317
  if (resolvedCommand === ':') {
159
- if (!quiet) {
160
- stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is a no-op. Skipping.\n`);
161
- }
162
- return 0;
318
+ return 'noop-override';
163
319
  }
164
320
  return undefined;
165
321
  }
@@ -191,20 +347,23 @@ async function lookUpRecordedPass(options) {
191
347
  writeDebugNote(`running ${command}: ${stale}'s build output came from a different tree`, env, stderr);
192
348
  return undefined;
193
349
  }
194
- 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
+ };
195
356
  }
196
357
  function openGate(options) {
197
358
  const { command, config, env, monorepoRoot, passthrough, stderr } = options;
198
- const covered = !isHookName(command) &&
199
- config.checkCache?.enabled !== false &&
200
- resolveCacheableCommands(config.checkCache).has(command);
201
- if (!covered) {
359
+ if (!isCacheableCommand(config.checkCache, command)) {
202
360
  return undefined;
203
361
  }
204
362
  if (passthrough.includes('--no-cache')) {
205
363
  stderr.write(`${formatMisplacedNoCacheWarning(command)}\n`);
206
364
  }
207
365
  if (passthrough.length > 0) {
366
+ writeDebugNote(`gate disabled: ${command} was passed arguments`, env, stderr);
208
367
  return undefined;
209
368
  }
210
369
  const snapshot = resolveTreeSnapshot({ monorepoRoot, env });
@@ -216,6 +375,8 @@ function openGate(options) {
216
375
  }
217
376
  function parseArgs(args) {
218
377
  const parsed = {
378
+ json: false,
379
+ log: false,
219
380
  noCache: false,
220
381
  quiet: false,
221
382
  recursive: false,
@@ -264,6 +425,16 @@ function parseArgs(args) {
264
425
  i++;
265
426
  continue;
266
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
+ }
267
438
  if (arg === '--no-cache') {
268
439
  parsed.noCache = true;
269
440
  i++;
@@ -273,7 +444,65 @@ function parseArgs(args) {
273
444
  parsed.passthrough = args.slice(i + 1);
274
445
  break;
275
446
  }
276
- 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);
277
506
  }
278
507
  async function recordPass(options) {
279
508
  const { anchorDir, command, env, monorepoRoot, snapshot, stderr } = options;
@@ -297,11 +526,22 @@ async function recordPass(options) {
297
526
  writeDebugNote(`not recording ${command}: ${changed}'s build output changed while it ran`, env, stderr);
298
527
  return;
299
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);
300
541
  try {
542
+ await recordTranscript(ref, transcript);
301
543
  await writeCheckCacheEntry({
302
- anchorDir,
303
- command,
304
- monorepoRoot,
544
+ ...ref,
305
545
  entry: {
306
546
  key: options.key,
307
547
  treeHash: snapshot.hash,
@@ -312,12 +552,17 @@ async function recordPass(options) {
312
552
  durationMs: options.durationMs,
313
553
  recordedAt: new Date().toISOString(),
314
554
  buildDigests: output.digests,
555
+ ...(retention !== undefined && { retention }),
315
556
  },
316
557
  });
317
558
  }
318
559
  catch (error) {
319
- const message = error instanceof Error ? error.message : String(error);
320
- 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);
321
566
  }
322
567
  }
323
568
  function resolveCacheKey(options) {
@@ -346,31 +591,44 @@ function resolveCacheKey(options) {
346
591
  }
347
592
  async function runGated(options) {
348
593
  const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
594
+ const quiet = options.runOptions.quiet === true;
349
595
  const gate = key !== undefined && snapshot !== undefined
350
- ? { 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
+ }
351
606
  : undefined;
352
607
  if (gate !== undefined && !options.noCache) {
353
- const skipLine = await lookUpRecordedPass({
608
+ const recalled = await lookUpRecordedPass({
354
609
  anchorDir,
355
610
  buildOutput: gate.buildOutputBefore,
356
611
  command,
357
612
  env,
358
613
  key: gate.key,
359
614
  monorepoRoot,
615
+ retentionKey: gate.retentionKey,
360
616
  stderr,
361
617
  });
362
- if (skipLine !== undefined) {
363
- if (!options.quiet) {
364
- 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 });
365
622
  }
366
- return 0;
623
+ return { exitCode: 0, outcome: { outcome: 'recalled', ...recall } };
367
624
  }
368
625
  }
369
626
  if (options.overrideNotice !== undefined) {
370
627
  stdout.write(options.overrideNotice);
371
628
  }
372
629
  const startedAt = Date.now();
373
- const { exitCode } = await runSteps(options.steps, anchorDir, options.runOptions);
630
+ const { exitCode, retained } = await runSteps(options.steps, anchorDir, options.runOptions);
631
+ const durationMs = Date.now() - startedAt;
374
632
  if (exitCode === 0 && gate !== undefined) {
375
633
  await recordPass({
376
634
  anchorDir,
@@ -378,15 +636,22 @@ async function runGated(options) {
378
636
  command,
379
637
  commandString,
380
638
  config: options.config,
381
- durationMs: Date.now() - startedAt,
639
+ durationMs,
382
640
  env,
383
641
  key: gate.key,
384
642
  monorepoRoot,
643
+ ownSteps: options.ownSteps,
644
+ retained,
645
+ retentionKey: gate.retentionKey,
646
+ runId: options.runId,
385
647
  snapshot: gate.snapshot,
386
648
  stderr,
387
649
  });
388
650
  }
389
- return exitCode;
651
+ return {
652
+ exitCode,
653
+ outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
654
+ };
390
655
  }
391
656
  function shellQuote(arg) {
392
657
  return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
@@ -13,6 +13,14 @@ export interface RunCommandOptions {
13
13
  env?: NodeJS.ProcessEnv;
14
14
  }
15
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
+ }
16
24
  type CommandCompletion = {
17
25
  outcome: 'exited';
18
26
  exitCode: number;
@@ -31,8 +39,6 @@ export type RunCommandResult = CommandCompletion & {
31
39
  };
32
40
  export declare function resolveChannel(stream: Writable, quiet: boolean): OutputChannel;
33
41
  export declare function resolveInheritedChannel(stream: Writable): OutputChannel;
34
- export declare function runSteps(steps: readonly Step[], cwd: string | undefined, options: RunStepsOptions): Promise<{
35
- exitCode: number;
36
- }>;
42
+ export declare function runSteps(steps: readonly Step[], cwd: string | undefined, options: RunStepsOptions): Promise<RunStepsResult>;
37
43
  export declare function runCommand(command: string, cwd: string | undefined, options: RunCommandOptions): Promise<RunCommandResult>;
38
44
  export {};
@@ -20,13 +20,23 @@ export async function runSteps(steps, cwd, options) {
20
20
  stdout: options.stdout ?? process.stdout,
21
21
  stderr: options.stderr ?? process.stderr,
22
22
  };
23
+ const parts = [];
24
+ let isCaptureWhole = true;
23
25
  for (const step of steps) {
24
- const { exitCode } = await runStep(step, cwd, resolved);
25
- if (exitCode !== 0) {
26
- return { exitCode };
26
+ const result = await runStep(step, cwd, resolved);
27
+ if (step.kind === 'opaque') {
28
+ if (result.stderr === undefined || result.stdout === undefined) {
29
+ isCaptureWhole = false;
30
+ }
31
+ else {
32
+ parts.push({ stderr: result.stderr, stdout: result.stdout });
33
+ }
34
+ }
35
+ if (result.exitCode !== 0) {
36
+ return { exitCode: result.exitCode, ...composeRetainedOutput(parts, isCaptureWhole) };
27
37
  }
28
38
  }
29
- return { exitCode: 0 };
39
+ return { exitCode: 0, ...composeRetainedOutput(parts, isCaptureWhole) };
30
40
  }
31
41
  export async function runCommand(command, cwd, options) {
32
42
  return runSpawned({ args: [], file: command, useShell: true }, cwd, options);
@@ -80,6 +90,17 @@ function runStep(step, cwd, options) {
80
90
  channels: { stderr: resolveInheritedChannel(stderr), stdout: resolveInheritedChannel(stdout) },
81
91
  });
82
92
  }
93
+ function composeRetainedOutput(parts, isCaptureWhole) {
94
+ if (!isCaptureWhole || parts.length === 0) {
95
+ return {};
96
+ }
97
+ return {
98
+ retained: {
99
+ stderr: Buffer.concat(parts.map((part) => part.stderr)),
100
+ stdout: Buffer.concat(parts.map((part) => part.stdout)),
101
+ },
102
+ };
103
+ }
83
104
  function captureStream(source, destination) {
84
105
  const retained = createBoundedBuffer();
85
106
  let isComplete = true;
@@ -1,2 +1,2 @@
1
- export type { ScriptRegistry, ScriptValue } from './default-scripts.js';
1
+ export type { ScriptRegistry, ScriptValue, StepSpec } from './default-scripts.js';
2
2
  export { getDefaultRootScripts, getDefaultWorkspaceScripts } from './resolve-scripts.js';