rman 1.2.5 → 2.0.0-beta.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.
Files changed (153) hide show
  1. package/README.md +63 -19
  2. package/cli.d.ts +5 -0
  3. package/cli.js +214 -88
  4. package/commands/build.command.d.ts +177 -3
  5. package/commands/build.command.js +20 -10
  6. package/commands/changed.command.d.ts +80 -3
  7. package/commands/changed.command.js +19 -12
  8. package/commands/changelog.command.d.ts +192 -3
  9. package/commands/changelog.command.js +87 -43
  10. package/commands/config.command.d.ts +44 -3
  11. package/commands/config.command.js +30 -19
  12. package/commands/diff.command.d.ts +37 -3
  13. package/commands/diff.command.js +25 -16
  14. package/commands/exec.command.d.ts +193 -3
  15. package/commands/exec.command.js +60 -58
  16. package/commands/github-release.command.d.ts +154 -3
  17. package/commands/github-release.command.js +67 -37
  18. package/commands/import.command.d.ts +36 -3
  19. package/commands/import.command.js +28 -20
  20. package/commands/info.command.d.ts +35 -7
  21. package/commands/info.command.js +36 -30
  22. package/commands/list.command.d.ts +163 -3
  23. package/commands/list.command.js +109 -71
  24. package/commands/publish.command.d.ts +231 -0
  25. package/commands/publish.command.js +304 -0
  26. package/commands/run.command.d.ts +186 -6
  27. package/commands/run.command.js +26 -72
  28. package/commands/test.command.d.ts +173 -3
  29. package/commands/test.command.js +16 -10
  30. package/commands/version.command.d.ts +317 -3
  31. package/commands/version.command.js +149 -69
  32. package/commands.d.ts +32 -0
  33. package/commands.js +28 -0
  34. package/constants.js +1 -1
  35. package/core/application.d.ts +116 -0
  36. package/core/application.js +143 -0
  37. package/core/command-builder.d.ts +14 -0
  38. package/core/command-builder.js +78 -0
  39. package/core/config.d.ts +180 -50
  40. package/core/config.js +332 -153
  41. package/core/core-services.d.ts +14 -0
  42. package/core/core-services.js +30 -0
  43. package/core/core-targets.d.ts +14 -0
  44. package/core/core-targets.js +16 -0
  45. package/core/custom-command.d.ts +42 -6
  46. package/core/custom-command.js +44 -17
  47. package/core/extends-config.d.ts +13 -5
  48. package/core/extends-config.js +52 -12
  49. package/core/load-config-module.d.ts +28 -0
  50. package/core/load-config-module.js +42 -0
  51. package/core/manifest.d.ts +47 -25
  52. package/core/manifest.js +51 -69
  53. package/core/merge-config.d.ts +33 -34
  54. package/core/merge-config.js +138 -93
  55. package/core/package.d.ts +145 -17
  56. package/core/package.js +128 -36
  57. package/core/plugin-loader.d.ts +65 -0
  58. package/core/plugin-loader.js +234 -0
  59. package/core/plugin.d.ts +135 -90
  60. package/core/plugin.js +70 -173
  61. package/core/publish-target.d.ts +124 -0
  62. package/core/publish-target.js +30 -0
  63. package/core/registry.d.ts +30 -0
  64. package/core/registry.js +47 -0
  65. package/core/repository.d.ts +72 -9
  66. package/core/repository.js +293 -43
  67. package/core/resolve-target.d.ts +1 -1
  68. package/core/resolve-target.js +1 -1
  69. package/core/service.d.ts +49 -0
  70. package/core/service.js +40 -0
  71. package/core/version-scheme.d.ts +23 -1
  72. package/core/version-scheme.js +29 -1
  73. package/core/workspace.d.ts +84 -33
  74. package/core/workspace.js +63 -22
  75. package/index.d.ts +111 -14
  76. package/index.js +85 -9
  77. package/interfaces/rman-config.interface.d.ts +739 -212
  78. package/interfaces/rman-config.interface.js +61 -1
  79. package/package.json +2 -1
  80. package/plugins/builtins.d.ts +44 -0
  81. package/plugins/builtins.js +33 -0
  82. package/plugins/detect.d.ts +78 -0
  83. package/plugins/detect.js +70 -0
  84. package/plugins/node/augmentation/rman.augmentation.d.ts +84 -0
  85. package/plugins/node/augmentation/rman.augmentation.js +1 -0
  86. package/plugins/node/augmentation/system-info.augmentation.d.ts +26 -0
  87. package/plugins/node/augmentation/system-info.augmentation.js +79 -0
  88. package/plugins/node/commands/ci.command.d.ts +131 -0
  89. package/plugins/node/commands/ci.command.js +59 -0
  90. package/plugins/node/commands/clean.command.d.ts +183 -0
  91. package/plugins/node/commands/clean.command.js +73 -0
  92. package/plugins/node/index.d.ts +29 -0
  93. package/plugins/node/index.js +40 -0
  94. package/plugins/node/node-config.interface.d.ts +77 -0
  95. package/plugins/node/node-config.interface.js +7 -0
  96. package/plugins/node/node-manifest.provider.d.ts +68 -0
  97. package/plugins/node/node-manifest.provider.js +125 -0
  98. package/plugins/node/node.platform.d.ts +53 -0
  99. package/plugins/node/node.platform.js +134 -0
  100. package/plugins/node/npm-publish-target.d.ts +73 -0
  101. package/plugins/node/npm-publish-target.js +96 -0
  102. package/plugins/node/services/ci.service.d.ts +47 -0
  103. package/plugins/node/services/ci.service.js +213 -0
  104. package/plugins/node/services/clean.service.d.ts +53 -0
  105. package/plugins/node/services/clean.service.js +237 -0
  106. package/plugins/node/services/publish.service.d.ts +114 -0
  107. package/plugins/node/services/publish.service.js +371 -0
  108. package/plugins/node/services/version-plan.service.d.ts +44 -0
  109. package/plugins/node/services/version-plan.service.js +58 -0
  110. package/plugins/node/utils/npm-view.d.ts +48 -0
  111. package/plugins/node/utils/npm-view.js +71 -0
  112. package/plugins/node/utils/workspace-range.d.ts +26 -0
  113. package/plugins/node/utils/workspace-range.js +28 -0
  114. package/services/change-hash.service.d.ts +2 -2
  115. package/services/change-hash.service.js +2 -2
  116. package/services/changelog.service.d.ts +62 -51
  117. package/services/changelog.service.js +14 -11
  118. package/services/docker-publish.service.d.ts +50 -29
  119. package/services/docker-publish.service.js +43 -20
  120. package/services/exec.service.d.ts +23 -12
  121. package/services/exec.service.js +14 -9
  122. package/services/github-release.service.d.ts +44 -33
  123. package/services/github-release.service.js +13 -10
  124. package/services/import.service.d.ts +25 -14
  125. package/services/import.service.js +9 -5
  126. package/services/list.service.d.ts +62 -10
  127. package/services/list.service.js +62 -15
  128. package/services/run.service.d.ts +22 -13
  129. package/services/run.service.js +262 -223
  130. package/services/version-plan.service.d.ts +27 -4
  131. package/services/version-plan.service.js +42 -15
  132. package/services/version.service.d.ts +31 -11
  133. package/services/version.service.js +29 -13
  134. package/targets/docker.target.d.ts +53 -0
  135. package/targets/docker.target.js +40 -0
  136. package/utils/bin-path.d.ts +6 -7
  137. package/utils/bin-path.js +7 -18
  138. package/utils/branch-guard.d.ts +29 -0
  139. package/utils/branch-guard.js +31 -0
  140. package/utils/exec.d.ts +10 -0
  141. package/utils/exec.js +1 -1
  142. package/utils/logger.d.ts +1 -1
  143. package/utils/logger.js +1 -1
  144. package/utils/package-filter.d.ts +127 -7
  145. package/utils/package-filter.js +197 -16
  146. package/utils/printable-config.d.ts +1 -1
  147. package/utils/printable-config.js +1 -1
  148. package/utils/run-bin.d.ts +10 -0
  149. package/utils/run-bin.js +1 -1
  150. package/utils/run-options.d.ts +97 -0
  151. package/utils/run-options.js +81 -0
  152. package/utils/version-stamp.d.ts +1 -1
  153. package/utils/version-stamp.js +1 -1
