rman 1.0.12 → 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.
Files changed (102) hide show
  1. package/README.md +90 -70
  2. package/cli.js +226 -14
  3. package/commands/build.command.js +1 -0
  4. package/commands/changed.command.js +2 -2
  5. package/commands/changelog.command.js +13 -15
  6. package/commands/config.command.js +61 -0
  7. package/commands/diff.command.js +9 -4
  8. package/commands/exec.command.js +2 -8
  9. package/commands/github-release.command.js +1 -0
  10. package/commands/info.command.d.ts +9 -0
  11. package/commands/info.command.js +12 -2
  12. package/commands/run.command.js +5 -8
  13. package/commands/test.command.js +1 -0
  14. package/commands/version.command.js +53 -14
  15. package/constants.js +1 -1
  16. package/core/config.d.ts +265 -17
  17. package/core/config.js +651 -76
  18. package/core/custom-command.d.ts +133 -0
  19. package/core/custom-command.js +99 -0
  20. package/core/extends-config.d.ts +27 -0
  21. package/core/extends-config.js +89 -0
  22. package/core/manifest.d.ts +222 -0
  23. package/core/manifest.js +150 -0
  24. package/core/merge-config.d.ts +70 -0
  25. package/core/merge-config.js +193 -0
  26. package/core/package.d.ts +73 -7
  27. package/core/package.js +86 -24
  28. package/core/plugin.d.ts +112 -0
  29. package/core/plugin.js +189 -0
  30. package/core/repository.d.ts +91 -1
  31. package/core/repository.js +277 -132
  32. package/core/resolve-target.d.ts +12 -0
  33. package/core/resolve-target.js +33 -0
  34. package/core/run-step.d.ts +75 -0
  35. package/core/run-step.js +1 -0
  36. package/core/version-scheme.d.ts +134 -0
  37. package/core/version-scheme.js +148 -0
  38. package/core/workspace.d.ts +68 -0
  39. package/core/workspace.js +83 -0
  40. package/index.d.ts +55 -8
  41. package/index.js +42 -7
  42. package/interfaces/rman-config.interface.d.ts +222 -46
  43. package/package.json +16 -7
  44. package/services/change-hash.service.d.ts +88 -0
  45. package/services/change-hash.service.js +112 -0
  46. package/services/changelog.service.d.ts +8 -13
  47. package/services/changelog.service.js +12 -11
  48. package/services/conventional-commits.service.d.ts +73 -0
  49. package/services/conventional-commits.service.js +116 -0
  50. package/services/docker-publish.service.js +1 -1
  51. package/services/exec.service.js +1 -1
  52. package/services/github-release.service.d.ts +2 -2
  53. package/services/github-release.service.js +10 -5
  54. package/services/list.service.js +5 -2
  55. package/services/run.service.d.ts +112 -6
  56. package/services/run.service.js +265 -89
  57. package/services/system-info.d.ts +22 -7
  58. package/services/system-info.js +8 -23
  59. package/services/version-plan.service.d.ts +244 -0
  60. package/services/version-plan.service.js +414 -0
  61. package/services/version.service.d.ts +102 -82
  62. package/services/version.service.js +226 -434
  63. package/services.d.ts +5 -3
  64. package/services.js +5 -3
  65. package/utils/bin-path.d.ts +59 -0
  66. package/utils/bin-path.js +82 -0
  67. package/utils/child-tracker.d.ts +16 -0
  68. package/utils/child-tracker.js +30 -0
  69. package/utils/exec.d.ts +13 -2
  70. package/utils/exec.js +17 -17
  71. package/utils/git.d.ts +9 -3
  72. package/utils/git.js +10 -2
  73. package/utils/package-filter.d.ts +33 -2
  74. package/utils/package-filter.js +47 -7
  75. package/utils/printable-config.d.ts +15 -0
  76. package/utils/printable-config.js +42 -0
  77. package/utils/release-version.js +3 -3
  78. package/utils/run-bin.d.ts +46 -0
  79. package/utils/run-bin.js +63 -0
  80. package/utils/version-stamp.d.ts +14 -6
  81. package/utils/version-stamp.js +25 -13
  82. package/commands/ci.command.js +0 -30
  83. package/commands/clean.command.d.ts +0 -3
  84. package/commands/clean.command.js +0 -36
  85. package/commands/publish.command.d.ts +0 -3
  86. package/commands/publish.command.js +0 -225
  87. package/rmanrc.schema.json +0 -392
  88. package/services/ci.service.d.ts +0 -40
  89. package/services/ci.service.js +0 -204
  90. package/services/clean.service.d.ts +0 -42
  91. package/services/clean.service.js +0 -226
  92. package/services/publish.service.d.ts +0 -79
  93. package/services/publish.service.js +0 -273
  94. package/utils/change-hash.d.ts +0 -68
  95. package/utils/change-hash.js +0 -98
  96. package/utils/conventional-commits.d.ts +0 -52
  97. package/utils/conventional-commits.js +0 -90
  98. package/utils/npm-run-path.d.ts +0 -67
  99. package/utils/npm-run-path.js +0 -63
  100. package/utils/workspace-range.d.ts +0 -17
  101. package/utils/workspace-range.js +0 -28
  102. /package/commands/{ci.command.d.ts → config.command.d.ts} +0 -0
