rman 1.2.1 → 1.2.4

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/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '1.2.1';
1
+ export const version = '1.2.4';
package/core/config.js CHANGED
@@ -8,7 +8,7 @@ import semver from 'semver';
8
8
  import { pathToFileURL } from 'url';
9
9
  import vm from 'vm';
10
10
  import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
11
- import { finalizeConfig, mergeConfig, PREVIOUS_VALUE } from './merge-config.js';
11
+ import { finalizeConfig, mergeConfig, PREVIOUS_VALUES } from './merge-config.js';
12
12
  /**
13
13
  * Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
14
14
  * autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
@@ -349,7 +349,8 @@ export function interpolateConfig(config, scope, options) {
349
349
  }
350
350
  resolving.push(key);
351
351
  try {
352
- const value = walk(config[key], scope, context, [...base, key], skip);
352
+ const chain = config[PREVIOUS_VALUES];
353
+ const value = walkWithPrevious(config[key], chain?.[key], scope, context, [...base, key], skip);
353
354
  resolved.set(key, value);
354
355
  return value;
355
356
  }
@@ -515,17 +516,19 @@ function walk(value, scope, context, at, skip) {
515
516
  * function form exists to draw. */
516
517
  if (isCodePath(at))
517
518
  return value;
518
- return callValueFn(value, scope, context, at, skip);
519
+ return callValueFn(value, context, at);
519
520
  }
520
521
  if (typeof value === 'string')
521
522
  return interpolateString(value, context, at);
522
523
  if (Array.isArray(value))
523
524
  return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
524
525
  if (value && typeof value === 'object') {
526
+ const chain = value[PREVIOUS_VALUES];
525
527
  return withScopedVars(value, scope, context, at, skip, () => {
526
528
  const result = {};
527
- for (const [key, item] of Object.entries(value))
528
- result[key] = walk(item, scope, context, [...at, key], skip);
529
+ for (const [key, item] of Object.entries(value)) {
530
+ result[key] = walkWithPrevious(item, chain?.[key], scope, context, [...at, key], skip);
531
+ }
529
532
  return result;
530
533
  });
531
534
  }
@@ -609,6 +612,71 @@ function isPlainObject(value) {
609
612
  * level, which costs a script that would have been called `vars`: `run.vars` is a scope, not a
610
613
  * script. Nothing enumerates `run`'s keys as a list of script names, so the cost stops there. */
611
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';
612
680
  /**
613
681
  * Whether a function at `at` is **code** - a step to run later, or part of a plugin - rather than a
614
682
  * value to compute now.
@@ -646,12 +714,12 @@ function isCodePath(at) {
646
714
  * anything. That is the same reason `FileScope` offers no way to change anything. Work goes in a
647
715
  * step, which is the one thing rman runs on purpose and which can also be a function.
648
716
  */
649
- function callValueFn(fn, scope, context, at, skip) {
650
- const previous = fn[PREVIOUS_VALUE];
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). */
651
722
  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
723
  /**
656
724
  * A getter only so the catch below can tell whether the function **actually read `value`**.
657
725
  *
@@ -661,33 +729,14 @@ function callValueFn(fn, scope, context, at, skip) {
661
729
  * exists to prevent. Recorded rather than inferred from the message, because matching on V8's
662
730
  * wording is the other way to get this wrong.
663
731
  */
664
- let valueRead = false;
665
- Object.defineProperty(arg, 'value', {
666
- enumerable: true,
667
- get: () => {
668
- valueRead = true;
669
- return resolvedPrevious;
670
- },
671
- });
672
732
  try {
673
733
  return fn(arg);
674
734
  }
675
735
  catch (e) {
676
736
  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 });
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 });
691
740
  }
692
741
  }
693
742
  function interpolateString(value, context, at) {
@@ -15,18 +15,29 @@ export declare const APPEND_PREFIX = "+";
15
15
  */
16
16
  export declare const ALWAYS_APPEND: readonly string[];
17
17
  /**
18
- * Where a value function keeps the value it is replacing, so it can be handed back as `value`.
18
+ * Where a key keeps what it is replacing, so the replacement can be handed it back as `value`.
19
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.
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
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`.
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.
28
32
  */
29
- export declare const PREVIOUS_VALUE: unique symbol;
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;
30
41
  /** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
31
42
  export declare function appendTarget(key: string): string | undefined;
32
43
  /**
@@ -15,18 +15,26 @@ export const APPEND_PREFIX = '+';
15
15
  */
16
16
  export const ALWAYS_APPEND = ['plugins'];
17
17
  /**
18
- * Where a value function keeps the value it is replacing, so it can be handed back as `value`.
18
+ * Where a key keeps what it is replacing, so the replacement can be handed it back as `value`.
19
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.
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
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`.
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.
28
32
  */
29
- export const PREVIOUS_VALUE = Symbol('rman.previousValue');
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
+ }
30
38
  /** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
31
39
  export function appendTarget(key) {
32
40
  return key.length > APPEND_PREFIX.length && key.startsWith(APPEND_PREFIX)
@@ -121,6 +129,11 @@ export function finalizeConfig(config) {
121
129
  const pending = finalizeConfig(value);
122
130
  result[plain] = plain in result ? [...toList(result[plain]), ...toList(pending)] : toList(pending);
123
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 });
124
137
  return result;
125
138
  }
126
139
  function assignMerged(target, key, value) {
@@ -131,41 +144,24 @@ function assignMerged(target, key, value) {
131
144
  return;
132
145
  }
133
146
  /**
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:
147
+ * A value that can ask for `value` **replaces** like any other - and remembers what it replaced:
136
148
  *
137
149
  * ```js
138
- * '[*]': { clean: { include: ({ vars }) => [vars.buildDir] } }
139
- * '[ws:*]': { clean: { include: ({ value, pkg }) => [...value, pkg.basename + '.log'] } }
150
+ * '[*]': { clean: { include: ({ vars }) => [vars.buildDir] } }
151
+ * '[ws:*]': { clean: { include: "${{ [...value, pkg.basename + '.log'] }}" } }
140
152
  * ```
141
153
  *
142
154
  * Chained here rather than at resolution time because only the merge knows the order of the
143
155
  * layers - by the time `interpolateConfig` sees the config they have collapsed into one object,
144
156
  * and whatever a closer layer said has already taken the place of what it was derived from.
145
157
  */