package/core/config.js CHANGED
@@ -2,13 +2,13 @@ import { DOMParser } from '@xmldom/xmldom';
2
2
  import fs from 'fs';
3
3
  import ini from 'ini';
4
4
  import * as yaml from 'js-yaml';
5
- import { createRequire } from 'module';
6
5
  import path from 'path';
7
6
  import semver from 'semver';
8
- import { pathToFileURL } from 'url';
9
7
  import vm from 'vm';
10
- import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
11
- import { finalizeConfig, mergeConfig, ORIGINS, PREVIOUS_VALUES } from './merge-config.js';
8
+ import { DETECTED_BUILTIN } from '../plugins/detect.js';
9
+ import { assertSelectorBlocks, EXTENDS_KEY, resolveExtends } from './extends-config.js';
10
+ import { loadConfigModule } from './load-config-module.js';
11
+ import { mergeConfig, ORIGINS, 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
@@ -31,35 +31,18 @@ export function defineConfig(config) {
31
31
  * or a `.js` under a `"type": "commonjs"` package.json) and native ESM (`.mjs`, or a `.js` under
32
32
  * `"type": "module"`) are supported - the reason `readDirConfig`/`resolveConfig` are async at all. */
33
33
  const JS_CONFIG_FILES = ['.rmanrc.cjs', '.rmanrc.mjs', '.rmanrc.js'];
34
- const requireJsConfig = createRequire(import.meta.url);
35
- /**
36
- * Loads `file`'s config object. Tries `require()` first - not just an optimization: a CommonJS
37
- * module's `module.exports` is more reliably observed this way than through dynamic `import()`'s
38
- * CJS-interop synthesis, which some ESM loader hooks (e.g. ts-node/swc-node-style transpilers
39
- * registered via `--import`) can end up short-circuiting into an empty object. `require()` throws
40
- * `ERR_REQUIRE_ESM` for a genuinely-ESM file (`.mjs`, or `.js` under `"type": "module"`) - only
41
- * then does this fall back to `import()`, the one case that actually needs it. Either path can
42
- * hand back an ES module namespace instead of a plain object (Node's `require(esm)` support does
43
- * this too, not just `import()`), so `.default` is preferred whenever present.
44
- */
45
- async function loadJsConfig(file) {
46
- let mod;
47
- try {
48
- mod = requireJsConfig(file);
49
- }
50
- catch (e) {
51
- if (e?.code !== 'ERR_REQUIRE_ESM')
52
- throw e;
53
- mod = await import(pathToFileURL(file).href);
54
- }
55
- return mod?.default ?? mod;
56
- }
57
34
  /**
58
35
  * Reads the rman configuration defined at a single directory level, merging
59
36
  * (in increasing precedence): `package.json#rman`, `.rmanrc.yml`, `.rmanrc`,
60
37
  * then `.rmanrc.cjs`/`.rmanrc.mjs`/`.rmanrc.js` (whichever exist, in that order).
38
+ *
39
+ * `options.inject` supplies a built-in for a repository that declared no technology - **already
40
+ * decided**, rather than a "please detect" flag. The decision needs the application (a programmatic
41
+ * caller or a spec may have registered a technology without writing it in a config), which this
42
+ * function has no business knowing about; `Repository.create` makes it once and hands the answer
43
+ * to every read that has to agree with it. See `detectBuiltin`.
61
44
  */
62
- export async function readDirConfig(dirname) {
45
+ export async function readDirConfig(dirname, options) {
63
46
  const result = {};
64
47
  /** The file an `extends` in this directory resolves relative to. The last form that actually
65
48
  * declared one wins, which matters only for the unusual directory holding several. */
@@ -68,7 +51,7 @@ export async function readDirConfig(dirname) {
68
51
  if (fs.existsSync(pkgJsonFile)) {
69
52
  const pkgJson = JSON.parse(fs.readFileSync(pkgJsonFile, 'utf-8'));
70
53
  if (pkgJson && typeof pkgJson.rman === 'object') {
71
- assertNoSelectorExtends(pkgJson.rman, pkgJsonFile);
54
+ assertSelectorBlocks(pkgJson.rman, pkgJsonFile);
72
55
  if (EXTENDS_KEY in pkgJson.rman)
73
56
  extendsFrom = pkgJsonFile;
74
57
  mergeConfig(result, pkgJson.rman, pkgJsonFile);
@@ -78,7 +61,7 @@ export async function readDirConfig(dirname) {
78
61
  if (fs.existsSync(ymlFile)) {
79
62
  const obj = yaml.load(fs.readFileSync(ymlFile, 'utf-8'));
80
63
  if (obj && typeof obj === 'object') {
81
- assertNoSelectorExtends(obj, ymlFile);
64
+ assertSelectorBlocks(obj, ymlFile);
82
65
  if (EXTENDS_KEY in obj)
83
66
  extendsFrom = ymlFile;
84
67
  mergeConfig(result, obj, ymlFile);
@@ -88,7 +71,7 @@ export async function readDirConfig(dirname) {
88
71
  if (fs.existsSync(rcFile)) {
89
72
  const obj = JSON.parse(fs.readFileSync(rcFile, 'utf-8'));
90
73
  if (obj && typeof obj === 'object') {
91
- assertNoSelectorExtends(obj, rcFile);
74
+ assertSelectorBlocks(obj, rcFile);
92
75
  if (EXTENDS_KEY in obj)
93
76
  extendsFrom = rcFile;
94
77
  mergeConfig(result, obj, rcFile);
@@ -97,9 +80,9 @@ export async function readDirConfig(dirname) {
97
80
  for (const jsFileName of JS_CONFIG_FILES) {
98
81
  const jsFile = path.join(dirname, jsFileName);
99
82
  if (fs.existsSync(jsFile)) {
100
- const obj = await loadJsConfig(jsFile);
83
+ const obj = await loadConfigModule(jsFile);
101
84
  if (obj && typeof obj === 'object') {
102
- assertNoSelectorExtends(obj, jsFile);
85
+ assertSelectorBlocks(obj, jsFile);
103
86
  if (EXTENDS_KEY in obj)
104
87
  extendsFrom = jsFile;
105
88
  mergeConfig(result, obj, jsFile);
@@ -110,79 +93,192 @@ export async function readDirConfig(dirname) {
110
93
  * one of them sits on, and the directory chain then layers on top as it always did. Each form
111
94
  * was checked for a misplaced `extends` as it was read, so that error can name the file holding
112
95
  * it rather than whichever form happened to declare the real one. */
113
- return resolveExtends(result, extendsFrom);
96
+ const resolved = await resolveExtends(result, extendsFrom);
97
+ /**
98
+ * **After `extends`, because a base may be what declares the technology** - a shared config
99
+ * naming `plugins` is a statement, and detecting on top of it would be guessing over an answer.
100
+ * `plugins` being *present* is what counts, so `plugins: []` is a repository saying "none".
101
+ */
102
+ const detected = options?.inject && resolved.plugins === undefined ? options.inject : undefined;
103
+ if (detected)
104
+ resolved.plugins = [detected.name];
105
+ const expanded = await expandBuiltinPlugins(resolved);
106
+ /**
107
+ * **Marked after the expansion, not before, because the expansion rebuilds the object.**
108
+ * `expandBuiltinPlugins` merges the built-in's config underneath and returns a *new* config, so a
109
+ * symbol set on the way in is simply gone on the way out - measured: detection worked and the
110
+ * "detected" line never printed. The same trap `PREVIOUS_VALUES` and `ORIGINS` document from the
111
+ * other side, where `mergeConfig` has to copy them across by hand.
112
+ */
113
+ if (detected) {
114
+ Object.defineProperty(expanded, DETECTED_BUILTIN, { value: detected, enumerable: false, configurable: true });
115
+ }
116
+ return expanded;
117
+ }
118
+ /**
119
+ * Turns a built-in **name** in `plugins` into what that built-in contributes - `['node']` into the
120
+ * node plugin, its two commands and its publish target.
121
+ *
122
+ * **Here, beside `extends`, because it is the same operation**: something named brings a config,
123
+ * and that config sits *underneath* the one naming it. Doing it anywhere later would not reach far
124
+ * enough - `commands` is read off the resolved root package by `cli.ts`, not off the raw config
125
+ * `Repository.create` hands to `loadPlugins`, so a built-in expanded only there would register its
126
+ * technology and silently lose its commands.
127
+ *
128
+ * **The name is consumed.** `plugins` always appends, so leaving the string beside the instance it
129
+ * expanded into would hand `loadPlugins` a glob that matches no file - the built-in would load and
130
+ * then the run would fail saying it did not.
131
+ *
132
+ * Runs per directory, like `extends`, but only the root's `plugins` is ever read (`loadPlugins`
133
+ * needs the technologies before any package exists). The cost of walking a key that is almost
134
+ * always absent is one `Array.isArray`.
135
+ */
136
+ async function expandBuiltinPlugins(config) {
137
+ const declared = config.plugins;
138
+ const own = Array.isArray(declared) ? declared : declared === undefined ? [] : [declared];
139
+ const platform = declaredPlatform(config);
140
+ if (!own.some(e => typeof e === 'string') && platform === undefined)
141
+ return config;
142
+ /**
143
+ * **Imported here rather than at the top, and that is a cycle rather than a style.** A built-in
144
+ * pulls in its commands and services, which read config - so a static import would have
145
+ * `config.ts` and the plugin subtree initialising each other, which in ESM half-works and fails
146
+ * silently. Node caches the module, so the cost is one resolution on a config that names one.
147
+ */
148
+ const { BUILTIN_PLUGINS, isBuiltinPlugin } = await import('../plugins/builtins.js');
149
+ /**
150
+ * **A `platform` naming a built-in puts it at the front of `plugins`.**
151
+ *
152
+ * Saying which technology this repository is *is* saying it has it, so making the author write
153
+ * both was a distinction only rman could see. Measured on the repository this was noticed in:
154
+ * `platform: 'node'` alone gave a working `rman list` with a `node` column and
155
+ * `Unknown arguments: clean`, because the technology had loaded and its commands had not.
156
+ *
157
+ * **At the front, not the back**, and that is what makes it a statement rather than an addition:
158
+ * `platformFor` takes the first registered platform that recognizes a directory, so the one this
159
+ * repository says it *is* should win over anything a shared config brought along.
160
+ *
161
+ * **Only a built-in**, checked after the import above for exactly this reason: a `platform` naming
162
+ * a third-party technology is answered by the `plugins` entry that loads it, and pushing the bare
163
+ * name in here would hand `loadPlugins` a glob matching no file - the failure would read as the
164
+ * plugin being missing when it is registered perfectly well.
165
+ *
166
+ * Already named, and nothing happens: `plugins` de-duplicates, and this keeps the author's own
167
+ * ordering rather than promoting an entry they placed deliberately.
168
+ */
169
+ const entries = platform !== undefined && isBuiltinPlugin(platform) && !own.includes(platform) ? [platform, ...own] : own;
170
+ const named = entries.filter((e) => typeof e === 'string' && isBuiltinPlugin(e));
171
+ if (!named.length)
172
+ return config;
173
+ const base = {};
174
+ /** De-duplicated first: two layers naming the same built-in is ordinary (a shared config and the
175
+ * repository that inherits it), and registering a plugin twice defines its commands twice. */
176
+ for (const name of [...new Set(named)])
177
+ mergeConfig(base, BUILTIN_PLUGINS[name].contribute());
178
+ const result = { ...config };
179
+ result.plugins = entries.filter(e => !(typeof e === 'string' && isBuiltinPlugin(e)));
180
+ return mergeConfig(base, result);
181
+ }
182
+ /**
183
+ * The **root's** declared platform, however it was spelled - unmarked, or inside a `"[/]"` block.
184
+ *
185
+ * Both, because both are the root saying what it is and a reader would not expect one to bring the
186
+ * built-in and the other not. `"[/]"` is the precise spelling (it speaks for the root package
187
+ * alone, where an unmarked key also cascades to every package below), so leaving it out would have
188
+ * punished the more careful author.
189
+ *
190
+ * A glob block is not consulted and cannot be: `assertSelectorBlocks` refuses `platform` there,
191
+ * since the glob matches a selector the key is upstream of.
192
+ */
193
+ function declaredPlatform(config) {
194
+ const root = config[`[${ROOT_SELECTOR_INNER}]`];
195
+ const declared = config.platform ?? (root && typeof root === 'object' ? root.platform : undefined);
196
+ return typeof declared === 'string' && declared.trim() ? declared.trim() : undefined;
114
197
  }
115
198
  /**
116
199
  * Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
117
200
  * it (inclusive) - each directory level overrides the ones above it, the way tsconfig's `extends`
118
201
  * chain does.
119
202
  *
120
- * Every level contributes in two ways, and the difference is the whole model:
121
- *
122
- * - **Unmarked keys configure the package of the directory that declares them.** The root's own
123
- * `.rmanrc` therefore configures the *root package* - which is where repo-wide settings
124
- * (`packageManager`, `allowBranch`, `version.*`, `githubRelease.*`) are read from anyway - and
125
- * not, silently, every package under it.
126
- * - **A `"[selector]"` block configures the packages it names** - `"[*]"` for all of them (the root
127
- * included), `"[ws:*]"` for every one but the root, `"[/]"` for the root alone, `"[*-dialect]"`
128
- * for a glob over package names. See `parseSelector`. This is the only way a directory speaks
129
- * about anything but its own package.
130
- *
131
- * Splitting the two matters because the same key means different things to the two audiences. The
132
- * clearest case is `run.<script>.postScript`: on a package it's that package's build hook, run in
133
- * its own directory; on the root it's a repo-wide bookend run once at the repository root. A
134
- * cascade that fed one declaration to both ran a package-relative command (`node
135
- * ../../support/postbuild.cjs`) at the root, where it cannot resolve.
136
- *
137
- * `packageName` is what selectors match against; without it, selector blocks contribute nothing at
138
- * all. The root package passes its own, since `"[/]"` and `"[*]"` speak to it.
203
+ * Every level contributes in two ways:
204
+ *
205
+ * - **An unmarked key configures that directory and every package under it.** What a parent says
206
+ * reaches the children, which is what every directory-scoped config in the ecosystem does and
207
+ * what a reader expects without being told.
208
+ * - **A `"[selector]"` block narrows the audience** - `"[/]"` to the root package alone, `"[*]"` or
209
+ * a glob to the packages below (never the root, which is nobody's child). See `parseSelector`.
210
+ *
211
+ * **The root used to be the one directory whose unmarked config did *not* cascade**, on the
212
+ * reasoning that a setting means different things to a package and to the repository - and the
213
+ * reasoning is sound, but the rule it produced was not readable: an intermediate `packages/`
214
+ * cascaded while the root did not, so what a file meant depended on whether a `package.json` sat
215
+ * beside it. `vars` then had to be carved out as an exception, which is what a rule fighting itself
216
+ * looks like. One sentence now covers both: what is written above reaches below, and `"[/]"` is how
217
+ * a statement stays at the root.
218
+ *
219
+ * **The cost is real and lands on one subtree.** `run.<script>`'s hooks on the root are a repo-wide
220
+ * bookend, run once at the repository root; on a package they are that package's own hook, run in
221
+ * its directory. Cascaded, one declaration is both - once at the root and once per package. A
222
+ * repo-wide bookend therefore belongs under `"[/]"`, where its audience is visible; that is the
223
+ * migration this change asks for, and the only one that is not mechanical.
224
+ *
225
+ * **`selector` is what a `"[glob]"` block matches** - `Package.selector`, which is the package's
226
+ * `.rmanrc "name"` if it declares one and its platform's answer otherwise. Without it, glob blocks
227
+ * contribute nothing: the walk resolves config for a directory *before* the package exists, since
228
+ * that is where `platform` and `name` are read from, and a glob has nothing to match against yet.
229
+ *
230
+ * **`"[/]"` needs no selector, and that is the documented rule rather than an exception.** The root
231
+ * is addressed structurally - its directory *is* the repository root - which is the whole reason it
232
+ * is `/` and not a name. So a root block applies whenever the target is the root, named or not, and
233
+ * `platform` under `"[/]"` therefore works during the walk. It did not until this was noticed: the
234
+ * gate was `if (packageName)`, so the walk skipped every selector block including that one, and the
235
+ * key documented as "keeps it on the root package alone" silently did nothing.
236
+ *
237
+ * It was called `packageName`, which was wrong twice over: a package is not guaranteed to have a
238
+ * name (that is an ecosystem's promise, not rman's), and what this matches is the selector, which a
239
+ * repository can assign itself.
139
240
  */
140
- export async function resolveConfig(rootDir, targetDir, cache = new Map(), packageName) {
241
+ export async function resolveConfig(rootDir, targetDir, cache = new Map(), selector,
242
+ /** The built-in `Repository.create` decided on, for a repository that declared no technology.
243
+ * Applied at the **root level only** - `plugins` is read nowhere else, and this is the read whose
244
+ * result becomes `pkg.config`, which is where `cli.ts` finds a built-in's `commands`. */
245
+ inject) {
141
246
  const result = {};
142
247
  const target = path.resolve(targetDir);
143
248
  /** The root *package* is the one whose directory is the repository root - no other test is
144
249
  * needed, and none would be as reliable: a name can be anything. In a single-package repository
145
- * that is the only package, so `"[/]"` reaches it and `"[ws:*]"` reaches nothing. */
250
+ * that is the only package, so `"[/]"` reaches it and `"[*]"` reaches nothing. */
146
251
  const isRoot = target === path.resolve(rootDir);
147
252
  for (const dir of dirChain(rootDir, targetDir)) {
148
253
  let local = cache.get(dir);
149
254
  if (!local) {
150
- local = await readDirConfig(dir);
255
+ local = await readDirConfig(dir, {
256
+ inject: path.resolve(dir) === path.resolve(rootDir) ? inject : undefined,
257
+ });
151
258
  cache.set(dir, local);
152
259
  }
153
- // A directory holding a package speaks for that package only - which is what keeps the root's
154
- // own config off every package under it. A directory that holds none (an intermediate
155
- // `packages/`, say) has no package to speak for, so its unmarked config can only mean
156
- // "everything below" and still cascades.
157
- const ownsAPackage = fs.existsSync(path.join(dir, 'package.json'));
158
- const speaksForTarget = !ownsAPackage || path.resolve(dir) === target;
159
260
  /**
160
- * `vars` is the **one** unmarked key that cascades past the package its directory speaks for,
161
- * and it is not a hole in that rule - it is a key the rule was never about. The rule exists
162
- * because a setting means different things to the two audiences (`run.build.after` on the root
163
- * is a repo-wide bookend, on a package its own hook), so one declaration cannot serve both.
164
- * `vars: {x: 1}` means the number 1 to everyone; there is no second audience to be wrong for.
261
+ * **Unmarked first, because it is the widest thing this level says** - and that is an inversion
262
+ * of the order this loop used to run in, where a directory's own plain config beat a selector
263
+ * declared beside it. Under the old reading "unmarked" meant *this package* and so was the
264
+ * narrower of the two; it now means *this package and everything below*, which is the wider.
265
+ * Precedence follows the audience, not the spelling, so it had to move.
165
266
  *
166
- * Merged *before* this directory's selector blocks, so `"[*]": {vars: ...}` - which names the
167
- * packages explicitly - overrides the same directory's plainer statement.
267
+ * Its position in the file is deliberately not consulted: a selector block written above the
268
+ * plain keys still wins. Unmarked is not a fourth selector - it is the level's floor, and the
269
+ * layer that feeds the directories below it.
168
270
  */
169
- if (!speaksForTarget && local.vars !== undefined)
170
- mergeConfig(result, { vars: local.vars });
171
- // Selectors next, so a directory's own unmarked config still wins over a selector declared
172
- // alongside it - "this package" is a more specific statement than "packages matching a glob".
173
- if (packageName) {
174
- for (const block of matchingSelectors(local, packageName, isRoot))
271
+ mergeConfig(result, stripSelectors(local));
272
+ /** Then the selector blocks, **in the order they were written** - see `matchingSelectors`.
273
+ * Reached for the root even with no selector, since `"[/]"` is structural. */
274
+ if (selector !== undefined || isRoot) {
275
+ for (const block of matchingSelectors(local, selector, isRoot))
175
276
  mergeConfig(result, block);
176
277
  }
177
- if (speaksForTarget)
178
- mergeConfig(result, stripSelectors(local));
179
278
  }
180
- /** Every layer has had its turn, so an append still outstanding has nothing left to attach to
181
- * and becomes the value itself. Done here rather than per layer: until the chain is finished,
182
- * the key it appends to may still be coming. */
183
- return finalizeConfig(result);
279
+ return result;
184
280
  }
185
- /** A config key naming packages rather than settings: `"[*]"`, `"[/]"`, `"[ws:*]"`, `"[pkg-a]"`. The
281
+ /** A config key naming packages rather than settings: `"[*]"`, `"[/]"`, `"[pkg-a]"`. The
186
282
  * brackets are what keep this space from colliding with real config keys - no setting starts with
187
283
  * one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
188
284
  * parses as a flow sequence. */
@@ -190,74 +286,77 @@ export function isSelectorKey(key) {
190
286
  return key.length > 2 && key.startsWith('[') && key.endsWith(']');
191
287
  }
192
288
  /**
193
- * **Which packages a selector speaks for.** Three audiences, because a repository has three:
289
+ * **Which packages a selector speaks for.** Two audiences, and the second is a glob:
194
290
  *
195
291
  * | | |
196
292
  * | --- | --- |
197
- * | `"[/]"` | the **root package** only |
198
- * | `"[*]"`, `"[pkg-a]"`, `"[*-dialect]"` | **every** package the glob matches, root included |
199
- * | `"[ws:*]"`, `"[workspace:pkg-*]"` | every **non-root** package the glob matches |
293
+ * | `"[/]"` | the **root package** alone |
294
+ * | `"[*]"`, `"[pkg-a]"`, `"[*-dialect]"` | the packages **below** this directory that the glob matches |
200
295
  *
201
296
  * `/` for the root because that is what a repository root is called everywhere else, and it cannot
202
- * collide with a package name. `ws:` is a qualifier on the glob rather than a separate spelling of
203
- * `*`, so `"[ws:pkg-*]"` means what it looks like.
297
+ * collide with a package name.
204
298
  *
205
- * **`"[*]"` includes the root, and that is a change from how it used to read.** Before, selectors
206
- * were not applied to the root at all, so `"[*]"` silently meant "the workspace packages" - a
207
- * catch-all with an exception nothing in the syntax mentioned. The three names above say which
208
- * audience is meant; `"[ws:*]"` is the old behaviour, now spelled.
299
+ * **The root is never selected by name, and that one rule removes two traps.** A glob matches
300
+ * package names, and the root is nobody's child - so `"[my-*]"` cannot quietly pick up a repository
301
+ * whose root package happens to be called `my-repo`, and `"[*]"` cannot hand a package-shaped
302
+ * setting to a root that has no build directory to apply it to. The root is addressed structurally
303
+ * or not at all.
304
+ *
305
+ * **`"[ws:*]"` / `"[workspace:*]"` is accepted and means exactly `"[*]"`.** The qualifier existed to
306
+ * say "not the root" back when a bare glob included it; the shape of the set says that now, so it
307
+ * has nothing left to add. Accepted rather than rejected because the two spellings resolve to the
308
+ * same packages - an error would be friction with no reader to protect.
209
309
  */
210
310
  export function parseSelector(key) {
211
311
  const inner = key.slice(1, -1);
212
312
  if (inner === ROOT_SELECTOR_INNER)
213
313
  return { scope: 'root', test: () => true };
214
- for (const prefix of WORKSPACE_PREFIXES) {
215
- if (inner.startsWith(prefix)) {
216
- const re = globToRegExp(inner.slice(prefix.length));
217
- return { scope: 'workspace', test: name => re.test(name) };
218
- }
219
- }
220
- const re = globToRegExp(inner);
221
- return { scope: 'all', test: name => re.test(name) };
314
+ const re = globToRegExp(stripWorkspacePrefix(inner));
315
+ return { scope: 'package', test: name => re.test(name) };
222
316
  }
223
317
  /** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
224
318
  * `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
225
319
  * pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
226
320
  export function selectorToRegExp(key) {
227
- return globToRegExp(key.slice(1, -1));
321
+ return globToRegExp(stripWorkspacePrefix(key.slice(1, -1)));
228
322
  }
229
323
  /**
230
- * Every selector block in `config` that speaks for this package, in increasing precedence.
324
+ * Every selector block in `config` that speaks for this package, **in the order they were written**
325
+ * - later wins, the way `overrides` works in eslint, prettier and babel, and the way a `.gitignore`
326
+ * rule does.
327
+ *
328
+ * **There used to be a ranking** (`"[*]"` lowest, then a catch-all `"[ws:*]"`, then the rest by
329
+ * declaration), so that "everything" could not beat a rule about one package by being written last.
330
+ * It was dropped because the ordering it implies does not exist: specificity only ranks sets that
331
+ * nest, and globs do not. For a package called `pkg-dialect`, neither `"[pkg-*]"` nor
332
+ * `"[*-dialect]"` contains the other, so any answer is an invented tiebreak - and an invented
333
+ * tiebreak is worse than the order the author typed. What was left was already declaration order
334
+ * with one case lifted out of it; this removes the exception rather than generalizing it.
231
335
  *
232
- * Order, lowest first: **`"[*]"`, then a catch-all `"[ws:*]"`, then the rest in declaration
233
- * order** - so narrowing the audience wins over the widest one, a named package or `"[/]"` wins
234
- * over both, and two equally specific globs resolve by the order they were written in. A catch-all
235
- * is ranked rather than left to declaration order on purpose: where you happen to write "everything"
236
- * should not decide whether it beats a rule about one package.
336
+ * The cost, which the docs state rather than hide: a catch-all written *below* a narrower block now
337
+ * overrides it. Writing catch-alls first is a convention, not a rule - the file reads top to bottom.
237
338
  */
238
- function matchingSelectors(config, packageName, isRoot) {
339
+ function matchingSelectors(config, selector, isRoot) {
239
340
  const matches = [];
240
341
  for (const [key, value] of Object.entries(config)) {
241
342
  if (!isSelectorKey(key) || !value || typeof value !== 'object')
242
343
  continue;
243
344
  const { scope, test } = parseSelector(key);
244
- if (scope === 'root' && !isRoot)
345
+ /** A root block asks only whether this *is* the root - no selector needed, which is what makes
346
+ * `/` structural. A glob has to have something to match, and during the walk it does not. */
347
+ if (scope === 'root' ? !isRoot : isRoot || selector === undefined || !test(selector))
245
348
  continue;
246
- if (scope === 'workspace' && isRoot)
247
- continue;
248
- if (!test(packageName))
249
- continue;
250
- matches.push([selectorRank(key), value]);
349
+ matches.push(value);
251
350
  }
252
- return matches.sort((a, b) => a[0] - b[0]).map(([, block]) => block);
351
+ return matches;
253
352
  }
254
- /** 0 for `"[*]"`, 1 for a catch-all workspace selector, 2 for anything that names something. Equal
255
- * ranks keep their declaration order, since `Array.prototype.sort` is stable. */
256
- function selectorRank(key) {
257
- if (key === CATCH_ALL)
258
- return 0;
259
- const inner = key.slice(1, -1);
260
- return WORKSPACE_PREFIXES.some(prefix => inner === `${prefix}*`) ? 1 : 2;
353
+ /** `"[ws:*]"` and `"[workspace:*]"` are the pre-2.x spelling of "not the root", kept working
354
+ * because they now name the same set a bare glob does. Stripped here so one code path serves both. */
355
+ function stripWorkspacePrefix(inner) {
356
+ for (const prefix of WORKSPACE_PREFIXES)
357
+ if (inner.startsWith(prefix))
358
+ return inner.slice(prefix.length);
359
+ return inner;
261
360
  }
262
361
  function globToRegExp(glob) {
263
362
  const source = glob
@@ -273,12 +372,10 @@ function stripSelectors(config) {
273
372
  result[key] = value;
274
373
  return result;
275
374
  }
276
- const CATCH_ALL = '[*]';
277
375
  /** `"[/]"` - the root package, spelled the way a repository root is spelled everywhere else, and
278
376
  * unable to collide with a package name. */
279
377
  const ROOT_SELECTOR_INNER = '/';
280
- /** Both spellings of "the workspace packages, not the root". The long one reads in a config file
281
- * someone else has to understand; the short one is what gets typed. */
378
+ /** Accepted spellings of the retired "not the root" qualifier - see `stripWorkspacePrefix`. */
282
379
  const WORKSPACE_PREFIXES = ['workspace:', 'ws:'];
283
380
  function dirChain(rootDir, targetDir) {
284
381
  const rel = path.relative(rootDir, targetDir);
@@ -292,6 +389,11 @@ function dirChain(rootDir, targetDir) {
292
389
  }
293
390
  return dirs;
294
391
  }
392
+ /**
393
+ * **Returns `Resolved<T>`, not `T`, because resolving is what it does.** Calling every value
394
+ * function is half this function's job, so the type it hands back is the one where they are gone -
395
+ * which is what makes `pkg.config` a `ResolvedConfig` without a cast anywhere between.
396
+ */
295
397
  export function interpolateConfig(config, scope, options) {
296
398
  const skip = options?.skip ?? [];
297
399
  /**
@@ -318,8 +420,8 @@ export function interpolateConfig(config, scope, options) {
318
420
  if (!config || typeof config !== 'object' || Array.isArray(config))
319
421
  return walk(config, scope, context, base, skip);
320
422
  /**
321
- * The config's own top-level keys, readable bare: `${{ publish.directory }}`. So a value that
322
- * restates another - `after: "cp README.md ${{ publish.directory }}/"` - stops being a second
423
+ * The config's own top-level keys, readable bare: `${{ changelog.filePath }}`. So a value that
424
+ * restates another - `after: "cp README.md ${{ changelog.filePath }}/"` - stops being a second
323
425
  * copy that drifts when the first one changes.
324
426
  *
325
427
  * Resolved **on demand**, one key at a time, and memoized. Interpolating the config in tree order
@@ -379,6 +481,13 @@ export function interpolateConfig(config, scope, options) {
379
481
  const result = {};
380
482
  for (const key of Object.keys(config))
381
483
  result[key] = resolve(key);
484
+ /**
485
+ * **The one cast in the whole two-view split, and it is here rather than at every read.** No type
486
+ * can prove that a runtime walk turned `T` into `Resolved<T>`; this walk is what makes it true.
487
+ * Putting it at this single return is what keeps `pkg.config` honest without a cast in any of the
488
+ * commands - which is the arrangement the alternative (widening `RmanConfig` alone) gave up, six
489
+ * read sites at a time.
490
+ */
382
491
  return result;
383
492
  }
384
493
  /**
@@ -399,14 +508,14 @@ export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'
399
508
  * config:
400
509
  *
401
510
  * ```js
402
- * '[ws:*]': {
511
+ * '[*]': {
403
512
  * clean: { include: ({ vars, value }) => [...value, vars.buildDir] }, // a value: called here
404
513
  * run: { build: { after: ({ pkg }) => copyDocs(pkg) } }, // a step: called by `run`
405
514
  * }
406
515
  * ```
407
516
  *
408
517
  * **The key decides, and it already did.** `run.build.exec: 'tsc -b'` is a shell command and
409
- * `publish.directory: 'build'` is a path - not because of anything about the strings, but because of
518
+ * `publish.npm.directory: 'build'` is a path - not because of anything about the strings, but because of
410
519
  * where they sit. A function inherits the same rule, so nothing new has to be learned and no marker
411
520
  * has to be remembered. The alternative was inspecting the function (arity, parameter names), which
412
521
  * is the kind of guess `loadPlugins` refuses to make about a module's export for the same reason:
@@ -434,16 +543,23 @@ export const STEP_PATHS = [
434
543
  ];
435
544
  /**
436
545
  * Keys whose **whole subtree** is code rather than config, so no function under them is a value to
437
- * compute. `plugins` is the only one, and it has to be here: an entry may be the plugin *object*
438
- * itself, and an `RmanPlugin` is almost entirely functions - `manifest.read`, `workspace.resolve`,
439
- * `versionPlanner`, `binPaths`, and every command's `builder` and `handler`.
440
- *
441
- * Measured, and it is why this exists: with `plugins` walked like any other key, resolving the
442
- * config of a repository that named a plugin called that plugin's yargs builder with the config
443
- * scope - `Config function in "plugins[0].commands[0].builder" failed: cmd.option is not a
444
- * function`. A `plugins` entry is loaded by `loadPlugins`, never read as a setting.
546
+ * compute.
547
+ *
548
+ * The three contribution keys, and each has to be here: an entry may be the *instance* itself, and
549
+ * a `Plugin` is almost entirely functions - `manifestProvider.read`, `getWorkspace`,
550
+ * `getBinPaths`, `versionPlanner` - while a command is often a bare factory and a publish target
551
+ * carries `getPlan`/`applyPlan`.
552
+ *
553
+ * Measured twice, once per shape. With `plugins` walked like any other key, resolving the config
554
+ * of a repository that named a plugin called that plugin's yargs builder with the config scope:
555
+ * `Config function in "plugins[0].commands[0].builder" failed: cmd.option is not a function`. And
556
+ * with `commands` left out of this list, a declarative command - which *is* a function - was
557
+ * invoked with the interpolation scope instead of the application, so its handler closed over a
558
+ * repository that was not one: `repository.getPackages is not a function`, from inside `clean`.
559
+ *
560
+ * These entries are loaded by `loadPlugins` and `cli.ts`, never read as settings.
445
561
  */
446
- export const CODE_SUBTREES = ['plugins'];
562
+ export const CODE_SUBTREES = ['plugins', 'commands', 'publishTargets'];
447
563
  const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
448
564
  /** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
449
565
  * `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
@@ -661,13 +777,14 @@ function shortenOrigin(file) {
661
777
  *
662
778
  * **Always bound, even with nothing underneath.** Left unbound, an expression naming it fails with
663
779
  * V8's `value is not defined`, which reads as "there is no such thing" rather than "nothing below
664
- * this layer set it" - two different mistakes needing two different fixes.
780
+ * this layer set it" - two different mistakes needing two different fixes. With nothing underneath
781
+ * it is `unsetValue()` rather than `undefined` - see there.
665
782
  *
666
783
  * The chain resolves bottom-up, so a layer deriving from a layer that itself derived from something
667
784
  * is handed the finished value rather than a half-resolved expression.
668
785
  */
669
786
  function walkWithPrevious(item, previous, scope, context, at, skip) {
670
- const resolved = previous === undefined ? undefined : walkWithPrevious(previous.value, previous.previous, scope, context, at, skip);
787
+ const resolved = previousValue(previous === undefined ? undefined : walkWithPrevious(previous.value, previous.previous, scope, context, at, skip), at);
671
788
  const outer = Object.getOwnPropertyDescriptor(context, VALUE_KEY);
672
789
  /** A getter, so the catch below can tell whether the value **actually read `value`**: the hint is
673
790
  * irrelevant to any other failure, and attaching it anyway is the send-the-reader-to-the-wrong-
@@ -686,22 +803,18 @@ function walkWithPrevious(item, previous, scope, context, at, skip) {
686
803
  }
687
804
  catch (e) {
688
805
  /**
689
- * **`value` is `undefined` when no layer underneath set this key**, and a value written to
690
- * extend an inherited list is also the *first* layer in a repository that inherits nothing.
691
- * V8 reports that as `value is not iterable`, naming neither the key nor the reason.
692
- *
693
- * Here rather than in `callValueFn`, so the expression and the function spelling get the same
694
- * sentence from the same place. `rmanValueHint` keeps a rethrow from stacking it twice as the
695
- * error passes back up through the enclosing keys.
806
+ * **The hint is only about *reading* `value`**, so it is attached only when the value did -
807
+ * recorded through the getter above, never matched on V8's wording. Attaching it to any other
808
+ * failure is the send-the-reader-to-the-wrong-place mistake it exists to prevent.
696
809
  *
697
- * Not papered over by defaulting `value` to `[]`: that would be a guess about the key's type,
698
- * and wrong for every key that is not a list.
810
+ * The sentence itself comes from `unsetValue`, which knows the key and throws at the exact
811
+ * point of misuse; all this adds is the case the sentinel cannot catch, where a value reads
812
+ * `value` and fails for a reason of its own. `rmanValueHint` keeps a rethrow from stacking it
813
+ * twice as the error passes back up through the enclosing keys.
699
814
  */
700
- if (wasRead && resolved === undefined && !e?.rmanValueHint) {
815
+ if (wasRead && isUnsetValue(resolved) && !e?.rmanValueHint) {
701
816
  e.rmanValueHint = true;
702
- e.message =
703
- `${e.message}\n \`value\` is undefined here - nothing below this layer sets "${describeAt(at)}.` +
704
- `\n Write \`value ?? []\` (or \`?? ''\`) if it has to work as the first layer too.`;
817
+ e.message = `${e.message}\n Note: nothing below this layer sets "${describeAt(at)}, so \`value\` is empty.`;
705
818
  }
706
819
  throw e;
707
820
  }
@@ -942,3 +1055,69 @@ function deepFreeze(value) {
942
1055
  for (const item of Object.values(value))
943
1056
  deepFreeze(item);
944
1057
  }
1058
+ /**
1059
+ * What a layer is handed as `value`: **the list form of whatever is underneath it.**
1060
+ *
1061
+ * `value` exists for one job - extending what a closer layer inherited, the general form of `+key`,
1062
+ * and `+key` only ever meant append. So the shape a spread wants is the shape to hand over:
1063
+ * `[...value, 'x']` works with no guard whether the layers below said nothing, said `'build'`, or
1064
+ * said `['build']`.
1065
+ *
1066
+ * **Coercing a scalar into a one-element list is not a guess about the key's type.** Every key this
1067
+ * is reached for is declared `X | X[]` - `clean.include`, `clean.exclude`, `version.stamp`,
1068
+ * `version.before`/`.exec`/`.after` - where the list is the type and the scalar is *shorthand*.
1069
+ * `CleanService` and `RunService` already normalize it; doing it here as well decides nothing new.
1070
+ * And spreading a string into its characters, which is what handing the raw value over did, is not
1071
+ * something any key wants.
1072
+ *
1073
+ * It reads as the scalar wherever a scalar is what makes sense, through `Symbol.toPrimitive`:
1074
+ * `` `${value}-x` `` is `'build-x'` and `value + 1` is `6`. A *list* underneath refuses both, since
1075
+ * splicing `a,b` into a sentence is a mistake worth naming; so does nothing-underneath.
1076
+ *
1077
+ * **A boolean is handed over as itself**, the one carve-out, because it is never a list nor a
1078
+ * list's shorthand - and an object cannot be fixed up for it: `!value` and `value ? :` use
1079
+ * ToBoolean, which has no hook and answers `true` for every object, so a wrapped `false` would read
1080
+ * as `true`. Measured. `[...value]` on one then throws, which is right - spreading a boolean means
1081
+ * nothing.
1082
+ *
1083
+ * **The cost, stated rather than hidden: strict equality and string methods on an inherited
1084
+ * scalar.** `value === 'build'` is `false` and `value.includes('bui')` is `false` (an array's
1085
+ * `includes` matches elements, not substrings). `value == 'build'`, `` `${value}` === 'build' `` and
1086
+ * `String(value).includes('bui')` all work, and `value.length` is the number of layers' worth of
1087
+ * entries rather than a string's length. That is the trade for the append case never needing a
1088
+ * guard; `value` was introduced for the append case.
1089
+ */
1090
+ function previousValue(raw, at) {
1091
+ /** Never a list, and unfixable as one - see above. */
1092
+ if (typeof raw === 'boolean')
1093
+ return raw;
1094
+ const list = raw === undefined ? [] : Array.isArray(raw) ? [...raw] : [raw];
1095
+ Object.defineProperty(list, UNSET_MARKER, { value: raw === undefined });
1096
+ return Object.defineProperty(list, Symbol.toPrimitive, {
1097
+ value: (hint) => {
1098
+ if (raw === undefined) {
1099
+ throw unusable(at, 'nothing below this layer sets it, so it is empty', "`value ?? ''`, `value ?? 0`");
1100
+ }
1101
+ if (typeof raw !== 'string' && typeof raw !== 'number') {
1102
+ throw unusable(at, `the layer below it is ${Array.isArray(raw) ? 'a list' : 'an object'}`, '`value.join(", ")` for a list');
1103
+ }
1104
+ return hint === 'string' ? String(raw) : raw;
1105
+ },
1106
+ });
1107
+ }
1108
+ /** The one sentence both refusals share: what was asked for, why it cannot be done, what to write
1109
+ * instead. Marked `rmanValueHint` so `walkWithPrevious`'s catch leaves it alone - that note exists
1110
+ * to explain an empty `value` to an error that does not mention it, and this error *is* that
1111
+ * explanation. */
1112
+ function unusable(at, because, instead) {
1113
+ const error = new Error(`\`value\` cannot be used as a string or a number here - ${because}, for "${describeAt(at)}. ` +
1114
+ `It spreads as a list (\`[...value, x]\`); to use it as something else, say what it should be - ${instead}.`);
1115
+ error.rmanValueHint = true;
1116
+ return error;
1117
+ }
1118
+ /** Whether `value` stands for "no layer underneath set this key" - by the marker `previousValue`
1119
+ * puts on it, never by emptiness, since a layer may legitimately resolve to `[]`. */
1120
+ function isUnsetValue(value) {
1121
+ return Array.isArray(value) && value[UNSET_MARKER] === true;
1122
+ }
1123
+ const UNSET_MARKER = Symbol('rman.valueUnset');