@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.
- package/.readyup/kits/default.js +33 -22
- package/.readyup/manifest.json +2 -2
- package/README.md +327 -78
- package/agents/guidance/rulebooks/nmr.md +4 -2
- package/bin/nmr-report-catalog.js +11 -0
- package/dist/esm/UserError.d.ts +3 -0
- package/dist/esm/UserError.js +6 -0
- package/dist/esm/check-cache.d.ts +36 -1
- package/dist/esm/check-cache.js +93 -24
- package/dist/esm/cli-build.js +2 -1
- package/dist/esm/cli-clean.js +2 -1
- package/dist/esm/cli-ensure-prepublish-hooks.js +2 -1
- package/dist/esm/cli-report-catalog.d.ts +1 -0
- package/dist/esm/cli-report-catalog.js +11 -0
- package/dist/esm/cli-report-overrides.js +2 -1
- package/dist/esm/cli-taze.js +2 -1
- package/dist/esm/cli.js +2 -1
- package/dist/esm/commands/clean.js +9 -5
- package/dist/esm/commands/report-catalog.d.ts +1 -0
- package/dist/esm/commands/report-catalog.js +34 -0
- package/dist/esm/commands/report-overrides.js +22 -7
- package/dist/esm/commands/taze.js +2 -1
- package/dist/esm/config.d.ts +1 -0
- package/dist/esm/config.js +81 -16
- package/dist/esm/default-scripts.d.ts +5 -1
- package/dist/esm/default-scripts.js +19 -16
- package/dist/esm/defineConfig.d.ts +1 -1
- package/dist/esm/help.js +3 -1
- package/dist/esm/helpers/clampToBytes.d.ts +2 -0
- package/dist/esm/helpers/clampToBytes.js +20 -0
- package/dist/esm/helpers/createBoundedBuffer.js +1 -4
- package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
- package/dist/esm/helpers/deriveExcerpt.js +30 -0
- package/dist/esm/helpers/duration.js +16 -9
- package/dist/esm/helpers/package-json.d.ts +11 -4
- package/dist/esm/helpers/package-json.js +51 -22
- package/dist/esm/helpers/readStringValues.d.ts +1 -0
- package/dist/esm/helpers/readStringValues.js +8 -0
- package/dist/esm/helpers/transcript.d.ts +5 -0
- package/dist/esm/helpers/transcript.js +59 -0
- package/dist/esm/recording.d.ts +65 -0
- package/dist/esm/recording.js +96 -0
- package/dist/esm/replay-assembly.d.ts +9 -0
- package/dist/esm/replay-assembly.js +26 -0
- package/dist/esm/report-format.d.ts +18 -0
- package/dist/esm/report-format.js +25 -0
- package/dist/esm/reportCliFailure.d.ts +2 -0
- package/dist/esm/reportCliFailure.js +11 -0
- package/dist/esm/resolve-scripts.d.ts +1 -1
- package/dist/esm/resolver.d.ts +13 -3
- package/dist/esm/resolver.js +34 -19
- package/dist/esm/runCli.d.ts +1 -0
- package/dist/esm/runCli.js +372 -70
- package/dist/esm/runner.d.ts +20 -1
- package/dist/esm/runner.js +72 -12
- package/dist/esm/scripts.d.ts +1 -1
- package/dist/esm/steps.d.ts +18 -0
- package/dist/esm/steps.js +144 -0
- package/dist/esm/types.d.ts +11 -2
- package/dist/esm/verbosity.d.ts +22 -0
- package/dist/esm/verbosity.js +37 -0
- package/dist/esm/verdict.d.ts +29 -0
- package/dist/esm/verdict.js +150 -0
- package/dist/esm/workspace.d.ts +2 -0
- package/dist/esm/workspace.js +20 -3
- package/package.json +12 -5
package/dist/esm/runCli.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { readPackageVersion, reportError } from '@williamthorsen/nmr-core';
|
|
3
|
-
import {
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
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
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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[
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
67
|
-
const mainCommand = substitutedCommand + passthrough;
|
|
108
|
+
const mainSteps = bound.steps;
|
|
68
109
|
const isHookInvocation = isHookName(command);
|
|
69
|
-
const
|
|
70
|
-
?
|
|
71
|
-
: wrapWithHooks(command,
|
|
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 ===
|
|
132
|
+
substitution: substitutedCommand === resolvedCommand ? undefined : substitutedCommand,
|
|
81
133
|
});
|
|
82
|
-
|
|
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,
|
|
92
|
-
|
|
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(
|
|
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
|
-
...(
|
|
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.
|
|
289
|
+
if (quiet || resolved.origin.tier !== 'package' || registryEntry === undefined) {
|
|
110
290
|
return undefined;
|
|
111
291
|
}
|
|
112
|
-
return `📦 ${path.basename(anchorDir)}: Using override script: ${resolved.
|
|
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
|
|
313
|
+
function findNoOpReason(resolvedCommand) {
|
|
115
314
|
if (resolvedCommand === '') {
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
? {
|
|
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
|
|
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 (
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
357
|
-
const
|
|
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
|
-
|
|
662
|
+
steps.push(composeNmrStep(`${command}:pre`, workspaceRoot));
|
|
361
663
|
}
|
|
362
|
-
|
|
664
|
+
steps.push(...mainSteps);
|
|
363
665
|
if (hasRunnableHook(`${command}:post`, registry, anchorDir, workspaceRoot)) {
|
|
364
|
-
|
|
666
|
+
steps.push(composeNmrStep(`${command}:post`, workspaceRoot));
|
|
365
667
|
}
|
|
366
|
-
return
|
|
668
|
+
return steps;
|
|
367
669
|
}
|
package/dist/esm/runner.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|