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.
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_VALUE } 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,7 @@ 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 value = walk(config[key], scope, context, [...base, key], skip);
373
353
  resolved.set(key, value);
374
354
  return value;
375
355
  }
@@ -409,11 +389,87 @@ export function interpolateConfig(config, scope, options) {
409
389
  * command at all would fail on a config that mentions it.
410
390
  */
411
391
  export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'];
392
+ /**
393
+ * Paths whose value is a **step** - something to run later - rather than a setting to compute now.
394
+ * `*` matches one path segment (`run.<script>.exec`).
395
+ *
396
+ * This is what tells a step function from a value function, and the two live side by side in one
397
+ * config:
398
+ *
399
+ * ```js
400
+ * '[ws:*]': {
401
+ * clean: { include: ({ vars, value }) => [...value, vars.buildDir] }, // a value: called here
402
+ * run: { build: { after: ({ pkg }) => copyDocs(pkg) } }, // a step: called by `run`
403
+ * }
404
+ * ```
405
+ *
406
+ * **The key decides, and it already did.** `run.build.exec: 'tsc -b'` is a shell command and
407
+ * `publish.directory: 'build'` is a path - not because of anything about the strings, but because of
408
+ * where they sit. A function inherits the same rule, so nothing new has to be learned and no marker
409
+ * has to be remembered. The alternative was inspecting the function (arity, parameter names), which
410
+ * is the kind of guess `loadPlugins` refuses to make about a module's export for the same reason:
411
+ * guessing wrong here means running build-time code while merely loading the repository, or
412
+ * silently never running it.
413
+ *
414
+ * A **string** at one of these paths is still interpolated - `exec: 'tsc -b ${{ file.resolve(...) }}'`
415
+ * has to keep working - so this is narrower than `DEFERRED_PATHS`, which skips its paths entirely.
416
+ */
417
+ export const STEP_PATHS = [
418
+ /** The bare-value shorthand: `run: { build: fn }` means `{ exec: fn }`, as `run: { build: 'cmd' }`
419
+ * means `{ exec: 'cmd' }`. Missing it made the two spellings disagree about *when* the function
420
+ * runs, which is worse than not supporting the short one at all. */
421
+ 'run.*',
422
+ 'run.*.before',
423
+ 'run.*.exec',
424
+ 'run.*.after',
425
+ /** A condition, evaluated per package by `RunService` when the run reaches it. Called here
426
+ * instead, it collapsed to the boolean it happened to return at load time - and `parseIfExpr`
427
+ * then read that boolean as "no condition given", so the script ran unconditionally (measured). */
428
+ 'run.*.if',
429
+ 'version.before',
430
+ 'version.exec',
431
+ 'version.after',
432
+ ];
433
+ /**
434
+ * Keys whose **whole subtree** is code rather than config, so no function under them is a value to
435
+ * compute. `plugins` is the only one, and it has to be here: an entry may be the plugin *object*
436
+ * itself, and an `RmanPlugin` is almost entirely functions - `manifest.read`, `workspace.resolve`,
437
+ * `versionPlanner`, `binPaths`, and every command's `builder` and `handler`.
438
+ *
439
+ * Measured, and it is why this exists: with `plugins` walked like any other key, resolving the
440
+ * config of a repository that named a plugin called that plugin's yargs builder with the config
441
+ * scope - `Config function in "plugins[0].commands[0].builder" failed: cmd.option is not a
442
+ * function`. A `plugins` entry is loaded by `loadPlugins`, never read as a setting.
443
+ */
444
+ export const CODE_SUBTREES = ['plugins'];
412
445
  const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
413
446
  /** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
414
447
  * `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
415
448
  const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
416
449
  /** The `file` namespace for one package's directory - see `FileScope`. */
450
+ /**
451
+ * `read` in a `${{ ... }}` expression (and in a value function): a structured file's **contents**,
452
+ * parsed - where `file` answers only where a path is.
453
+ *
454
+ * ```yaml
455
+ * "[*]":
456
+ * run:
457
+ * build:
458
+ * exec: 'tsc --outDir ${{ read("tsconfig.json").compilerOptions.outDir }}'
459
+ * ```
460
+ *
461
+ * `cache` is shared across every package (see `Repository.configScope`) and keyed by what the file
462
+ * *is*, not merely where - so the same file read by twenty packages is parsed once, and a file rman
463
+ * itself rewrites mid-run is re-read rather than remembered. See `readStructuredFile`.
464
+ */
465
+ export function createReadScope(dirname, cache) {
466
+ return (target, format) => {
467
+ if (typeof target !== 'string' || !target.trim()) {
468
+ throw new Error('read() needs a path - it was given ' + JSON.stringify(target));
469
+ }
470
+ return readStructuredFile(path.resolve(dirname, target), format, cache);
471
+ };
472
+ }
417
473
  export function createFileScope(dirname) {
418
474
  const locate = (target) => {
419
475
  if (typeof target !== 'string' || !target.trim()) {
@@ -452,18 +508,188 @@ function walk(value, scope, context, at, skip) {
452
508
  * command or an array of them - is handed on untouched. */
453
509
  if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
454
510
  return value;
511
+ if (typeof value === 'function') {
512
+ /** Code, not a value: a step for `run`/`version` to call in its own time, or a plugin's own
513
+ * function. Carried through exactly as a command string would be - calling it here would run
514
+ * build-time work while merely *loading* the repository, which is the whole distinction the
515
+ * function form exists to draw. */
516
+ if (isCodePath(at))
517
+ return value;
518
+ return callValueFn(value, scope, context, at, skip);
519
+ }
455
520
  if (typeof value === 'string')
456
521
  return interpolateString(value, context, at);
457
522
  if (Array.isArray(value))
458
523
  return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
459
524
  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;
525
+ return withScopedVars(value, scope, context, at, skip, () => {
526
+ const result = {};
527
+ for (const [key, item] of Object.entries(value))
528
+ result[key] = walk(item, scope, context, [...at, key], skip);
529
+ return result;
530
+ });
464
531
  }
465
532
  return value;
466
533
  }
534
+ /**
535
+ * Runs `body` with `vars` scoped to this node: **a fresh copy at every level**, with the node's own
536
+ * `vars` block - if it declares one - merged over what the level above resolved to.
537
+ *
538
+ * ```yaml
539
+ * vars: { x: 1 }
540
+ * run:
541
+ * vars: { x: 2 }
542
+ * clean: { before: '${{ read(vars.x + ".json") }}' } # 2.json
543
+ * build:
544
+ * vars: { x: 3 }
545
+ * before: '${{ read(vars.x + ".json") }}' # 3.json
546
+ * ```
547
+ *
548
+ * **Copied at every node, not only where a `vars` block appears**, and that is the difference
549
+ * between scoping and leaking: a value function is handed this object, so one that writes to it
550
+ * (`vars.built = Date.now()`) must not be writing into the level above. Without a copy per node,
551
+ * a write inside `run.build` would land in `run`'s object and `run.clean` would see it. Merged per
552
+ * key rather than replaced, so redeclaring one var keeps the rest - the rule the top-level `vars`
553
+ * has always followed.
554
+ *
555
+ * The node's own block is resolved **against the outer scope** before being installed, so
556
+ * `vars: { out: '${{ vars.x }}/dist' }` reads the `x` it is refining rather than itself.
557
+ *
558
+ * Installed as a plain property over the context's lazy top-level getter and restored afterwards -
559
+ * `walk` is depth-first and synchronous, so the window is exactly this subtree, and a value function
560
+ * called inside it reads the same object through its prototype.
561
+ */
562
+ function withScopedVars(node, scope, context, at, skip, body) {
563
+ /**
564
+ * **A `vars` block does not scope itself.** Resolving one walks its own values, and without this
565
+ * that walk asks for the scope it is in the middle of producing - which the cycle guard catches
566
+ * and reports as `vars -> vars`. It recovered (the guard returns `undefined`, so the block simply
567
+ * saw no outer scope, which is what it should see anyway), but it left the cycle *flag* set, and
568
+ * the next genuine error in that key came out wearing `Config expression forms a cycle` - found by
569
+ * running a real shared config, whose `[...value]` mistake arrived with a loop attached that had
570
+ * nothing to do with it.
571
+ *
572
+ * Any path with a `vars` segment is inside a block: its contents are values, not config nodes.
573
+ */
574
+ if (at.some(segment => segment === VARS_KEY))
575
+ return body();
576
+ const outer = context[VARS_KEY];
577
+ const own = node[VARS_KEY];
578
+ /** Nothing to shadow and nothing to protect: a node with no object below it can hold no function
579
+ * either, so the copy would be pure cost. */
580
+ if (own === undefined && !hasObjectChild(node))
581
+ return body();
582
+ const resolvedOwn = own === undefined ? undefined : walk(own, scope, context, [...at, VARS_KEY], skip);
583
+ const scoped = { ...outer, ...(isPlainObject(resolvedOwn) ? resolvedOwn : undefined) };
584
+ const previous = Object.getOwnPropertyDescriptor(context, VARS_KEY);
585
+ Object.defineProperty(context, VARS_KEY, { value: scoped, enumerable: true, configurable: true, writable: true });
586
+ try {
587
+ return body();
588
+ }
589
+ finally {
590
+ if (previous)
591
+ Object.defineProperty(context, VARS_KEY, previous);
592
+ else
593
+ delete context[VARS_KEY];
594
+ }
595
+ }
596
+ function hasObjectChild(node) {
597
+ for (const item of Object.values(node)) {
598
+ if (typeof item === 'function')
599
+ return true;
600
+ if (item && typeof item === 'object')
601
+ return true;
602
+ }
603
+ return false;
604
+ }
605
+ function isPlainObject(value) {
606
+ return !!value && typeof value === 'object' && !Array.isArray(value);
607
+ }
608
+ /** The one key that scopes rather than configures - see `withScopedVars`. Reserved at **every**
609
+ * level, which costs a script that would have been called `vars`: `run.vars` is a scope, not a
610
+ * script. Nothing enumerates `run`'s keys as a list of script names, so the cost stops there. */
611
+ const VARS_KEY = 'vars';
612
+ /**
613
+ * Whether a function at `at` is **code** - a step to run later, or part of a plugin - rather than a
614
+ * value to compute now.
615
+ *
616
+ * Array indices are dropped before matching, so a function inside a *list* of steps is still a
617
+ * step; `*` in a `STEP_PATHS` entry matches any one segment (`run.<script>.exec`).
618
+ */
619
+ function isCodePath(at) {
620
+ const segments = at.filter((p) => typeof p === 'string');
621
+ if (CODE_SUBTREES.includes(segments[0]))
622
+ return true;
623
+ return STEP_PATHS.some(pattern => {
624
+ const parts = pattern.split('.');
625
+ return parts.length === segments.length && parts.every((part, i) => part === '*' || part === segments[i]);
626
+ });
627
+ }
628
+ /**
629
+ * Calls a **value** function: the JS spelling of a `${{ }}` expression, and it answers the same
630
+ * question at the same moment.
631
+ *
632
+ * It receives one object carrying everything an expression can name - `pkg`, `repository`, `file`,
633
+ * `env`, `semver`, `path`, plus the config's own top-level keys - and, in addition, **`value`**: what
634
+ * this key resolved to in the layers underneath, which is what makes a derived value possible
635
+ * without restating the base.
636
+ *
637
+ * Built with the interpolation context as its **prototype**, not copied from it. The top-level keys
638
+ * are lazy getters (`resolve`, memoized, so key order in the file means nothing and a cycle is
639
+ * reported rather than half-resolved); spreading them into a new object would fire every one of
640
+ * them on every call, including the ones a function never reads - and one of those throwing would
641
+ * blame the wrong key.
642
+ *
643
+ * **It must compute and return, never act.** This runs while the repository's config resolves,
644
+ * which *every* command does - so a value function that writes a file writes it on `rman list`,
645
+ * `rman info` and `rman config` too, N times for N packages, with no command having asked for
646
+ * anything. That is the same reason `FileScope` offers no way to change anything. Work goes in a
647
+ * step, which is the one thing rman runs on purpose and which can also be a function.
648
+ */
649
+ function callValueFn(fn, scope, context, at, skip) {
650
+ const previous = fn[PREVIOUS_VALUE];
651
+ const arg = Object.create(context);
652
+ /** Resolved the same way any other value is, so an inherited `${{ }}` string or a function under
653
+ * it is already finished by the time this one is handed it. */
654
+ const resolvedPrevious = previous === undefined ? undefined : walk(previous, scope, context, at, skip);
655
+ /**
656
+ * A getter only so the catch below can tell whether the function **actually read `value`**.
657
+ *
658
+ * Without that, the "value is undefined" hint went out with *every* failure of a first-layer
659
+ * function - a frozen-object `TypeError` from `read()` arrived wearing advice about spreading an
660
+ * inherited list, which is precisely the send-the-reader-to-the-wrong-place mistake the hint
661
+ * exists to prevent. Recorded rather than inferred from the message, because matching on V8's
662
+ * wording is the other way to get this wrong.
663
+ */
664
+ let valueRead = false;
665
+ Object.defineProperty(arg, 'value', {
666
+ enumerable: true,
667
+ get: () => {
668
+ valueRead = true;
669
+ return resolvedPrevious;
670
+ },
671
+ });
672
+ try {
673
+ return fn(arg);
674
+ }
675
+ catch (e) {
676
+ const where = at.length ? formatPath(at) : 'the config root';
677
+ /**
678
+ * **`value` is `undefined` when no layer underneath set this key.** A function written to extend
679
+ * an inherited list (`[...value, x]`) is also the *first* layer in a repository that inherits
680
+ * nothing, and V8's report for that is `value is not iterable` - which names neither the key nor
681
+ * the reason, and sends the reader looking at their spread instead of at what is missing.
682
+ *
683
+ * Told rather than papered over: defaulting `value` to `[]` would be a guess about the key's
684
+ * type, and wrong for every key that is not a list.
685
+ */
686
+ const hint = valueRead && resolvedPrevious === undefined
687
+ ? `\n \`value\` is undefined here - nothing below this layer sets "${where}".` +
688
+ `\n Write \`value ?? []\` (or \`?? ''\`) if the function has to work as the first layer too.`
689
+ : '';
690
+ throw new Error(`Config function in "${where}" failed: ${e?.message}${hint}`, { cause: e });
691
+ }
692
+ }
467
693
  function interpolateString(value, context, at) {
468
694
  if (!value.includes('${{'))
469
695
  return value;
@@ -507,3 +733,129 @@ function formatPath(at) {
507
733
  /** Guards against an expression that never returns (`while(true)`) taking the whole command with
508
734
  * it - a typo, not an attack, but the failure mode is identical. */
509
735
  const EXPRESSION_TIMEOUT = 1000;
736
+ /**
737
+ * Reads and parses one structured file, memoized against **the identity of its contents** rather
738
+ * than its path alone: the cache key is `mtimeNs:size`.
739
+ *
740
+ * Both halves of that were chosen against a measurement.
741
+ *
742
+ * - **A stat rather than a re-read**: `statSync` is 1.3µs where `readFileSync` + `JSON.parse` is
743
+ * 16.1µs on a 2KB manifest - so the check costs a thirteenth of what it saves, and the same file
744
+ * read by twenty packages is parsed once. (`interpolateConfig` runs once *per package*, so a
745
+ * cache living in one pass would not have helped across them at all.)
746
+ * - **Keyed on the stat rather than held for the run**: rman writes JSON files while it is running
747
+ * - `version` rewrites every bumped manifest, then re-interpolates its own deferred hooks. A
748
+ * cache that only remembered the path would hand those back as they were before the write.
749
+ * `mtimeNs` is nanoseconds, so a rewrite within the same millisecond does not slip through; the
750
+ * size is in the key as well because it costs nothing.
751
+ *
752
+ * **Frozen, deeply, once on the way into the cache.** Every package is handed the same object, so
753
+ * one config mutating it would quietly change what the next package sees - the reason `pkg.manifest`
754
+ * has always been a copy. Freezing is better than copying here: a copy costs 5.6µs on *every* call,
755
+ * freezing costs ~1µs *once*, and it turns the mistake into a `TypeError` instead of an effect at a
756
+ * distance. A caller that wants to change something spreads it first.
757
+ */
758
+ function readStructuredFile(file, format, cache) {
759
+ let stat;
760
+ try {
761
+ stat = fs.statSync(file, { bigint: true });
762
+ }
763
+ catch {
764
+ throw new Error(`read("${path.basename(file)}") found nothing at ${file}\n` +
765
+ ` Use file.exists() first if its absence is a case to handle rather than a mistake.`);
766
+ }
767
+ if (stat.isDirectory())
768
+ throw new Error(`read() was given a directory, not a file: ${file}`);
769
+ const stamp = `${stat.mtimeNs}:${stat.size}`;
770
+ const cached = cache.get(file);
771
+ if (cached?.stamp === stamp)
772
+ return cached.value;
773
+ const resolved = format ?? formatOf(file);
774
+ const text = fs.readFileSync(file, 'utf-8');
775
+ let value;
776
+ try {
777
+ value = parseStructured(text, resolved);
778
+ }
779
+ catch (e) {
780
+ /** The parser's own message says what is wrong with the syntax but never which file it was
781
+ * reading - and an expression can name several. */
782
+ throw new Error(`read("${path.basename(file)}") could not parse ${file} as ${resolved}: ${e?.message}`, {
783
+ cause: e,
784
+ });
785
+ }
786
+ deepFreeze(value);
787
+ cache.set(file, { stamp, value });
788
+ return value;
789
+ }
790
+ /** The extension decides, because the caller already wrote it - naming the parser as well would
791
+ * restate it and let the two disagree (`json("x.yml")`). A name that says nothing takes the
792
+ * explicit argument instead. */
793
+ function formatOf(file) {
794
+ const ext = path.extname(file).toLowerCase();
795
+ if (ext === '.json')
796
+ return 'json';
797
+ if (ext === '.yml' || ext === '.yaml')
798
+ return 'yaml';
799
+ if (ext === '.ini')
800
+ return 'ini';
801
+ if (XML_EXTENSIONS.has(ext))
802
+ return 'xml';
803
+ throw new Error(`read() cannot tell what "${path.basename(file)}" is from its name.\n` +
804
+ ` Name the format: read("${path.basename(file)}", "json" | "yaml" | "ini" | "xml").`);
805
+ }
806
+ /** The XML family worth recognizing by name: a project file is XML whatever its extension calls
807
+ * itself, and `.csproj`/`.pom` are what a .NET or Maven repository actually holds. Anything else
808
+ * still reads with an explicit `read(p, 'xml')`. */
809
+ const XML_EXTENSIONS = new Set(['.xml', '.csproj', '.vbproj', '.fsproj', '.props', '.targets', '.nuspec', '.plist']);
810
+ function parseStructured(text, format) {
811
+ if (format === 'json')
812
+ return JSON.parse(text);
813
+ /** `load`, not `loadAll`: a multi-document stream has no single value to be, and js-yaml says so
814
+ * clearly enough ("expected a single document in the stream") to leave alone. */
815
+ if (format === 'yaml')
816
+ return yaml.load(text);
817
+ if (format === 'xml')
818
+ return parseXml(text);
819
+ return ini.parse(text);
820
+ }
821
+ /**
822
+ * A **DOM**, not an object - and the asymmetry with the other three formats is the honest shape
823
+ * rather than an omission.
824
+ *
825
+ * XML has no lossless object form: an element can repeat, carry attributes and hold text at the
826
+ * same time, so any flattening has to pick a convention (`$`? `_text`? array-or-not?) and be wrong
827
+ * for somebody. A DOM is the shape XML actually has, so a config reads it the way every other XML
828
+ * tool does:
829
+ *
830
+ * ```yaml
831
+ * version: '${{ read("pom.xml").getElementsByTagName("version")[0].textContent }}'
832
+ * ```
833
+ *
834
+ * **Freezing it is safe** - measured, not assumed: a frozen `@xmldom/xmldom` document still answers
835
+ * `getElementsByTagName` for a tag first asked about *after* the freeze (the live-collection case
836
+ * that would have broken it), reads attributes, resolves namespaces, walks `childNodes` and
837
+ * serialises back.
838
+ */
839
+ function parseXml(text) {
840
+ /** xmldom reports a malformed document through a handler and otherwise carries on with whatever
841
+ * it could salvage - so without this, a broken file would come back as a half-parsed DOM and the
842
+ * expression reading it would simply find nothing. `read()` throws for a broken JSON file; it has
843
+ * to throw for this one too. */
844
+ const problems = [];
845
+ const doc = new DOMParser({
846
+ onError: (level, message) => {
847
+ if (level !== 'warning')
848
+ problems.push(message.split('\n')[0]);
849
+ },
850
+ }).parseFromString(text, 'text/xml');
851
+ if (problems.length)
852
+ throw new Error(problems[0]);
853
+ return doc;
854
+ }
855
+ function deepFreeze(value) {
856
+ if (!value || typeof value !== 'object' || Object.isFrozen(value))
857
+ return;
858
+ Object.freeze(value);
859
+ for (const item of Object.values(value))
860
+ deepFreeze(item);
861
+ }
@@ -14,6 +14,19 @@ 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 value function keeps the value it is replacing, so it can be handed back as `value`.
19
+ *
20
+ * A **symbol on a forwarding wrapper**, rather than a class or a `{fn, prev}` object, for one
21
+ * concrete reason: every walker in this file and in `config.ts` decides what to do by asking
22
+ * `isPlainObject`, and a wrapper object would answer yes - `finalizeConfig` would rebuild it as a
23
+ * plain object and lose the function, and `mergeConfig` would try to merge into it key by key. A
24
+ * function is not a plain object, so it travels through all of them untouched.
25
+ *
26
+ * The user's own function is never mutated: two packages inheriting the same shared-config function
27
+ * would otherwise share - and overwrite - one `prev`.
28
+ */
29
+ export declare const PREVIOUS_VALUE: unique symbol;
17
30
  /** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
18
31
  export declare function appendTarget(key: string): string | undefined;
19
32
  /**
@@ -14,6 +14,19 @@ 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 value function keeps the value it is replacing, so it can be handed back as `value`.
19
+ *
20
+ * A **symbol on a forwarding wrapper**, rather than a class or a `{fn, prev}` object, for one
21
+ * concrete reason: every walker in this file and in `config.ts` decides what to do by asking
22
+ * `isPlainObject`, and a wrapper object would answer yes - `finalizeConfig` would rebuild it as a
23
+ * plain object and lose the function, and `mergeConfig` would try to merge into it key by key. A
24
+ * function is not a plain object, so it travels through all of them untouched.
25
+ *
26
+ * The user's own function is never mutated: two packages inheriting the same shared-config function
27
+ * would otherwise share - and overwrite - one `prev`.
28
+ */
29
+ export const PREVIOUS_VALUE = Symbol('rman.previousValue');
17
30
  /** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
18
31
  export function appendTarget(key) {
19
32
  return key.length > APPEND_PREFIX.length && key.startsWith(APPEND_PREFIX)
@@ -117,8 +130,42 @@ function assignMerged(target, key, value) {
117
130
  mergeConfig(target[key], value);
118
131
  return;
119
132
  }
133
+ /**
134
+ * A function **replaces** like any other value - and remembers what it replaced, so it can be
135
+ * given it back as `value` when the config resolves:
136
+ *
137
+ * ```js
138
+ * '[*]': { clean: { include: ({ vars }) => [vars.buildDir] } }
139
+ * '[ws:*]': { clean: { include: ({ value, pkg }) => [...value, pkg.basename + '.log'] } }
140
+ * ```
141
+ *
142
+ * Chained here rather than at resolution time because only the merge knows the order of the
143
+ * layers - by the time `interpolateConfig` sees the config they have collapsed into one object,
144
+ * and whatever a closer layer said has already taken the place of what it was derived from.
145
+ */
146
+ if (typeof value === 'function') {
147
+ target[key] = chainValueFn(value, target[key]);
148
+ return;
149
+ }
120
150
  target[key] = Array.isArray(value) ? [...value] : value;
121
151
  }
152
+ /**
153
+ * Wraps `fn` so it carries `previous`, leaving `fn` itself alone.
154
+ *
155
+ * The wrapper forwards every argument unchanged, which is what lets one rule cover both kinds of
156
+ * function a config can hold: a **value** function is called by `interpolateConfig` with the config
157
+ * scope, a **step** function by `RunService` with a `RunStepContext`, and neither needs to know it
158
+ * has been wrapped. `name` is copied over because a step's label is its function's name.
159
+ */
160
+ function chainValueFn(fn, previous) {
161
+ const wrapper = (...args) => fn(...args);
162
+ Object.defineProperty(wrapper, 'name', { value: fn.name, configurable: true });
163
+ /** Only when there *is* one: an own property set to `undefined` is indistinguishable from an
164
+ * inherited value that genuinely resolved to nothing. */
165
+ if (previous !== undefined)
166
+ Object.defineProperty(wrapper, PREVIOUS_VALUE, { value: previous });
167
+ return wrapper;
168
+ }
122
169
  /**
123
170
  * Appends `value` to `target[key]`, de-duplicating **only** an `ALWAYS_APPEND` key.
124
171
  *
@@ -24,6 +24,18 @@ export declare class Repository extends Package {
24
24
  * An internal cache has no business being walked anyway.
25
25
  */
26
26
  private _repoScope?;
27
+ /** Cached `${{ git.* }}` facts - see `_gitScope`. Non-enumerable for the same reason as above,
28
+ * and because reading it is a subprocess: a deep walk of a package must not spawn one. */
29
+ private _git?;
30
+ /**
31
+ * Files `${{ read(...) }}` has parsed, shared by every package's scope and keyed by the identity
32
+ * of the bytes - see `readStructuredFile`.
33
+ *
34
+ * **On the repository rather than per scope, and that is the whole point of it**: `configScope`
35
+ * is built once per package, so a cache living there would re-read a repository-level file once
36
+ * for every package that mentions it.
37
+ */
38
+ private readonly _readCache;
27
39
  protected constructor(dirname: string, monorepo: boolean, packages: Package[],
28
40
  /** The directory `Repository.create()` was actually invoked from - unlike `dirname` (the
29
41
  * resolved repository root, possibly several levels up), this is where the user's shell
@@ -64,6 +76,8 @@ export declare class Repository extends Package {
64
76
  configScope(pkg: Package, options?: {
65
77
  targetVersion?: string;
66
78
  }): ConfigScope;
79
+ /** `${{ git.* }}`, read at most once per repository per process. */
80
+ protected _gitScope(): GitScope;
67
81
  /**
68
82
  * Resolves the effective rman config for the repository root and every package, cascading
69
83
  * root -> intermediate directories -> package directory, so a `.rmanrc` placed anywhere along
@@ -104,7 +118,7 @@ export declare class Repository extends Package {
104
118
  */
105
119
  protected _resolveDeclaredPackage(entry: string): Package | undefined;
106
120
  protected _updateDependencies(): void;
107
- /** `git` facts for a `${{ repository.git.* }}` expression. Synchronous on purpose: it backs a lazy
121
+ /** `git` facts for a `${{ git.* }}` expression. Synchronous on purpose: it backs a lazy
108
122
  * getter, and a getter cannot await. Everything is `undefined` outside a git checkout - not an
109
123
  * error, just a repository without one. */
110
124
  protected _readGitScope(dirname: string): GitScope;