package/core/config.js CHANGED
@@ -1,11 +1,14 @@
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';
5
- import merge from 'putil-merge';
6
7
  import semver from 'semver';
7
8
  import { pathToFileURL } from 'url';
8
9
  import vm from 'vm';
10
+ import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
11
+ import { finalizeConfig, mergeConfig, PREVIOUS_VALUE } from './merge-config.js';
9
12
  /**
10
13
  * Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
11
14
  * autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
@@ -58,33 +61,56 @@ async function loadJsConfig(file) {
58
61
  */
59
62
  export async function readDirConfig(dirname) {
60
63
  const result = {};
64
+ /** The file an `extends` in this directory resolves relative to. The last form that actually
65
+ * declared one wins, which matters only for the unusual directory holding several. */
66
+ let extendsFrom = path.join(dirname, '.rmanrc');
61
67
  const pkgJsonFile = path.join(dirname, 'package.json');
62
68
  if (fs.existsSync(pkgJsonFile)) {
63
69
  const pkgJson = JSON.parse(fs.readFileSync(pkgJsonFile, 'utf-8'));
64
- if (pkgJson && typeof pkgJson.rman === 'object')
65
- merge(result, pkgJson.rman, { deep: true });
70
+ if (pkgJson && typeof pkgJson.rman === 'object') {
71
+ assertNoSelectorExtends(pkgJson.rman, pkgJsonFile);
72
+ if (EXTENDS_KEY in pkgJson.rman)
73
+ extendsFrom = pkgJsonFile;
74
+ mergeConfig(result, pkgJson.rman);
75
+ }
66
76
  }
67
77
  const ymlFile = path.join(dirname, '.rmanrc.yml');
68
78
  if (fs.existsSync(ymlFile)) {
69
79
  const obj = yaml.load(fs.readFileSync(ymlFile, 'utf-8'));
70
- if (obj && typeof obj === 'object')
71
- merge(result, obj, { deep: true });
80
+ if (obj && typeof obj === 'object') {
81
+ assertNoSelectorExtends(obj, ymlFile);
82
+ if (EXTENDS_KEY in obj)
83
+ extendsFrom = ymlFile;
84
+ mergeConfig(result, obj);
85
+ }
72
86
  }
73
87
  const rcFile = path.join(dirname, '.rmanrc');
74
88
  if (fs.existsSync(rcFile)) {
75
89
  const obj = JSON.parse(fs.readFileSync(rcFile, 'utf-8'));
76
- if (obj && typeof obj === 'object')
77
- merge(result, obj, { deep: true });
90
+ if (obj && typeof obj === 'object') {
91
+ assertNoSelectorExtends(obj, rcFile);
92
+ if (EXTENDS_KEY in obj)
93
+ extendsFrom = rcFile;
94
+ mergeConfig(result, obj);
95
+ }
78
96
  }
79
97
  for (const jsFileName of JS_CONFIG_FILES) {
80
98
  const jsFile = path.join(dirname, jsFileName);
81
99
  if (fs.existsSync(jsFile)) {
82
100
  const obj = await loadJsConfig(jsFile);
83
- if (obj && typeof obj === 'object')
84
- merge(result, obj, { deep: true });
101
+ if (obj && typeof obj === 'object') {
102
+ assertNoSelectorExtends(obj, jsFile);
103
+ if (EXTENDS_KEY in obj)
104
+ extendsFrom = jsFile;
105
+ mergeConfig(result, obj);
106
+ }
85
107
  }
86
108
  }
87
- return result;
109
+ /** Resolved per directory, once its own forms have been combined: `extends` is the base every
110
+ * one of them sits on, and the directory chain then layers on top as it always did. Each form
111
+ * was checked for a misplaced `extends` as it was read, so that error can name the file holding
112
+ * it rather than whichever form happened to declare the real one. */
113
+ return resolveExtends(result, extendsFrom);
88
114
  }
89
115
  /**
90
116
  * Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
@@ -97,9 +123,10 @@ export async function readDirConfig(dirname) {
97
123
  * `.rmanrc` therefore configures the *root package* - which is where repo-wide settings
98
124
  * (`packageManager`, `allowBranch`, `version.*`, `githubRelease.*`) are read from anyway - and
99
125
  * not, silently, every package under it.
100
- * - **A `"[selector]"` block configures the packages it names** (`"[*]"` for all of them,
101
- * `"[*-dialect]"` for a glob over package names). This is the only way a directory speaks about
102
- * anything but its own package.
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.
103
130
  *
104
131
  * Splitting the two matters because the same key means different things to the two audiences. The
105
132
  * clearest case is `run.<script>.postScript`: on a package it's that package's build hook, run in
@@ -107,64 +134,137 @@ export async function readDirConfig(dirname) {
107
134
  * cascade that fed one declaration to both ran a package-relative command (`node
108
135
  * ../../support/postbuild.cjs`) at the root, where it cannot resolve.
109
136
  *
110
- * `packageName` is what selectors match against; without it (resolving the root's own config, say)
111
- * selector blocks contribute nothing at all.
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.
112
139
  */
113
140
  export async function resolveConfig(rootDir, targetDir, cache = new Map(), packageName) {
114
141
  const result = {};
115
142
  const target = path.resolve(targetDir);
143
+ /** The root *package* is the one whose directory is the repository root - no other test is
144
+ * 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. */
146
+ const isRoot = target === path.resolve(rootDir);
116
147
  for (const dir of dirChain(rootDir, targetDir)) {
117
148
  let local = cache.get(dir);
118
149
  if (!local) {
119
150
  local = await readDirConfig(dir);
120
151
  cache.set(dir, local);
121
152
  }
122
- // Selectors first, so a directory's own unmarked config still wins over a selector declared
123
- // alongside it - "this package" is a more specific statement than "packages matching a glob".
124
- if (packageName) {
125
- for (const block of matchingSelectors(local, packageName))
126
- merge(result, block, { deep: true });
127
- }
128
153
  // A directory holding a package speaks for that package only - which is what keeps the root's
129
154
  // own config off every package under it. A directory that holds none (an intermediate
130
155
  // `packages/`, say) has no package to speak for, so its unmarked config can only mean
131
156
  // "everything below" and still cascades.
132
157
  const ownsAPackage = fs.existsSync(path.join(dir, 'package.json'));
133
- if (!ownsAPackage || path.resolve(dir) === target)
134
- merge(result, stripSelectors(local), { deep: true });
158
+ const speaksForTarget = !ownsAPackage || path.resolve(dir) === target;
159
+ /**
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.
165
+ *
166
+ * Merged *before* this directory's selector blocks, so `"[*]": {vars: ...}` - which names the
167
+ * packages explicitly - overrides the same directory's plainer statement.
168
+ */
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))
175
+ mergeConfig(result, block);
176
+ }
177
+ if (speaksForTarget)
178
+ mergeConfig(result, stripSelectors(local));
135
179
  }
136
- return result;
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);
137
184
  }
138
- /** A config key naming packages rather than settings: `"[*]"`, `"[*-dialect]"`, `"[pkg-a]"`. The
185
+ /** A config key naming packages rather than settings: `"[*]"`, `"[/]"`, `"[ws:*]"`, `"[pkg-a]"`. The
139
186
  * brackets are what keep this space from colliding with real config keys - no setting starts with
140
187
  * one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
141
188
  * parses as a flow sequence. */
142
189
  export function isSelectorKey(key) {
143
190
  return key.length > 2 && key.startsWith('[') && key.endsWith(']');
144
191
  }
192
+ /**
193
+ * **Which packages a selector speaks for.** Three audiences, because a repository has three:
194
+ *
195
+ * | | |
196
+ * | --- | --- |
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 |
200
+ *
201
+ * `/` 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.
204
+ *
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.
209
+ */
210
+ export function parseSelector(key) {
211
+ const inner = key.slice(1, -1);
212
+ if (inner === ROOT_SELECTOR_INNER)
213
+ 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) };
222
+ }
145
223
  /** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
146
224
  * `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
147
225
  * pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
148
226
  export function selectorToRegExp(key) {
149
- const glob = key.slice(1, -1);
150
- const source = glob
151
- .split('*')
152
- .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
153
- .join('.*');
154
- return new RegExp(`^${source}$`);
227
+ return globToRegExp(key.slice(1, -1));
155
228
  }
156
- /** Every selector block in `config` matching `packageName`, in increasing precedence: `"[*]"` first
157
- * and the rest in declaration order - so a specific glob overrides the catch-all, and two equally
158
- * specific ones resolve by the order they were written in. */
159
- function matchingSelectors(config, packageName) {
229
+ /**
230
+ * Every selector block in `config` that speaks for this package, in increasing precedence.
231
+ *
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.
237
+ */
238
+ function matchingSelectors(config, packageName, isRoot) {
160
239
  const matches = [];
161
240
  for (const [key, value] of Object.entries(config)) {
162
241
  if (!isSelectorKey(key) || !value || typeof value !== 'object')
163
242
  continue;
164
- if (selectorToRegExp(key).test(packageName))
165
- matches.push([key, value]);
243
+ const { scope, test } = parseSelector(key);
244
+ if (scope === 'root' && !isRoot)
245
+ continue;
246
+ if (scope === 'workspace' && isRoot)
247
+ continue;
248
+ if (!test(packageName))
249
+ continue;
250
+ matches.push([selectorRank(key), value]);
166
251
  }
167
- return matches.sort((a, b) => Number(b[0] === CATCH_ALL) - Number(a[0] === CATCH_ALL)).map(([, block]) => block);
252
+ return matches.sort((a, b) => a[0] - b[0]).map(([, block]) => block);
253
+ }
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;
261
+ }
262
+ function globToRegExp(glob) {
263
+ const source = glob
264
+ .split('*')
265
+ .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
266
+ .join('.*');
267
+ return new RegExp(`^${source}$`);
168
268
  }
169
269
  function stripSelectors(config) {
170
270
  const result = {};
@@ -174,6 +274,12 @@ function stripSelectors(config) {
174
274
  return result;
175
275
  }
176
276
  const CATCH_ALL = '[*]';
277
+ /** `"[/]"` - the root package, spelled the way a repository root is spelled everywhere else, and
278
+ * unable to collide with a package name. */
279
+ 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. */
282
+ const WORKSPACE_PREFIXES = ['workspace:', 'ws:'];
177
283
  function dirChain(rootDir, targetDir) {
178
284
  const rel = path.relative(rootDir, targetDir);
179
285
  if (!rel || rel === '.' || rel.startsWith('..'))
@@ -186,61 +292,404 @@ function dirChain(rootDir, targetDir) {
186
292
  }
187
293
  return dirs;
188
294
  }
295
+ export function interpolateConfig(config, scope, options) {
296
+ const skip = options?.skip ?? [];
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)));
318
+ if (!config || typeof config !== 'object' || Array.isArray(config))
319
+ return walk(config, scope, context, base, skip);
320
+ /**
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
323
+ * copy that drifts when the first one changes.
324
+ *
325
+ * Resolved **on demand**, one key at a time, and memoized. Interpolating the config in tree order
326
+ * and handing an expression whatever was ready would make the answer depend on key order in the
327
+ * file, which is exactly the kind of quiet wrongness this evaluator exists to prevent: a key
328
+ * declared above would read as resolved and one below as raw. On demand, each key is resolved
329
+ * when first read and the order in the file means nothing.
330
+ */
331
+ const resolved = new Map();
332
+ const resolving = [];
333
+ /**
334
+ * A cycle is **recorded here rather than thrown from the getter**, and that is not a style
335
+ * choice: a host getter that throws inside a `vm` property interceptor has its exception
336
+ * swallowed, and V8 then reports the global as absent - so a self-referencing key came out as
337
+ * `publish is not defined`, which sends the reader looking for a missing key instead of a loop
338
+ * (measured). The getter returns `undefined`, the resulting `ReferenceError` is caught below, and
339
+ * this replaces it.
340
+ */
341
+ let cycle;
342
+ const resolve = (key) => {
343
+ if (resolved.has(key))
344
+ return resolved.get(key);
345
+ if (resolving.includes(key)) {
346
+ cycle ??= new Error(`Config expression forms a cycle: ${[...resolving, key].join(' -> ')}\n` +
347
+ ` A value cannot be derived from itself, directly or through another key.`);
348
+ return undefined;
349
+ }
350
+ resolving.push(key);
351
+ try {
352
+ const value = walk(config[key], scope, context, [...base, key], skip);
353
+ resolved.set(key, value);
354
+ return value;
355
+ }
356
+ catch (e) {
357
+ /** Not cleared: a cycle aborts the whole interpolation, and each level up would otherwise
358
+ * re-swallow its own replacement the same way - leaving it set lets the outermost frame,
359
+ * the one with a real stack to throw from, report it. */
360
+ if (cycle)
361
+ throw new Error(`${String(e?.message).split('\n')[0]}\n ${cycle.message}`, { cause: e });
362
+ throw e;
363
+ }
364
+ finally {
365
+ resolving.pop();
366
+ }
367
+ };
368
+ for (const key of Object.keys(config)) {
369
+ /** A scope binding wins: `pkg`/`repository`/`env`/`semver` are not config keys, so nothing
370
+ * collides today, and a future key that did must not silently take over the namespace. */
371
+ if (key in scope || !IDENTIFIER.test(key))
372
+ continue;
373
+ Object.defineProperty(context, key, { enumerable: true, configurable: true, get: () => resolve(key) });
374
+ }
375
+ /** Built through the same memo the getters use, so every key is walked exactly once whether an
376
+ * expression asked for it first or the result did. */
377
+ const result = {};
378
+ for (const key of Object.keys(config))
379
+ result[key] = resolve(key);
380
+ return result;
381
+ }
189
382
  /**
190
- * Evaluates every `${{ ... }}` expression in **every** string value of a resolved config, against
191
- * the package it was resolved for:
383
+ * Config paths left untouched when a repository's config is first resolved, and evaluated only by
384
+ * the command that runs them.
192
385
  *
193
- * ```yaml
194
- * "[*]":
195
- * clean:
196
- * include: ["build", "../../coverage/${{ pkg.basename }}"]
197
- * publish:
198
- * docker:
199
- * image: "panates/${{ pkg.basename }}:${{ semver.major(pkg.version) }}"
200
- * ```
386
+ * `version`'s own hooks are the one place `${{ pkg.targetVersion }}` makes sense, and the version
387
+ * being written is not known until `version` has computed its plan - long after the config was
388
+ * resolved. Evaluating these eagerly would throw while merely *loading* the repository, so any
389
+ * command at all would fail on a config that mentions it.
390
+ */
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`).
201
395
  *
202
- * Every string, with no list of "interpolated keys" to memorize - a rule with exceptions is a rule
203
- * nobody remembers.
396
+ * This is what tells a step function from a value function, and the two live side by side in one
397
+ * config:
204
398
  *
205
- * The contents are **real JavaScript**, not a template mini-language, so there is no growing list
206
- * of substitutions to keep adding (`{{major}}`, `{{scope}}`, ...) - see `ConfigScope` for what is
207
- * in scope.
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
+ * ```
208
405
  *
209
- * **`${{ }}`, deliberately not `{{ }}`.** A config value may legitimately carry `{{...}}` meant for
210
- * something else entirely (`helm template --set tag={{.Values.tag}}`); with the plainer delimiter
211
- * rman would try to evaluate it. To emit a literal, let an expression produce it, the way GitHub
212
- * Actions does: `${{ '${{' }}`.
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.
213
413
  *
214
- * A string that is *nothing but* one expression keeps the value's own type (`"${{ pkg.private }}"`
215
- * -> a boolean), since otherwise this could only ever produce strings and settings like
216
- * `run.<script>.skip` would be unreachable. Embedded in surrounding text it is stringified.
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`.
217
438
  *
218
- * Evaluation happens in a fresh V8 context holding only the scope's bindings. That is a clean
219
- * scope, **not a sandbox** - `node:vm` is explicitly not a security mechanism, and no sandbox is
220
- * called for here anyway: a `.rmanrc` that can say `exec: "..."` already runs arbitrary shell, so
221
- * the expression evaluator adds no trust boundary that wasn't already wide open.
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'];
445
+ const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
446
+ /** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
447
+ * `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
448
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
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.
222
453
  *
223
- * A failing expression throws with the config path that holds it, rather than being left in place:
224
- * silently passing through a mistake is how a config ends up quietly doing nothing.
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`.
225
464
  */
226
- export function interpolateConfig(config, scope) {
227
- const context = vm.createContext({ ...scope });
228
- return walk(config, scope, context, []);
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
+ };
229
472
  }
230
- const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
231
- function walk(value, scope, context, at) {
473
+ export function createFileScope(dirname) {
474
+ const locate = (target) => {
475
+ if (typeof target !== 'string' || !target.trim()) {
476
+ throw new Error('file.exists()/file.resolve() need a path - they were given ' + JSON.stringify(target));
477
+ }
478
+ const resolved = path.resolve(dirname, target);
479
+ return { path: resolved, found: fs.existsSync(resolved) };
480
+ };
481
+ return {
482
+ exists(target) {
483
+ const { path: resolved, found } = locate(target);
484
+ return found ? resolved : '';
485
+ },
486
+ resolve(target) {
487
+ const { path: resolved, found } = locate(target);
488
+ if (!found) {
489
+ throw new Error(`file.resolve("${target}") found nothing at ${resolved}\n` +
490
+ ` Use file.exists() instead if its absence is a case to handle rather than a mistake.`);
491
+ }
492
+ return resolved;
493
+ },
494
+ resolveFirst(...targets) {
495
+ if (!targets.length)
496
+ throw new Error('file.resolveFirst() needs at least one path');
497
+ for (const target of targets) {
498
+ const { path: resolved, found } = locate(target);
499
+ if (found)
500
+ return resolved;
501
+ }
502
+ throw new Error(`file.resolveFirst() found none of: ${targets.map(t => `"${t}"`).join(', ')}\n` + ` Looked in ${dirname}.`);
503
+ },
504
+ };
505
+ }
506
+ function walk(value, scope, context, at, skip) {
507
+ /** Compared on the key path rather than the value, so a deferred key's whole subtree - a single
508
+ * command or an array of them - is handed on untouched. */
509
+ if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
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
+ }
232
520
  if (typeof value === 'string')
233
521
  return interpolateString(value, context, at);
234
522
  if (Array.isArray(value))
235
- return value.map((item, i) => walk(item, scope, context, [...at, i]));
523
+ return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
236
524
  if (value && typeof value === 'object') {
237
- const result = {};
238
- for (const [key, item] of Object.entries(value))
239
- result[key] = walk(item, scope, context, [...at, key]);
240
- 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
+ });
241
531
  }
242
532
  return value;
243
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
+ }
244
693
  function interpolateString(value, context, at) {
245
694
  if (!value.includes('${{'))
246
695
  return value;
@@ -284,3 +733,129 @@ function formatPath(at) {
284
733
  /** Guards against an expression that never returns (`while(true)`) taking the whole command with
285
734
  * it - a typo, not an attack, but the failure mode is identical. */
286
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
+ }