rman 1.1.1 → 1.2.1

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.
@@ -1,11 +1,13 @@
1
1
  import os from 'node:os';
2
+ import { inspect } from 'node:util';
2
3
  import colors from 'ansi-colors';
3
4
  import { tokenize } from 'fast-tokenizer';
4
5
  import { Task } from 'power-tasks';
5
6
  import { exec } from '../utils/exec.js';
6
- import { LOG_LEVELS, resolveRootLogLevel } from '../utils/logger.js';
7
+ import { LOG_LEVELS, Logger, resolveRootLogLevel } from '../utils/logger.js';
7
8
  import { filterPackages } from '../utils/package-filter.js';
8
9
  import { ProgressPanel } from '../utils/progress-panel.js';
10
+ import { runBin } from '../utils/run-bin.js';
9
11
  export var RunService;
10
12
  (function (RunService) {
11
13
  /**
@@ -53,26 +55,88 @@ export var RunService;
53
55
  * the copies would sit in the file that writes versions - which now runs no command of its own at
54
56
  * all.
55
57
  *
56
- * `fallback` is the caller's own configured command, **already evaluated**: `version`'s three
58
+ * `fallback` is the caller's own configured step(s), **already evaluated**: `version`'s three
57
59
  * paths are in `DEFERRED_PATHS` precisely because only the caller can bind
58
60
  * `${{ pkg.targetVersion }}`, so interpolating here would either be too early or need a scope this
59
61
  * service has no business holding.
62
+ *
63
+ * **A list, not one joined string.** `VersionService` used to `join(' && ')` an array into a
64
+ * single shell line, which a function step cannot be part of - and which quietly changed the
65
+ * semantics of the shell case too, since `cd x && y` in one process is not the same as two.
60
66
  */
61
67
  async function runLifecycleSlot(pkg, script, slot, fallback) {
62
68
  const own = contributedSlots(pkg, script)?.[slot] ?? [];
63
- const commands = own.length ? own : fallback ? [fallback] : [];
64
- for (const command of commands)
65
- await exec(command, { cwd: pkg.dirname, stdio: 'inherit' });
69
+ const values = own.length ? own : (fallback ?? []);
70
+ for (const value of values) {
71
+ if (typeof value === 'function') {
72
+ await value(createStepContext(pkg, pkg.dirname));
73
+ continue;
74
+ }
75
+ await exec(value, { cwd: pkg.dirname, stdio: 'inherit' });
76
+ }
66
77
  }
67
78
  RunService.runLifecycleSlot = runLifecycleSlot;
68
79
  function getConfig(pkg, script) {
69
80
  const runCfg = pkg.config?.run;
70
81
  const cfg = runCfg && typeof runCfg === 'object' ? runCfg[script] : undefined;
71
- if (typeof cfg === 'string' || Array.isArray(cfg))
82
+ /** The bare-value shorthand. A function is `typeof 'function'`, not `'object'`, so without
83
+ * naming it here `run: { build: myFn }` fell through to the `{}` below - the long form would
84
+ * have worked and the short one silently done nothing, an arbitrary difference. */
85
+ if (typeof cfg === 'string' || typeof cfg === 'function' || Array.isArray(cfg))
72
86
  return { exec: cfg };
73
87
  return cfg && typeof cfg === 'object' ? cfg : {};
74
88
  }
75
89
  RunService.getConfig = getConfig;
90
+ /**
91
+ * The context a function step or `if` is handed - see [`RunStepContext`](../core/run-step.ts).
92
+ *
93
+ * `runBin` and `logger` are bound to *this run* rather than left to be imported, which is the
94
+ * whole reason they are handed over: an imported `runBin` knows neither the cwd nor the resolved
95
+ * log level.
96
+ */
97
+ /**
98
+ * A `run.<script>.before`/`.exec`/`.after` (or `version.<slot>`) value: one step, or several to
99
+ * run in sequence. A shell command or a function, and a list may mix them.
100
+ *
101
+ * **Anything else throws, naming the path.** It used to `return []`, which meant a value rman did
102
+ * not recognize was dropped with no trace: writing a function here - the obvious guess, and now
103
+ * the supported form - produced `1 succeeded, 0 failed` with the step never run (measured). A
104
+ * configuration mistake has to be loud; silence here reads as success.
105
+ *
106
+ * Exported, and the only implementation: `VersionService` used to carry a second one that behaved
107
+ * differently, which is how `version.<slot>` came to join its array with `' && '`.
108
+ */
109
+ function normalizeScriptValue(value, at) {
110
+ const items = Array.isArray(value) ? value : [value];
111
+ const steps = [];
112
+ for (let i = 0; i < items.length; i++) {
113
+ const item = items[i];
114
+ /** An empty string and an absent value are both "nothing here", which is how a `"[*]"` block
115
+ * declaring a slot some packages don't use has always behaved. */
116
+ if (item === undefined || item === null || item === '')
117
+ continue;
118
+ if (typeof item === 'string' || typeof item === 'function') {
119
+ steps.push(item);
120
+ continue;
121
+ }
122
+ const where = Array.isArray(value) ? `${at}[${i}]` : at;
123
+ throw new Error(`"${where}" must be a shell command or a function, but it is ${describeValue(item)}.\n` +
124
+ ` A list of either (or both) runs them in sequence.`);
125
+ }
126
+ return steps;
127
+ }
128
+ RunService.normalizeScriptValue = normalizeScriptValue;
129
+ function createStepContext(pkg, cwd) {
130
+ const logLevel = resolveRootLogLevel(pkg.repository);
131
+ return {
132
+ pkg,
133
+ repository: pkg.repository,
134
+ cwd,
135
+ runBin: (bin, argv, opts) => runBin(bin, argv, { cwd, logLevel, ...opts }),
136
+ logger: new Logger(logLevel),
137
+ };
138
+ }
139
+ RunService.createStepContext = createStepContext;
76
140
  function resolveEnvPlaceholders(value) {
77
141
  return value.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => process.env[name] ?? '');
78
142
  }
@@ -180,7 +244,7 @@ export var RunService;
180
244
  * abort using *its own* resolved bail setting (see `runSteps` below) - power-tasks' own
181
245
  * `bail` is a single blanket policy for the whole batch, it can't vary per package. */
182
246
  let rootTask;
183
- const runSteps = async (ctx, pkgLabel, steps, cwd, pkgBail, pkgLogLevel) => {
247
+ const runSteps = async (ctx, pkg, pkgLabel, steps, cwd, pkgBail, pkgLogLevel) => {
184
248
  ctx.status = 'running';
185
249
  ctx.startedAt = Date.now();
186
250
  try {
@@ -189,15 +253,14 @@ export var RunService;
189
253
  ctx.currentStep = step.name;
190
254
  ctx.stepIndex = i;
191
255
  if (panel.enabled) {
192
- await exec(step.command, {
193
- cwd,
194
- stdio: 'pipe',
195
- onLine: (line, stdio) => {
196
- ctx.log.push(line);
197
- ctx.lastLine = line;
198
- void stdio;
199
- },
200
- });
256
+ const onLine = (line) => {
257
+ ctx.log.push(line);
258
+ ctx.lastLine = line;
259
+ };
260
+ if (step.run)
261
+ await runFunctionStep(step.run, pkg, cwd, onLine);
262
+ else
263
+ await exec(step.command, { cwd, stdio: 'pipe', onLine });
201
264
  }
202
265
  else {
203
266
  /** Match the classic rman output: raw command output streams straight through
@@ -206,7 +269,12 @@ export var RunService;
206
269
  const stepStart = Date.now();
207
270
  let stepError;
208
271
  try {
209
- await exec(step.command, { cwd, stdio: 'inherit' });
272
+ /** No capture with the panel off: the step owns the terminal, exactly as a shell
273
+ * step's `stdio: 'inherit'` does. */
274
+ if (step.run)
275
+ await runFunctionStep(step.run, pkg, cwd);
276
+ else
277
+ await exec(step.command, { cwd, stdio: 'inherit' });
210
278
  }
211
279
  catch (e) {
212
280
  stepError = e;
@@ -238,9 +306,12 @@ export var RunService;
238
306
  * Only in a monorepo. Without one the root *is* the single package, already in the loop below
239
307
  * with the same hooks and the same directory - a bookend would simply run each of them a
240
308
  * second time. */
241
- const rootIf = !cwdScope && repository.monorepo && parseIfExpr(rootCfg.if);
242
- const rootIfPasses = rootIf ? await evaluateIf(repository, repository.rootPackage, rootIf, ifStatusCache) : true;
243
- const rootSkipped = !!cwdScope || !repository.monorepo || rootCfg.skip === true || !rootIfPasses;
309
+ /** Short-circuited deliberately: a root already out of the run for a structural reason must not
310
+ * have its `if` evaluated, now that evaluating one can mean calling the repository's own code. */
311
+ const rootSkipped = !!cwdScope ||
312
+ !repository.monorepo ||
313
+ rootCfg.skip === true ||
314
+ !(await passesIf(repository, repository.rootPackage, rootCfg.if, repository.dirname, ifStatusCache));
244
315
  const rootSteps = rootSkipped ? [] : getScriptSteps(repository.rootPackage, script);
245
316
  /** Filtered on the slot, not on `'pre' + script`: the step labels are `before`/`exec`/`after`
246
317
  * now - the same words the config uses - rather than npm's `pre<script>` naming, which moved
@@ -253,7 +324,7 @@ export var RunService;
253
324
  const ctx = panel.addItem(rootPreName, rootPre.length);
254
325
  const pkgBail = resolveBail(options.bail, repository.rootPackage, script, true);
255
326
  const pkgLogLevel = resolveLogLevel(options.logLevel, repository.rootPackage, script, logLevelDefault);
256
- children.push(new Task(() => runSteps(ctx, 'root', rootPre, repository.dirname, pkgBail, pkgLogLevel), {
327
+ children.push(new Task(() => runSteps(ctx, repository.rootPackage, 'root', rootPre, repository.dirname, pkgBail, pkgLogLevel), {
257
328
  name: ctx.name,
258
329
  exclusive: true,
259
330
  }));
@@ -263,8 +334,7 @@ export var RunService;
263
334
  const pkgCfg = getConfig(pkg, script);
264
335
  if (pkgCfg.skip === true)
265
336
  continue;
266
- const pkgIf = parseIfExpr(pkgCfg.if);
267
- if (pkgIf && !(await evaluateIf(repository, pkg, pkgIf, ifStatusCache)))
337
+ if (!(await passesIf(repository, pkg, pkgCfg.if, pkg.dirname, ifStatusCache)))
268
338
  continue;
269
339
  const steps = getScriptSteps(pkg, script);
270
340
  if (steps.length)
@@ -283,7 +353,7 @@ export var RunService;
283
353
  const dependencies = pkgTopo ? pkg.dependencies.filter(d => stepsByPackage.has(d.name)).map(d => d.name) : [];
284
354
  if (rootPre.length)
285
355
  dependencies.push(rootPreName);
286
- children.push(new Task(() => runSteps(ctx, pkg.name, steps, pkg.dirname, pkgBail, pkgLogLevel), {
356
+ children.push(new Task(() => runSteps(ctx, pkg, pkg.name, steps, pkg.dirname, pkgBail, pkgLogLevel), {
287
357
  name: ctx.name,
288
358
  dependencies,
289
359
  }));
@@ -292,7 +362,7 @@ export var RunService;
292
362
  const ctx = panel.addItem(rootPostName, rootPost.length);
293
363
  const pkgBail = resolveBail(options.bail, repository.rootPackage, script, true);
294
364
  const pkgLogLevel = resolveLogLevel(options.logLevel, repository.rootPackage, script, logLevelDefault);
295
- children.push(new Task(() => runSteps(ctx, 'root', rootPost, repository.dirname, pkgBail, pkgLogLevel), {
365
+ children.push(new Task(() => runSteps(ctx, repository.rootPackage, 'root', rootPost, repository.dirname, pkgBail, pkgLogLevel), {
296
366
  name: ctx.name,
297
367
  exclusive: true,
298
368
  /** Must wait for every package task to finish, not just be "exclusive" once it starts. */
@@ -375,7 +445,7 @@ function printLegacyExecutingLine(commandName, pkgLabel, step, level) {
375
445
  if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf('verbose'))
376
446
  return;
377
447
  const sep = colors.gray('┆');
378
- console.log(colors.magenta('verbose'), commandName, colors.cyan(pkgLabel), sep, colors.cyanBright.bold(step.name), colors.cyanBright.bold('executing'), sep, step.command);
448
+ console.log(colors.magenta('verbose'), commandName, colors.cyan(pkgLabel), sep, colors.cyanBright.bold(step.name), colors.cyanBright.bold('executing'), sep, step.label);
379
449
  }
380
450
  function printLegacyStepLine(commandName, pkgLabel, step, durationMs, level, error) {
381
451
  if (!error && LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf('info'))
@@ -385,15 +455,77 @@ function printLegacyStepLine(commandName, pkgLabel, step, durationMs, level, err
385
455
  const sep = colors.gray('┆');
386
456
  const levelLabel = error ? colors.red('error') : colors.green('info');
387
457
  const status = error ? colors.red.bold('failed') : colors.green.bold('success');
388
- console.log(levelLabel, commandName, colors.cyan(pkgLabel), sep, colors.cyanBright.bold(step.name), status, sep, step.command, colors.yellow(` (${durationMs} ms)`));
458
+ console.log(levelLabel, commandName, colors.cyan(pkgLabel), sep, colors.cyanBright.bold(step.name), status, sep, step.label, colors.yellow(` (${durationMs} ms)`));
389
459
  }
390
- /** A `run.<script>.before`/`.exec`/`.after` value: one command, or several to run in sequence. */
391
- function normalizeScriptValue(value) {
392
- if (typeof value === 'string')
393
- return value ? [value] : [];
460
+ function describeValue(value) {
394
461
  if (Array.isArray(value))
395
- return value.filter((v) => typeof v === 'string' && !!v);
396
- return [];
462
+ return 'a nested array';
463
+ if (value && typeof value === 'object')
464
+ return 'an object';
465
+ return `a ${typeof value} (${JSON.stringify(value)})`;
466
+ }
467
+ /** One step, with the label the panel and the per-step log show. A function's own name - so
468
+ * `function copyDocs()` and `const copyDocs = () => {}` both read as `copyDocs` - falling back to
469
+ * the slot's own word for one passed inline, which has no name at all. */
470
+ function toStep(slot, value) {
471
+ if (typeof value === 'function')
472
+ return { name: slot, label: value.name || `${slot} (js)`, run: value };
473
+ return { name: slot, label: value, command: value };
474
+ }
475
+ /**
476
+ * Runs a function step.
477
+ *
478
+ * **`console` is redirected while it runs, but only when the panel is on** - and that is the same
479
+ * split a shell step already makes. `exec` hands the panel its output through `stdio: 'pipe'` and
480
+ * `onLine`, so a function writing straight to the real stdout would print *over* the panel it is
481
+ * being rendered inside. With the panel off, `exec` uses `stdio: 'inherit'` and the step owns the
482
+ * terminal; a function gets the same, untouched.
483
+ *
484
+ * Restored in a `finally`, because a step that throws must not leave the rest of the run writing
485
+ * into a log nobody reads.
486
+ */
487
+ async function runFunctionStep(run, pkg, cwd, onLine) {
488
+ const context = RunService.createStepContext(pkg, cwd);
489
+ if (!onLine) {
490
+ await run(context);
491
+ return;
492
+ }
493
+ const console_ = globalThis.console;
494
+ const original = {};
495
+ for (const method of CAPTURED_CONSOLE) {
496
+ original[method] = console_[method];
497
+ console_[method] = (...args) => {
498
+ /** Split, because one `console.log` may carry several lines and the panel's log is a list of
499
+ * them - a multi-line entry would render as one unreadable row. */
500
+ for (const line of format(args).split('\n'))
501
+ onLine(line);
502
+ };
503
+ }
504
+ try {
505
+ await run(context);
506
+ }
507
+ finally {
508
+ for (const method of CAPTURED_CONSOLE)
509
+ console_[method] = original[method];
510
+ }
511
+ }
512
+ const CAPTURED_CONSOLE = ['log', 'info', 'warn', 'error', 'debug'];
513
+ function format(args) {
514
+ return args.map(arg => (typeof arg === 'string' ? arg : inspect(arg))).join(' ');
515
+ }
516
+ /**
517
+ * Whether a script runs for `pkg` at all - `run.<script>.if`, in either of its two forms.
518
+ *
519
+ * The function form is checked **first**: `parseIfExpr` answers `undefined` for anything that is
520
+ * not a string, which the caller reads as "no condition given", so a function reaching it would be
521
+ * a condition that silently always passed.
522
+ */
523
+ async function passesIf(repository, pkg, raw, cwd, statusCache) {
524
+ if (typeof raw === 'function') {
525
+ return !!(await raw(RunService.createStepContext(pkg, cwd)));
526
+ }
527
+ const node = RunService.parseIfExpr(raw);
528
+ return node ? RunService.evaluateIf(repository, pkg, node, statusCache) : true;
397
529
  }
398
530
  /**
399
531
  * Resolves a package's steps for `script`, from its `.rmanrc` and from whatever sources plugins
@@ -419,17 +551,17 @@ function getScriptSteps(pkg, script) {
419
551
  const override = cfg.override === true;
420
552
  const contributed = firstContributed(pkg, script);
421
553
  const fromConfig = {
422
- before: normalizeScriptValue(cfg.before),
423
- exec: normalizeScriptValue(cfg.exec),
424
- after: normalizeScriptValue(cfg.after),
554
+ before: RunService.normalizeScriptValue(cfg.before, `run.${script}.before`),
555
+ exec: RunService.normalizeScriptValue(cfg.exec, `run.${script}.exec`),
556
+ after: RunService.normalizeScriptValue(cfg.after, `run.${script}.after`),
425
557
  };
426
558
  const steps = [];
427
559
  for (const slot of SCRIPT_SLOTS) {
428
560
  const own = contributed?.[slot] ?? [];
429
561
  const configured = fromConfig[slot] ?? [];
430
- const commands = override ? (configured.length ? configured : own) : own.length ? own : configured;
431
- for (const command of commands)
432
- steps.push({ name: slot, command });
562
+ const values = override ? (configured.length ? configured : own) : own.length ? own : configured;
563
+ for (const value of values)
564
+ steps.push(toStep(slot, value));
433
565
  }
434
566
  return steps;
435
567
  }
@@ -1,5 +1,6 @@
1
1
  import type { Package } from '../core/package.js';
2
2
  import type { Repository } from '../core/repository.js';
3
+ import type { RunStepValue } from '../core/run-step.js';
3
4
  import { VersionPlanService } from './version-plan.service.js';
4
5
  /**
5
6
  * Applying a version plan: the writes. Every manifest edit, dependency-range refresh, stamp,
@@ -74,9 +75,18 @@ export declare namespace VersionService {
74
75
  * listing of `name@version` pairs when this particular commit spans different versions (a
75
76
  * cross-group ripple can land a lone forced patch in a group that otherwise didn't move). */
76
77
  function buildCommitMessage(repository: Repository, entries: VersionPlanService.Entry[], messageOverride?: string): string;
77
- /** A `version.<key>` value: one command, or several to run in sequence - same shape as
78
- * `run.<script>.script`/`.preScript`/`.postScript`. */
79
- function normalizeScriptValue(value: unknown): string | undefined;
78
+ /**
79
+ * A `version.<slot>` value: one step, or several to run in sequence - the same shape, and now the
80
+ * same function, as `run.<script>.before`/`.exec`/`.after`.
81
+ *
82
+ * It used to be a second implementation living here, and it differed in two ways that both had to
83
+ * go. It **joined an array with `' && '`** into one shell line, which a function step cannot be
84
+ * part of and which was not even right for shell steps - `cd x && y` in one process is not two
85
+ * processes. And it **dropped anything it did not recognize**, so a function here was silently
86
+ * never run. (The doc comment also still named `.script`/`.preScript`/`.postScript`, three keys
87
+ * that have been `before`/`exec`/`after` for a long time.)
88
+ */
89
+ function normalizeScriptValue(value: unknown, at: string): RunStepValue[];
80
90
  /**
81
91
  * Keeps a package's Dockerfile `org.opencontainers.image.version` label in step with the version
82
92
  * just written, returning the absolute path when it actually changed (so the caller can fold it
@@ -65,7 +65,10 @@ export var VersionService;
65
65
  * pre-empts it, and running the thing, are `RunService`'s (`runLifecycleSlot`), so nothing
66
66
  * here names a script, a file, or a shell.
67
67
  */
68
- const hook = (slot) => RunService.runLifecycleSlot(pkg, VERSION_LIFECYCLE, slot, normalizeScriptValue(interpolateConfig(pkg.config?.version?.[slot], scope)));
68
+ const hook = (slot) => RunService.runLifecycleSlot(pkg, VERSION_LIFECYCLE, slot, RunService.normalizeScriptValue(
69
+ /** `at`: the path is what tells a step function from a value one, and this is a fragment -
70
+ * without it a function here was called while the hook was being prepared. */
71
+ interpolateConfig(pkg.config?.version?.[slot], scope, { at: ['version', slot] }), `version.${slot}`));
69
72
  await hook('before');
70
73
  /** Through the manifest, not through a `package.json` field: where a version is written is
71
74
  * the provider's business (see `ManifestProvider`), and this is the one place rman changes
@@ -198,16 +201,19 @@ export var VersionService;
198
201
  return `chore(release): ${entries.map(e => `${e.package.name}@${e.to}`).join(', ')}`;
199
202
  }
200
203
  VersionService.buildCommitMessage = buildCommitMessage;
201
- /** A `version.<key>` value: one command, or several to run in sequence - same shape as
202
- * `run.<script>.script`/`.preScript`/`.postScript`. */
203
- function normalizeScriptValue(value) {
204
- if (typeof value === 'string')
205
- return value || undefined;
206
- if (Array.isArray(value)) {
207
- const parts = value.filter((v) => typeof v === 'string' && !!v);
208
- return parts.length ? parts.join(' && ') : undefined;
209
- }
210
- return undefined;
204
+ /**
205
+ * A `version.<slot>` value: one step, or several to run in sequence - the same shape, and now the
206
+ * same function, as `run.<script>.before`/`.exec`/`.after`.
207
+ *
208
+ * It used to be a second implementation living here, and it differed in two ways that both had to
209
+ * go. It **joined an array with `' && '`** into one shell line, which a function step cannot be
210
+ * part of and which was not even right for shell steps - `cd x && y` in one process is not two
211
+ * processes. And it **dropped anything it did not recognize**, so a function here was silently
212
+ * never run. (The doc comment also still named `.script`/`.preScript`/`.postScript`, three keys
213
+ * that have been `before`/`exec`/`after` for a long time.)
214
+ */
215
+ function normalizeScriptValue(value, at) {
216
+ return RunService.normalizeScriptValue(value, at);
211
217
  }
212
218
  VersionService.normalizeScriptValue = normalizeScriptValue;
213
219
  /**
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A copy of a resolved config with every value a serializer cannot represent replaced by a short
3
+ * description of it - for `rman config` and `--config`, which exist to be *looked at*.
4
+ *
5
+ * Needed because a config legitimately holds functions now: a `run.<script>` or `version.<slot>`
6
+ * step written as JavaScript, and an `if` written the same way. It was already needed before that,
7
+ * though, which is the better argument for doing it here rather than at one call site - a
8
+ * `plugins` entry given in its object form carries the plugin's seams, and `rman config --root`
9
+ * died on one with `unacceptable kind of an object to dump [object Function]` (measured, on a
10
+ * repository whose shared config did nothing more unusual than `extends` a plugin package).
11
+ *
12
+ * A function prints as `[Function: copyDocs]`, so the output says *which* one - an anonymous step
13
+ * reads as `[Function]`, which is itself worth seeing.
14
+ */
15
+ export declare function printableConfig<T>(config: T): T;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A copy of a resolved config with every value a serializer cannot represent replaced by a short
3
+ * description of it - for `rman config` and `--config`, which exist to be *looked at*.
4
+ *
5
+ * Needed because a config legitimately holds functions now: a `run.<script>` or `version.<slot>`
6
+ * step written as JavaScript, and an `if` written the same way. It was already needed before that,
7
+ * though, which is the better argument for doing it here rather than at one call site - a
8
+ * `plugins` entry given in its object form carries the plugin's seams, and `rman config --root`
9
+ * died on one with `unacceptable kind of an object to dump [object Function]` (measured, on a
10
+ * repository whose shared config did nothing more unusual than `extends` a plugin package).
11
+ *
12
+ * A function prints as `[Function: copyDocs]`, so the output says *which* one - an anonymous step
13
+ * reads as `[Function]`, which is itself worth seeing.
14
+ */
15
+ export function printableConfig(config) {
16
+ return walk(config, new WeakSet());
17
+ }
18
+ function walk(value, seen) {
19
+ if (typeof value === 'function')
20
+ return `[Function${value.name ? `: ${value.name}` : ''}]`;
21
+ if (!value || typeof value !== 'object')
22
+ return value;
23
+ /** A resolved config is a tree, but a plugin object is arbitrary code's data and may not be.
24
+ * js-yaml's `noRefs` turns a repeat into a copy rather than an anchor, which on a true cycle
25
+ * never terminates - so the cycle is cut here, where it can be named. */
26
+ if (seen.has(value))
27
+ return '[Circular]';
28
+ seen.add(value);
29
+ try {
30
+ if (Array.isArray(value))
31
+ return value.map(item => walk(item, seen));
32
+ const result = {};
33
+ for (const [key, item] of Object.entries(value))
34
+ result[key] = walk(item, seen);
35
+ return result;
36
+ }
37
+ finally {
38
+ /** Released on the way out, so a value that merely appears twice in *different* branches - the
39
+ * ordinary case after merging - is printed both times rather than reported as a cycle. */
40
+ seen.delete(value);
41
+ }
42
+ }