rman 1.1.1 → 1.2.2

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/core/config.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { DOMParser } from '@xmldom/xmldom';
1
2
  import fs from 'fs';
3
+ import ini from 'ini';
2
4
  import * as yaml from 'js-yaml';
3
5
  import { createRequire } from 'module';
4
6
  import path from 'path';
@@ -6,7 +8,7 @@ import semver from 'semver';
6
8
  import { pathToFileURL } from 'url';
7
9
  import vm from 'vm';
8
10
  import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
9
- import { finalizeConfig, mergeConfig } from './merge-config.js';
11
+ import { finalizeConfig, mergeConfig, PREVIOUS_VALUES } from './merge-config.js';
10
12
  /**
11
13
  * Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
12
14
  * autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
@@ -290,53 +292,31 @@ function dirChain(rootDir, targetDir) {
290
292
  }
291
293
  return dirs;
292
294
  }
293
- /**
294
- * Evaluates every `${{ ... }}` expression in **every** string value of a resolved config, against
295
- * the package it was resolved for:
296
- *
297
- * ```yaml
298
- * "[*]":
299
- * clean:
300
- * include: ["build", "../../coverage/${{ pkg.basename }}"]
301
- * publish:
302
- * directory: build
303
- * docker:
304
- * image: "panates/${{ pkg.basename }}:${{ semver.major(pkg.version) }}"
305
- * run:
306
- * build:
307
- * # the config's own keys are in scope, so this is not a second copy of "build"
308
- * after: "cp README.md ${{ publish.directory }}/"
309
- * ```
310
- *
311
- * Every string, with no list of "interpolated keys" to memorize - a rule with exceptions is a rule
312
- * nobody remembers.
313
- *
314
- * The contents are **real JavaScript**, not a template mini-language, so there is no growing list
315
- * of substitutions to keep adding (`{{major}}`, `{{scope}}`, ...) - see `ConfigScope` for what is
316
- * in scope.
317
- *
318
- * **`${{ }}`, deliberately not `{{ }}`.** A config value may legitimately carry `{{...}}` meant for
319
- * something else entirely (`helm template --set tag={{.Values.tag}}`); with the plainer delimiter
320
- * rman would try to evaluate it. To emit a literal, let an expression produce it, the way GitHub
321
- * Actions does: `${{ '${{' }}`.
322
- *
323
- * A string that is *nothing but* one expression keeps the value's own type (`"${{ pkg.private }}"`
324
- * -> a boolean), since otherwise this could only ever produce strings and settings like
325
- * `run.<script>.skip` would be unreachable. Embedded in surrounding text it is stringified.
326
- *
327
- * Evaluation happens in a fresh V8 context holding only the scope's bindings. That is a clean
328
- * scope, **not a sandbox** - `node:vm` is explicitly not a security mechanism, and no sandbox is
329
- * called for here anyway: a `.rmanrc` that can say `exec: "..."` already runs arbitrary shell, so
330
- * the expression evaluator adds no trust boundary that wasn't already wide open.
331
- *
332
- * A failing expression throws with the config path that holds it, rather than being left in place:
333
- * silently passing through a mistake is how a config ends up quietly doing nothing.
334
- */
335
295
  export function interpolateConfig(config, scope, options) {
336
296
  const skip = options?.skip ?? [];
337
- const context = vm.createContext({ ...scope });
297
+ /**
298
+ * Where `config` sits in the whole config, when a caller hands over a fragment rather than the
299
+ * root - `version` interpolates its own `version.<slot>` value on its own, those three paths being
300
+ * in `DEFERRED_PATHS`.
301
+ *
302
+ * It matters because the path is what decides whether a function is a value to compute or a step
303
+ * to leave alone (`STEP_PATHS`). Without it, a fragment starts at the root and matches nothing, so
304
+ * a function in a `version` hook was called while the hook was being *prepared* - measured, and it
305
+ * failed inside the user's own code with `path.join` receiving undefined.
306
+ */
307
+ const base = options?.at ?? [];
308
+ /**
309
+ * Built from `scope`'s property **descriptors**, never `{ ...scope }`.
310
+ *
311
+ * A spread reads every property, so a lazy getter on the scope is no longer lazy the moment one
312
+ * is added - and `git` is exactly that: it shells out to `git rev-parse`, and a spread here would
313
+ * do it on `rman list`, `rman info` and every other command, in a repository whose config never
314
+ * mentions git. (The same trap `pkg.targetVersion` documents from the other side: it is a
315
+ * *throwing* getter, and being enumerable is what made a spread fire it.)
316
+ */
317
+ const context = vm.createContext(Object.defineProperties({}, Object.getOwnPropertyDescriptors(scope)));
338
318
  if (!config || typeof config !== 'object' || Array.isArray(config))
339
- return walk(config, scope, context, [], skip);
319
+ return walk(config, scope, context, base, skip);
340
320
  /**
341
321
  * The config's own top-level keys, readable bare: `${{ publish.directory }}`. So a value that
342
322
  * restates another - `after: "cp README.md ${{ publish.directory }}/"` - stops being a second
@@ -369,7 +349,8 @@ export function interpolateConfig(config, scope, options) {
369
349
  }
370
350
  resolving.push(key);
371
351
  try {
372
- const value = walk(config[key], scope, context, [key], skip);
352
+ const chain = config[PREVIOUS_VALUES];
353
+ const value = walkWithPrevious(config[key], chain?.[key], scope, context, [...base, key], skip);
373
354
  resolved.set(key, value);
374
355
  return value;
375
356
  }
@@ -409,11 +390,87 @@ export function interpolateConfig(config, scope, options) {
409
390
  * command at all would fail on a config that mentions it.
410
391
  */
411
392
  export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'];
393
+ /**
394
+ * Paths whose value is a **step** - something to run later - rather than a setting to compute now.
395
+ * `*` matches one path segment (`run.<script>.exec`).
396
+ *
397
+ * This is what tells a step function from a value function, and the two live side by side in one
398
+ * config:
399
+ *
400
+ * ```js
401
+ * '[ws:*]': {
402
+ * clean: { include: ({ vars, value }) => [...value, vars.buildDir] }, // a value: called here
403
+ * run: { build: { after: ({ pkg }) => copyDocs(pkg) } }, // a step: called by `run`
404
+ * }
405
+ * ```
406
+ *
407
+ * **The key decides, and it already did.** `run.build.exec: 'tsc -b'` is a shell command and
408
+ * `publish.directory: 'build'` is a path - not because of anything about the strings, but because of
409
+ * where they sit. A function inherits the same rule, so nothing new has to be learned and no marker
410
+ * has to be remembered. The alternative was inspecting the function (arity, parameter names), which
411
+ * is the kind of guess `loadPlugins` refuses to make about a module's export for the same reason:
412
+ * guessing wrong here means running build-time code while merely loading the repository, or
413
+ * silently never running it.
414
+ *
415
+ * A **string** at one of these paths is still interpolated - `exec: 'tsc -b ${{ file.resolve(...) }}'`
416
+ * has to keep working - so this is narrower than `DEFERRED_PATHS`, which skips its paths entirely.
417
+ */
418
+ export const STEP_PATHS = [
419
+ /** The bare-value shorthand: `run: { build: fn }` means `{ exec: fn }`, as `run: { build: 'cmd' }`
420
+ * means `{ exec: 'cmd' }`. Missing it made the two spellings disagree about *when* the function
421
+ * runs, which is worse than not supporting the short one at all. */
422
+ 'run.*',
423
+ 'run.*.before',
424
+ 'run.*.exec',
425
+ 'run.*.after',
426
+ /** A condition, evaluated per package by `RunService` when the run reaches it. Called here
427
+ * instead, it collapsed to the boolean it happened to return at load time - and `parseIfExpr`
428
+ * then read that boolean as "no condition given", so the script ran unconditionally (measured). */
429
+ 'run.*.if',
430
+ 'version.before',
431
+ 'version.exec',
432
+ 'version.after',
433
+ ];
434
+ /**
435
+ * Keys whose **whole subtree** is code rather than config, so no function under them is a value to
436
+ * compute. `plugins` is the only one, and it has to be here: an entry may be the plugin *object*
437
+ * itself, and an `RmanPlugin` is almost entirely functions - `manifest.read`, `workspace.resolve`,
438
+ * `versionPlanner`, `binPaths`, and every command's `builder` and `handler`.
439
+ *
440
+ * Measured, and it is why this exists: with `plugins` walked like any other key, resolving the
441
+ * config of a repository that named a plugin called that plugin's yargs builder with the config
442
+ * scope - `Config function in "plugins[0].commands[0].builder" failed: cmd.option is not a
443
+ * function`. A `plugins` entry is loaded by `loadPlugins`, never read as a setting.
444
+ */
445
+ export const CODE_SUBTREES = ['plugins'];
412
446
  const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
413
447
  /** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
414
448
  * `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
415
449
  const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
416
450
  /** The `file` namespace for one package's directory - see `FileScope`. */
451
+ /**
452
+ * `read` in a `${{ ... }}` expression (and in a value function): a structured file's **contents**,
453
+ * parsed - where `file` answers only where a path is.
454
+ *
455
+ * ```yaml
456
+ * "[*]":
457
+ * run:
458
+ * build:
459
+ * exec: 'tsc --outDir ${{ read("tsconfig.json").compilerOptions.outDir }}'
460
+ * ```
461
+ *
462
+ * `cache` is shared across every package (see `Repository.configScope`) and keyed by what the file
463
+ * *is*, not merely where - so the same file read by twenty packages is parsed once, and a file rman
464
+ * itself rewrites mid-run is re-read rather than remembered. See `readStructuredFile`.
465
+ */
466
+ export function createReadScope(dirname, cache) {
467
+ return (target, format) => {
468
+ if (typeof target !== 'string' || !target.trim()) {
469
+ throw new Error('read() needs a path - it was given ' + JSON.stringify(target));
470
+ }
471
+ return readStructuredFile(path.resolve(dirname, target), format, cache);
472
+ };
473
+ }
417
474
  export function createFileScope(dirname) {
418
475
  const locate = (target) => {
419
476
  if (typeof target !== 'string' || !target.trim()) {
@@ -452,18 +509,236 @@ function walk(value, scope, context, at, skip) {
452
509
  * command or an array of them - is handed on untouched. */
453
510
  if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
454
511
  return value;
512
+ if (typeof value === 'function') {
513
+ /** Code, not a value: a step for `run`/`version` to call in its own time, or a plugin's own
514
+ * function. Carried through exactly as a command string would be - calling it here would run
515
+ * build-time work while merely *loading* the repository, which is the whole distinction the
516
+ * function form exists to draw. */
517
+ if (isCodePath(at))
518
+ return value;
519
+ return callValueFn(value, context, at);
520
+ }
455
521
  if (typeof value === 'string')
456
522
  return interpolateString(value, context, at);
457
523
  if (Array.isArray(value))
458
524
  return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
459
525
  if (value && typeof value === 'object') {
460
- const result = {};
461
- for (const [key, item] of Object.entries(value))
462
- result[key] = walk(item, scope, context, [...at, key], skip);
463
- return result;
526
+ const chain = value[PREVIOUS_VALUES];
527
+ return withScopedVars(value, scope, context, at, skip, () => {
528
+ const result = {};
529
+ for (const [key, item] of Object.entries(value)) {
530
+ result[key] = walkWithPrevious(item, chain?.[key], scope, context, [...at, key], skip);
531
+ }
532
+ return result;
533
+ });
464
534
  }
465
535
  return value;
466
536
  }
537
+ /**
538
+ * Runs `body` with `vars` scoped to this node: **a fresh copy at every level**, with the node's own
539
+ * `vars` block - if it declares one - merged over what the level above resolved to.
540
+ *
541
+ * ```yaml
542
+ * vars: { x: 1 }
543
+ * run:
544
+ * vars: { x: 2 }
545
+ * clean: { before: '${{ read(vars.x + ".json") }}' } # 2.json
546
+ * build:
547
+ * vars: { x: 3 }
548
+ * before: '${{ read(vars.x + ".json") }}' # 3.json
549
+ * ```
550
+ *
551
+ * **Copied at every node, not only where a `vars` block appears**, and that is the difference
552
+ * between scoping and leaking: a value function is handed this object, so one that writes to it
553
+ * (`vars.built = Date.now()`) must not be writing into the level above. Without a copy per node,
554
+ * a write inside `run.build` would land in `run`'s object and `run.clean` would see it. Merged per
555
+ * key rather than replaced, so redeclaring one var keeps the rest - the rule the top-level `vars`
556
+ * has always followed.
557
+ *
558
+ * The node's own block is resolved **against the outer scope** before being installed, so
559
+ * `vars: { out: '${{ vars.x }}/dist' }` reads the `x` it is refining rather than itself.
560
+ *
561
+ * Installed as a plain property over the context's lazy top-level getter and restored afterwards -
562
+ * `walk` is depth-first and synchronous, so the window is exactly this subtree, and a value function
563
+ * called inside it reads the same object through its prototype.
564
+ */
565
+ function withScopedVars(node, scope, context, at, skip, body) {
566
+ /**
567
+ * **A `vars` block does not scope itself.** Resolving one walks its own values, and without this
568
+ * that walk asks for the scope it is in the middle of producing - which the cycle guard catches
569
+ * and reports as `vars -> vars`. It recovered (the guard returns `undefined`, so the block simply
570
+ * saw no outer scope, which is what it should see anyway), but it left the cycle *flag* set, and
571
+ * the next genuine error in that key came out wearing `Config expression forms a cycle` - found by
572
+ * running a real shared config, whose `[...value]` mistake arrived with a loop attached that had
573
+ * nothing to do with it.
574
+ *
575
+ * Any path with a `vars` segment is inside a block: its contents are values, not config nodes.
576
+ */
577
+ if (at.some(segment => segment === VARS_KEY))
578
+ return body();
579
+ const outer = context[VARS_KEY];
580
+ const own = node[VARS_KEY];
581
+ /** Nothing to shadow and nothing to protect: a node with no object below it can hold no function
582
+ * either, so the copy would be pure cost. */
583
+ if (own === undefined && !hasObjectChild(node))
584
+ return body();
585
+ const resolvedOwn = own === undefined ? undefined : walk(own, scope, context, [...at, VARS_KEY], skip);
586
+ const scoped = { ...outer, ...(isPlainObject(resolvedOwn) ? resolvedOwn : undefined) };
587
+ const previous = Object.getOwnPropertyDescriptor(context, VARS_KEY);
588
+ Object.defineProperty(context, VARS_KEY, { value: scoped, enumerable: true, configurable: true, writable: true });
589
+ try {
590
+ return body();
591
+ }
592
+ finally {
593
+ if (previous)
594
+ Object.defineProperty(context, VARS_KEY, previous);
595
+ else
596
+ delete context[VARS_KEY];
597
+ }
598
+ }
599
+ function hasObjectChild(node) {
600
+ for (const item of Object.values(node)) {
601
+ if (typeof item === 'function')
602
+ return true;
603
+ if (item && typeof item === 'object')
604
+ return true;
605
+ }
606
+ return false;
607
+ }
608
+ function isPlainObject(value) {
609
+ return !!value && typeof value === 'object' && !Array.isArray(value);
610
+ }
611
+ /** The one key that scopes rather than configures - see `withScopedVars`. Reserved at **every**
612
+ * level, which costs a script that would have been called `vars`: `run.vars` is a scope, not a
613
+ * script. Nothing enumerates `run`'s keys as a list of script names, so the cost stops there. */
614
+ const VARS_KEY = 'vars';
615
+ /**
616
+ * Walks one key of an object with **`value` bound** to whatever the layers below it resolved to.
617
+ *
618
+ * Bound on the interpolation context rather than passed as an argument, because an expression reads
619
+ * it as a global (`"${{ [...value, 'x'] }}"`) - a function then picks the same binding up through
620
+ * its prototype, so the two spellings cannot disagree about what `value` is. It was function-only
621
+ * at first, on the reasoning that a string cannot carry an array back; that was wrong, since a
622
+ * string which is *nothing but* one expression keeps the value's own type.
623
+ *
624
+ * **Always bound, even with nothing underneath.** Left unbound, an expression naming it fails with
625
+ * V8's `value is not defined`, which reads as "there is no such thing" rather than "nothing below
626
+ * this layer set it" - two different mistakes needing two different fixes.
627
+ *
628
+ * The chain resolves bottom-up, so a layer deriving from a layer that itself derived from something
629
+ * is handed the finished value rather than a half-resolved expression.
630
+ */
631
+ function walkWithPrevious(item, previous, scope, context, at, skip) {
632
+ const resolved = previous === undefined ? undefined : walkWithPrevious(previous.value, previous.previous, scope, context, at, skip);
633
+ const outer = Object.getOwnPropertyDescriptor(context, VALUE_KEY);
634
+ /** A getter, so the catch below can tell whether the value **actually read `value`**: the hint is
635
+ * irrelevant to any other failure, and attaching it anyway is the send-the-reader-to-the-wrong-
636
+ * place mistake it exists to prevent. Recorded, never matched on V8's wording. */
637
+ let wasRead = false;
638
+ Object.defineProperty(context, VALUE_KEY, {
639
+ configurable: true,
640
+ enumerable: true,
641
+ get: () => {
642
+ wasRead = true;
643
+ return resolved;
644
+ },
645
+ });
646
+ try {
647
+ return walk(item, scope, context, at, skip);
648
+ }
649
+ catch (e) {
650
+ /**
651
+ * **`value` is `undefined` when no layer underneath set this key**, and a value written to
652
+ * extend an inherited list is also the *first* layer in a repository that inherits nothing.
653
+ * V8 reports that as `value is not iterable`, naming neither the key nor the reason.
654
+ *
655
+ * Here rather than in `callValueFn`, so the expression and the function spelling get the same
656
+ * sentence from the same place. `rmanValueHint` keeps a rethrow from stacking it twice as the
657
+ * error passes back up through the enclosing keys.
658
+ *
659
+ * Not papered over by defaulting `value` to `[]`: that would be a guess about the key's type,
660
+ * and wrong for every key that is not a list.
661
+ */
662
+ if (wasRead && resolved === undefined && !e?.rmanValueHint) {
663
+ const where = at.length ? formatPath(at) : 'the config root';
664
+ e.rmanValueHint = true;
665
+ e.message =
666
+ `${e.message}\n \`value\` is undefined here - nothing below this layer sets "${where}".` +
667
+ `\n Write \`value ?? []\` (or \`?? ''\`) if it has to work as the first layer too.`;
668
+ }
669
+ throw e;
670
+ }
671
+ finally {
672
+ if (outer)
673
+ Object.defineProperty(context, VALUE_KEY, outer);
674
+ else
675
+ delete context[VALUE_KEY];
676
+ }
677
+ }
678
+ /** What a layer deriving from the one below it reads - see `walkWithPrevious`. */
679
+ const VALUE_KEY = 'value';
680
+ /**
681
+ * Whether a function at `at` is **code** - a step to run later, or part of a plugin - rather than a
682
+ * value to compute now.
683
+ *
684
+ * Array indices are dropped before matching, so a function inside a *list* of steps is still a
685
+ * step; `*` in a `STEP_PATHS` entry matches any one segment (`run.<script>.exec`).
686
+ */
687
+ function isCodePath(at) {
688
+ const segments = at.filter((p) => typeof p === 'string');
689
+ if (CODE_SUBTREES.includes(segments[0]))
690
+ return true;
691
+ return STEP_PATHS.some(pattern => {
692
+ const parts = pattern.split('.');
693
+ return parts.length === segments.length && parts.every((part, i) => part === '*' || part === segments[i]);
694
+ });
695
+ }
696
+ /**
697
+ * Calls a **value** function: the JS spelling of a `${{ }}` expression, and it answers the same
698
+ * question at the same moment.
699
+ *
700
+ * It receives one object carrying everything an expression can name - `pkg`, `repository`, `file`,
701
+ * `env`, `semver`, `path`, plus the config's own top-level keys - and, in addition, **`value`**: what
702
+ * this key resolved to in the layers underneath, which is what makes a derived value possible
703
+ * without restating the base.
704
+ *
705
+ * Built with the interpolation context as its **prototype**, not copied from it. The top-level keys
706
+ * are lazy getters (`resolve`, memoized, so key order in the file means nothing and a cycle is
707
+ * reported rather than half-resolved); spreading them into a new object would fire every one of
708
+ * them on every call, including the ones a function never reads - and one of those throwing would
709
+ * blame the wrong key.
710
+ *
711
+ * **It must compute and return, never act.** This runs while the repository's config resolves,
712
+ * which *every* command does - so a value function that writes a file writes it on `rman list`,
713
+ * `rman info` and `rman config` too, N times for N packages, with no command having asked for
714
+ * anything. That is the same reason `FileScope` offers no way to change anything. Work goes in a
715
+ * step, which is the one thing rman runs on purpose and which can also be a function.
716
+ */
717
+ function callValueFn(fn, context, at) {
718
+ /** `value` arrives through the prototype, bound by `walkWithPrevious` for exactly this key - so
719
+ * nothing here may *read* it. Passing it in as an argument did, which tripped the "was it read"
720
+ * getter before the function ran and put the `value` hint on every unrelated failure (caught by
721
+ * the spec that exists for precisely that). */
722
+ const arg = Object.create(context);
723
+ /**
724
+ * A getter only so the catch below can tell whether the function **actually read `value`**.
725
+ *
726
+ * Without that, the "value is undefined" hint went out with *every* failure of a first-layer
727
+ * function - a frozen-object `TypeError` from `read()` arrived wearing advice about spreading an
728
+ * inherited list, which is precisely the send-the-reader-to-the-wrong-place mistake the hint
729
+ * exists to prevent. Recorded rather than inferred from the message, because matching on V8's
730
+ * wording is the other way to get this wrong.
731
+ */
732
+ try {
733
+ return fn(arg);
734
+ }
735
+ catch (e) {
736
+ const where = at.length ? formatPath(at) : 'the config root';
737
+ /** The `value` hint comes from `walkWithPrevious`, which wraps this call and is the one place
738
+ * that knows whether `value` was read - so the expression spelling gets the same sentence. */
739
+ throw new Error(`Config function in "${where}" failed: ${e?.message}`, { cause: e });
740
+ }
741
+ }
467
742
  function interpolateString(value, context, at) {
468
743
  if (!value.includes('${{'))
469
744
  return value;
@@ -507,3 +782,129 @@ function formatPath(at) {
507
782
  /** Guards against an expression that never returns (`while(true)`) taking the whole command with
508
783
  * it - a typo, not an attack, but the failure mode is identical. */
509
784
  const EXPRESSION_TIMEOUT = 1000;
785
+ /**
786
+ * Reads and parses one structured file, memoized against **the identity of its contents** rather
787
+ * than its path alone: the cache key is `mtimeNs:size`.
788
+ *
789
+ * Both halves of that were chosen against a measurement.
790
+ *
791
+ * - **A stat rather than a re-read**: `statSync` is 1.3µs where `readFileSync` + `JSON.parse` is
792
+ * 16.1µs on a 2KB manifest - so the check costs a thirteenth of what it saves, and the same file
793
+ * read by twenty packages is parsed once. (`interpolateConfig` runs once *per package*, so a
794
+ * cache living in one pass would not have helped across them at all.)
795
+ * - **Keyed on the stat rather than held for the run**: rman writes JSON files while it is running
796
+ * - `version` rewrites every bumped manifest, then re-interpolates its own deferred hooks. A
797
+ * cache that only remembered the path would hand those back as they were before the write.
798
+ * `mtimeNs` is nanoseconds, so a rewrite within the same millisecond does not slip through; the
799
+ * size is in the key as well because it costs nothing.
800
+ *
801
+ * **Frozen, deeply, once on the way into the cache.** Every package is handed the same object, so
802
+ * one config mutating it would quietly change what the next package sees - the reason `pkg.manifest`
803
+ * has always been a copy. Freezing is better than copying here: a copy costs 5.6µs on *every* call,
804
+ * freezing costs ~1µs *once*, and it turns the mistake into a `TypeError` instead of an effect at a
805
+ * distance. A caller that wants to change something spreads it first.
806
+ */
807
+ function readStructuredFile(file, format, cache) {
808
+ let stat;
809
+ try {
810
+ stat = fs.statSync(file, { bigint: true });
811
+ }
812
+ catch {
813
+ throw new Error(`read("${path.basename(file)}") found nothing at ${file}\n` +
814
+ ` Use file.exists() first if its absence is a case to handle rather than a mistake.`);
815
+ }
816
+ if (stat.isDirectory())
817
+ throw new Error(`read() was given a directory, not a file: ${file}`);
818
+ const stamp = `${stat.mtimeNs}:${stat.size}`;
819
+ const cached = cache.get(file);
820
+ if (cached?.stamp === stamp)
821
+ return cached.value;
822
+ const resolved = format ?? formatOf(file);
823
+ const text = fs.readFileSync(file, 'utf-8');
824
+ let value;
825
+ try {
826
+ value = parseStructured(text, resolved);
827
+ }
828
+ catch (e) {
829
+ /** The parser's own message says what is wrong with the syntax but never which file it was
830
+ * reading - and an expression can name several. */
831
+ throw new Error(`read("${path.basename(file)}") could not parse ${file} as ${resolved}: ${e?.message}`, {
832
+ cause: e,
833
+ });
834
+ }
835
+ deepFreeze(value);
836
+ cache.set(file, { stamp, value });
837
+ return value;
838
+ }
839
+ /** The extension decides, because the caller already wrote it - naming the parser as well would
840
+ * restate it and let the two disagree (`json("x.yml")`). A name that says nothing takes the
841
+ * explicit argument instead. */
842
+ function formatOf(file) {
843
+ const ext = path.extname(file).toLowerCase();
844
+ if (ext === '.json')
845
+ return 'json';
846
+ if (ext === '.yml' || ext === '.yaml')
847
+ return 'yaml';
848
+ if (ext === '.ini')
849
+ return 'ini';
850
+ if (XML_EXTENSIONS.has(ext))
851
+ return 'xml';
852
+ throw new Error(`read() cannot tell what "${path.basename(file)}" is from its name.\n` +
853
+ ` Name the format: read("${path.basename(file)}", "json" | "yaml" | "ini" | "xml").`);
854
+ }
855
+ /** The XML family worth recognizing by name: a project file is XML whatever its extension calls
856
+ * itself, and `.csproj`/`.pom` are what a .NET or Maven repository actually holds. Anything else
857
+ * still reads with an explicit `read(p, 'xml')`. */
858
+ const XML_EXTENSIONS = new Set(['.xml', '.csproj', '.vbproj', '.fsproj', '.props', '.targets', '.nuspec', '.plist']);
859
+ function parseStructured(text, format) {
860
+ if (format === 'json')
861
+ return JSON.parse(text);
862
+ /** `load`, not `loadAll`: a multi-document stream has no single value to be, and js-yaml says so
863
+ * clearly enough ("expected a single document in the stream") to leave alone. */
864
+ if (format === 'yaml')
865
+ return yaml.load(text);
866
+ if (format === 'xml')
867
+ return parseXml(text);
868
+ return ini.parse(text);
869
+ }
870
+ /**
871
+ * A **DOM**, not an object - and the asymmetry with the other three formats is the honest shape
872
+ * rather than an omission.
873
+ *
874
+ * XML has no lossless object form: an element can repeat, carry attributes and hold text at the
875
+ * same time, so any flattening has to pick a convention (`$`? `_text`? array-or-not?) and be wrong
876
+ * for somebody. A DOM is the shape XML actually has, so a config reads it the way every other XML
877
+ * tool does:
878
+ *
879
+ * ```yaml
880
+ * version: '${{ read("pom.xml").getElementsByTagName("version")[0].textContent }}'
881
+ * ```
882
+ *
883
+ * **Freezing it is safe** - measured, not assumed: a frozen `@xmldom/xmldom` document still answers
884
+ * `getElementsByTagName` for a tag first asked about *after* the freeze (the live-collection case
885
+ * that would have broken it), reads attributes, resolves namespaces, walks `childNodes` and
886
+ * serialises back.
887
+ */
888
+ function parseXml(text) {
889
+ /** xmldom reports a malformed document through a handler and otherwise carries on with whatever
890
+ * it could salvage - so without this, a broken file would come back as a half-parsed DOM and the
891
+ * expression reading it would simply find nothing. `read()` throws for a broken JSON file; it has
892
+ * to throw for this one too. */
893
+ const problems = [];
894
+ const doc = new DOMParser({
895
+ onError: (level, message) => {
896
+ if (level !== 'warning')
897
+ problems.push(message.split('\n')[0]);
898
+ },
899
+ }).parseFromString(text, 'text/xml');
900
+ if (problems.length)
901
+ throw new Error(problems[0]);
902
+ return doc;
903
+ }
904
+ function deepFreeze(value) {
905
+ if (!value || typeof value !== 'object' || Object.isFrozen(value))
906
+ return;
907
+ Object.freeze(value);
908
+ for (const item of Object.values(value))
909
+ deepFreeze(item);
910
+ }
@@ -14,6 +14,30 @@ export declare const APPEND_PREFIX = "+";
14
14
  * layer, which is only acceptable where the value is a set of contributions rather than a decision.
15
15
  */
16
16
  export declare const ALWAYS_APPEND: readonly string[];
17
+ /**
18
+ * Where a key keeps what it is replacing, so the replacement can be handed it back as `value`.
19
+ *
20
+ * A **symbol-keyed chain on the containing object**, one entry per key, rather than something
21
+ * attached to the value itself. It started as a wrapper around a *function*, which is the only kind
22
+ * of value you can hang a property on - and that is exactly why it had to change: `value` belongs to
23
+ * an expression (`"${{ [...value, 'x'] }}"`) just as much as to a function, and a string cannot
24
+ * carry one.
25
+ *
26
+ * A symbol is invisible to `Object.entries`, `JSON.stringify` and js-yaml, so the chain travels
27
+ * through `mergeConfig`, `finalizeConfig` and `rman config` without any of them having to know it
28
+ * is there.
29
+ *
30
+ * Each entry is a link, not a single slot: three layers each deriving from the one below need
31
+ * `A <- expr2 <- expr3`, and one slot would have lost `A` the moment `expr3` arrived.
32
+ */
33
+ export declare const PREVIOUS_VALUES: unique symbol;
34
+ /** One link: the raw value this key held, and whatever *it* was derived from. */
35
+ export interface PreviousValue {
36
+ value: unknown;
37
+ previous?: PreviousValue;
38
+ }
39
+ /** Only these two can ask for `value`, so only these two are worth remembering a previous for. */
40
+ export declare function carriesPreviousValue(value: unknown): boolean;
17
41
  /** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
18
42
  export declare function appendTarget(key: string): string | undefined;
19
43
  /**
@@ -14,6 +14,27 @@ export const APPEND_PREFIX = '+';
14
14
  * layer, which is only acceptable where the value is a set of contributions rather than a decision.
15
15
  */
16
16
  export const ALWAYS_APPEND = ['plugins'];
17
+ /**
18
+ * Where a key keeps what it is replacing, so the replacement can be handed it back as `value`.
19
+ *
20
+ * A **symbol-keyed chain on the containing object**, one entry per key, rather than something
21
+ * attached to the value itself. It started as a wrapper around a *function*, which is the only kind
22
+ * of value you can hang a property on - and that is exactly why it had to change: `value` belongs to
23
+ * an expression (`"${{ [...value, 'x'] }}"`) just as much as to a function, and a string cannot
24
+ * carry one.
25
+ *
26
+ * A symbol is invisible to `Object.entries`, `JSON.stringify` and js-yaml, so the chain travels
27
+ * through `mergeConfig`, `finalizeConfig` and `rman config` without any of them having to know it
28
+ * is there.
29
+ *
30
+ * Each entry is a link, not a single slot: three layers each deriving from the one below need
31
+ * `A <- expr2 <- expr3`, and one slot would have lost `A` the moment `expr3` arrived.
32
+ */
33
+ export const PREVIOUS_VALUES = Symbol('rman.previousValues');
34
+ /** Only these two can ask for `value`, so only these two are worth remembering a previous for. */
35
+ export function carriesPreviousValue(value) {
36
+ return typeof value === 'function' || (typeof value === 'string' && value.includes('${{'));
37
+ }
17
38
  /** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
18
39
  export function appendTarget(key) {
19
40
  return key.length > APPEND_PREFIX.length && key.startsWith(APPEND_PREFIX)
@@ -108,6 +129,11 @@ export function finalizeConfig(config) {
108
129
  const pending = finalizeConfig(value);
109
130
  result[plain] = plain in result ? [...toList(result[plain]), ...toList(pending)] : toList(pending);
110
131
  }
132
+ /** Carried across by hand: this rebuilds the object from `Object.entries`, which does not see a
133
+ * symbol - and dropping it here would lose every `value` chain the merge just recorded. */
134
+ const chain = config[PREVIOUS_VALUES];
135
+ if (chain)
136
+ Object.defineProperty(result, PREVIOUS_VALUES, { value: chain });
111
137
  return result;
112
138
  }
113
139
  function assignMerged(target, key, value) {
@@ -117,6 +143,23 @@ function assignMerged(target, key, value) {
117
143
  mergeConfig(target[key], value);
118
144
  return;
119
145
  }
146
+ /**
147
+ * A value that can ask for `value` **replaces** like any other - and remembers what it replaced:
148
+ *
149
+ * ```js
150
+ * '[*]': { clean: { include: ({ vars }) => [vars.buildDir] } }
151
+ * '[ws:*]': { clean: { include: "${{ [...value, pkg.basename + '.log'] }}" } }
152
+ * ```
153
+ *
154
+ * Chained here rather than at resolution time because only the merge knows the order of the
155
+ * layers - by the time `interpolateConfig` sees the config they have collapsed into one object,
156
+ * and whatever a closer layer said has already taken the place of what it was derived from.
157
+ */
158
+ if (carriesPreviousValue(value) && key in target) {
159
+ const carrier = target;
160
+ const chain = (carrier[PREVIOUS_VALUES] ??= {});
161
+ chain[key] = { value: target[key], previous: chain[key] };
162
+ }
120
163
  target[key] = Array.isArray(value) ? [...value] : value;
121
164
  }
122
165
  /**