146
- if (typeof value === 'function') {
147
- target[key] = chainValueFn(value, target[key]);
148
- return;
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] };
149
162
  }
150
163
  target[key] = Array.isArray(value) ? [...value] : value;
151
164
  }
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
- }
169
165
  /**
170
166
  * Appends `value` to `target[key]`, de-duplicating **only** an `ALWAYS_APPEND` key.
171
167
  *
@@ -10,3 +10,31 @@
10
10
  * `label` names the config key in the error, so a failure says which setting to go and look at.
11
11
  */
12
12
  export declare function resolveConfigTarget(target: string, from: string, label: string): string;
13
+ /**
14
+ * The fallback: a plugin or shared config installed **beside rman itself**, tried when the
15
+ * repository cannot resolve it.
16
+ *
17
+ * A globally installed rman's siblings *are* the globally installed packages, so this is what makes
18
+ * `rman ci` work on a fresh clone - the command comes from `rman-node`, and `ci` exists to create
19
+ * the very `node_modules` the plugin would otherwise have to be found in. Measured: with both
20
+ * installed globally, a clone answered `"plugins" target "rman-node" could not be resolved ... is
21
+ * it installed in this repository?`, which was true and useless.
22
+ *
23
+ * **The repository is always tried first**, so a repository carrying its own copy is unaffected and
24
+ * its version always wins. This is a fallback, never a search order: `createRequire` is based on the
25
+ * config file precisely so a repository's config resolves against the repository.
26
+ *
27
+ * It is deliberately *not* gated on whether the repository looks installed. That was tried - fall
28
+ * back only when there is no `node_modules` anywhere above the config file - on the reasoning that
29
+ * an installed repository merely *missing* a dependency should keep the honest error. It reads well
30
+ * and behaves unpredictably: the walk reaches the filesystem root, so a checkout under any directory
31
+ * that happens to have a `node_modules` (a home directory, a nested clone) silently lost the
32
+ * fallback. A rule whose answer depends on where the repository was cloned is worse than the
33
+ * looser one.
34
+ *
35
+ * `from` exists for the specs, and is the whole test seam: the answer depends on where **this
36
+ * module** sits, so a spec running inside this repository could otherwise only ever prove that this
37
+ * repository can see its own `node_modules`. Given a throwaway layout's URL it exercises the real
38
+ * resolution - no subprocess, and nothing to keep in step with the source.
39
+ */
40
+ export declare function resolveBesideRman(target: string, from?: string): string | undefined;
@@ -28,6 +28,44 @@ export function resolveConfigTarget(target, from, label) {
28
28
  return createRequire(pathToFileURL(path.join(dir, 'noop.js'))).resolve(target);
29
29
  }
30
30
  catch {
31
+ const beside = resolveBesideRman(target);
32
+ if (beside)
33
+ return beside;
31
34
  throw new Error(`"${label}" target "${target}" could not be resolved from "${from}" - is it installed in this repository?`);
32
35
  }
33
36
  }
37
+ /**
38
+ * The fallback: a plugin or shared config installed **beside rman itself**, tried when the
39
+ * repository cannot resolve it.
40
+ *
41
+ * A globally installed rman's siblings *are* the globally installed packages, so this is what makes
42
+ * `rman ci` work on a fresh clone - the command comes from `rman-node`, and `ci` exists to create
43
+ * the very `node_modules` the plugin would otherwise have to be found in. Measured: with both
44
+ * installed globally, a clone answered `"plugins" target "rman-node" could not be resolved ... is
45
+ * it installed in this repository?`, which was true and useless.
46
+ *
47
+ * **The repository is always tried first**, so a repository carrying its own copy is unaffected and
48
+ * its version always wins. This is a fallback, never a search order: `createRequire` is based on the
49
+ * config file precisely so a repository's config resolves against the repository.
50
+ *
51
+ * It is deliberately *not* gated on whether the repository looks installed. That was tried - fall
52
+ * back only when there is no `node_modules` anywhere above the config file - on the reasoning that
53
+ * an installed repository merely *missing* a dependency should keep the honest error. It reads well
54
+ * and behaves unpredictably: the walk reaches the filesystem root, so a checkout under any directory
55
+ * that happens to have a `node_modules` (a home directory, a nested clone) silently lost the
56
+ * fallback. A rule whose answer depends on where the repository was cloned is worse than the
57
+ * looser one.
58
+ *
59
+ * `from` exists for the specs, and is the whole test seam: the answer depends on where **this
60
+ * module** sits, so a spec running inside this repository could otherwise only ever prove that this
61
+ * repository can see its own `node_modules`. Given a throwaway layout's URL it exercises the real
62
+ * resolution - no subprocess, and nothing to keep in step with the source.
63
+ */
64
+ export function resolveBesideRman(target, from = import.meta.url) {
65
+ try {
66
+ return createRequire(from).resolve(target);
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "1.2.1",
4
+ "version": "1.2.4",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {