rman 2.0.0-beta.2 → 2.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +2 -12
  2. package/{plugins/node/augmentation/rman.augmentation.d.ts → builtins/platforms/node/augmentation/rmanrc.augmentation.d.ts} +4 -5
  3. package/{plugins → builtins/platforms}/node/augmentation/system-info.augmentation.js +1 -1
  4. package/{plugins → builtins/platforms}/node/commands/ci.command.d.ts +5 -3
  5. package/{plugins → builtins/platforms}/node/commands/ci.command.js +6 -5
  6. package/{plugins → builtins/platforms}/node/commands/clean.command.d.ts +6 -4
  7. package/{plugins → builtins/platforms}/node/commands/clean.command.js +6 -5
  8. package/builtins/platforms/node/index.d.ts +8 -0
  9. package/builtins/platforms/node/index.js +8 -0
  10. package/{plugins → builtins/platforms}/node/node-config.interface.d.ts +1 -2
  11. package/{plugins → builtins/platforms}/node/node-manifest.provider.d.ts +3 -3
  12. package/{plugins → builtins/platforms}/node/node-manifest.provider.js +3 -3
  13. package/{plugins → builtins/platforms}/node/node.platform.d.ts +3 -3
  14. package/{plugins → builtins/platforms}/node/services/ci.service.d.ts +3 -3
  15. package/{plugins → builtins/platforms}/node/services/ci.service.js +4 -4
  16. package/{plugins → builtins/platforms}/node/services/clean.service.d.ts +3 -3
  17. package/{plugins → builtins/platforms}/node/services/clean.service.js +3 -3
  18. package/{plugins → builtins/platforms}/node/services/publish.service.d.ts +4 -4
  19. package/{plugins → builtins/platforms}/node/services/publish.service.js +13 -7
  20. package/{plugins → builtins/platforms}/node/services/version-plan.service.d.ts +3 -3
  21. package/{plugins → builtins/platforms}/node/services/version-plan.service.js +2 -2
  22. package/builtins/presets/index.d.ts +15 -0
  23. package/builtins/presets/index.js +57 -0
  24. package/builtins/presets/node.d.ts +16 -0
  25. package/builtins/presets/node.js +57 -0
  26. package/{services → builtins/publish-targets/docker}/docker-publish.service.d.ts +6 -6
  27. package/{services → builtins/publish-targets/docker}/docker-publish.service.js +9 -9
  28. package/{targets → builtins/publish-targets/docker}/docker.target.d.ts +6 -5
  29. package/{targets → builtins/publish-targets/docker}/docker.target.js +1 -1
  30. package/builtins/publish-targets/docker/index.d.ts +2 -0
  31. package/builtins/publish-targets/docker/index.js +2 -0
  32. package/builtins/publish-targets/npm/index.d.ts +2 -0
  33. package/builtins/publish-targets/npm/index.js +2 -0
  34. package/{plugins/node → builtins/publish-targets/npm}/npm-publish-target.d.ts +4 -4
  35. package/{plugins/node → builtins/publish-targets/npm}/npm-publish-target.js +4 -5
  36. package/cli.d.ts +3 -0
  37. package/cli.js +16 -7
  38. package/commands/changelog.command.d.ts +106 -2
  39. package/commands/changelog.command.js +40 -0
  40. package/commands/config.command.js +23 -5
  41. package/commands/list.command.js +1 -1
  42. package/commands/publish.command.d.ts +1 -1
  43. package/commands/publish.command.js +1 -1
  44. package/commands/run.command.js +1 -1
  45. package/commands/version.command.d.ts +16 -3
  46. package/commands/version.command.js +143 -9
  47. package/commands.d.ts +0 -1
  48. package/commands.js +0 -1
  49. package/constants.js +1 -1
  50. package/core/application.d.ts +10 -10
  51. package/core/application.js +28 -11
  52. package/core/{package.d.ts → classes/package.d.ts} +12 -4
  53. package/core/{package.js → classes/package.js} +17 -1
  54. package/core/{repository.d.ts → classes/repository.d.ts} +22 -36
  55. package/core/{repository.js → classes/repository.js} +112 -241
  56. package/core/{service.d.ts → classes/service.d.ts} +2 -2
  57. package/core/classes/workspace.d.ts +277 -0
  58. package/core/classes/workspace.js +585 -0
  59. package/core/command-builder.js +17 -1
  60. package/core/config/config-file-scope.d.ts +107 -0
  61. package/core/config/config-file-scope.js +226 -0
  62. package/core/config/config-interpolator.d.ts +164 -0
  63. package/core/config/config-interpolator.js +577 -0
  64. package/core/config/config-paths.d.ts +65 -0
  65. package/core/config/config-paths.js +78 -0
  66. package/core/config/config-reader.d.ts +215 -0
  67. package/core/config/config-reader.js +851 -0
  68. package/core/{merge-config.js → config/merge-config.js} +4 -8
  69. package/core/{custom-command.d.ts → interfaces/custom-command.d.ts} +5 -5
  70. package/core/{manifest.d.ts → interfaces/manifest.d.ts} +3 -3
  71. package/core/{manifest.js → interfaces/manifest.js} +2 -2
  72. package/core/{plugin.d.ts → interfaces/plugin.d.ts} +44 -27
  73. package/core/{plugin.js → interfaces/plugin.js} +48 -10
  74. package/core/{publish-target.d.ts → interfaces/publish-target.d.ts} +24 -12
  75. package/core/{publish-target.js → interfaces/publish-target.js} +16 -3
  76. package/core/{run-step.d.ts → interfaces/run-step.d.ts} +4 -4
  77. package/core/plugin-loader.d.ts +11 -51
  78. package/core/plugin-loader.js +21 -202
  79. package/index.d.ts +29 -28
  80. package/index.js +20 -46
  81. package/interfaces/config-scope.interface.d.ts +223 -0
  82. package/interfaces/config-scope.interface.js +1 -0
  83. package/interfaces/rman-config.interface.d.ts +100 -5
  84. package/interfaces/rman-config.interface.js +17 -0
  85. package/package.json +3 -2
  86. package/services/change-hash.service.d.ts +40 -6
  87. package/services/change-hash.service.js +90 -11
  88. package/services/changelog.service.d.ts +31 -3
  89. package/services/changelog.service.js +448 -65
  90. package/services/config-service.d.ts +8 -0
  91. package/services/config-service.js +3 -0
  92. package/services/exec.service.d.ts +2 -2
  93. package/services/exec.service.js +1 -1
  94. package/services/github-release.service.d.ts +3 -3
  95. package/services/github-release.service.js +3 -3
  96. package/services/import.service.d.ts +2 -2
  97. package/services/import.service.js +1 -1
  98. package/services/list.service.d.ts +9 -7
  99. package/services/list.service.js +2 -2
  100. package/services/run.service.d.ts +15 -11
  101. package/services/run.service.js +62 -18
  102. package/services/system-info.d.ts +1 -1
  103. package/services/version-plan.service.d.ts +8 -4
  104. package/services/version-plan.service.js +54 -21
  105. package/services/version.service.d.ts +5 -5
  106. package/services/version.service.js +7 -4
  107. package/services.d.ts +1 -1
  108. package/services.js +1 -1
  109. package/utils/branch-guard.d.ts +1 -1
  110. package/utils/color-yaml.d.ts +8 -0
  111. package/utils/color-yaml.js +86 -0
  112. package/utils/git.d.ts +26 -0
  113. package/utils/git.js +65 -0
  114. package/utils/logger.d.ts +1 -1
  115. package/utils/package-filter.d.ts +8 -1
  116. package/utils/package-filter.js +7 -0
  117. package/utils/printable-config.d.ts +8 -0
  118. package/utils/printable-config.js +45 -0
  119. package/utils/progress-panel.d.ts +9 -0
  120. package/utils/progress-panel.js +23 -1
  121. package/utils/release-version.d.ts +1 -1
  122. package/utils/run-options.js +1 -1
  123. package/utils/version-group.d.ts +31 -0
  124. package/utils/version-group.js +43 -0
  125. package/commands/changed.command.d.ts +0 -80
  126. package/commands/changed.command.js +0 -36
  127. package/core/config.d.ts +0 -530
  128. package/core/config.js +0 -1123
  129. package/core/core-services.d.ts +0 -14
  130. package/core/core-services.js +0 -30
  131. package/core/core-targets.d.ts +0 -14
  132. package/core/core-targets.js +0 -16
  133. package/core/extends-config.d.ts +0 -35
  134. package/core/extends-config.js +0 -129
  135. package/core/load-config-module.d.ts +0 -28
  136. package/core/load-config-module.js +0 -42
  137. package/core/workspace.d.ts +0 -119
  138. package/core/workspace.js +0 -124
  139. package/plugins/builtins.d.ts +0 -44
  140. package/plugins/builtins.js +0 -33
  141. package/plugins/detect.d.ts +0 -78
  142. package/plugins/detect.js +0 -70
  143. package/plugins/node/index.d.ts +0 -29
  144. package/plugins/node/index.js +0 -40
  145. /package/{core/run-step.js → builtins/platforms/node/augmentation/rmanrc.augmentation.js} +0 -0
  146. /package/{plugins → builtins/platforms}/node/augmentation/system-info.augmentation.d.ts +0 -0
  147. /package/{plugins → builtins/platforms}/node/node-config.interface.js +0 -0
  148. /package/{plugins → builtins/platforms}/node/node.platform.js +0 -0
  149. /package/{plugins → builtins/platforms}/node/utils/workspace-range.d.ts +0 -0
  150. /package/{plugins → builtins/platforms}/node/utils/workspace-range.js +0 -0
  151. /package/{plugins/node/utils → builtins/publish-targets/npm}/npm-view.d.ts +0 -0
  152. /package/{plugins/node/utils → builtins/publish-targets/npm}/npm-view.js +0 -0
  153. /package/core/{registry.d.ts → classes/registry.d.ts} +0 -0
  154. /package/core/{registry.js → classes/registry.js} +0 -0
  155. /package/core/{service.js → classes/service.js} +0 -0
  156. /package/core/{version-scheme.d.ts → classes/version-scheme.d.ts} +0 -0
  157. /package/core/{version-scheme.js → classes/version-scheme.js} +0 -0
  158. /package/core/{merge-config.d.ts → config/merge-config.d.ts} +0 -0
  159. /package/core/{resolve-target.d.ts → config/resolve-config-target.d.ts} +0 -0
  160. /package/core/{resolve-target.js → config/resolve-config-target.js} +0 -0
  161. /package/core/{custom-command.js → interfaces/custom-command.js} +0 -0
  162. /package/{plugins/node/augmentation/rman.augmentation.js → core/interfaces/run-step.js} +0 -0
package/core/config.js DELETED
@@ -1,1123 +0,0 @@
1
- import { DOMParser } from '@xmldom/xmldom';
2
- import fs from 'fs';
3
- import ini from 'ini';
4
- import * as yaml from 'js-yaml';
5
- import path from 'path';
6
- import semver from 'semver';
7
- import vm from 'vm';
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
- /**
13
- * Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
14
- * autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
15
- * unchanged; this exists purely so TypeScript can infer/check against `RmanConfig`, not for any
16
- * runtime behavior:
17
- *
18
- * // .rmanrc.mjs
19
- * import { defineConfig } from 'rman';
20
- * export default defineConfig({ packageManager: 'pnpm' });
21
- *
22
- * // .rmanrc.cjs
23
- * const { defineConfig } = require('rman');
24
- * module.exports = defineConfig({ packageManager: 'pnpm' });
25
- */
26
- export function defineConfig(config) {
27
- return config;
28
- }
29
- /** `.rmanrc.cjs`/`.rmanrc.mjs`/`.rmanrc.js`, checked in this order - a JS module whose default
30
- * export (or, lacking one, the module's own exports object) is the config. Both CommonJS (`.cjs`,
31
- * or a `.js` under a `"type": "commonjs"` package.json) and native ESM (`.mjs`, or a `.js` under
32
- * `"type": "module"`) are supported - the reason `readDirConfig`/`resolveConfig` are async at all. */
33
- const JS_CONFIG_FILES = ['.rmanrc.cjs', '.rmanrc.mjs', '.rmanrc.js'];
34
- /**
35
- * Reads the rman configuration defined at a single directory level, merging
36
- * (in increasing precedence): `package.json#rman`, `.rmanrc.yml`, `.rmanrc`,
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`.
44
- */
45
- export async function readDirConfig(dirname, options) {
46
- const result = {};
47
- /** The file an `extends` in this directory resolves relative to. The last form that actually
48
- * declared one wins, which matters only for the unusual directory holding several. */
49
- let extendsFrom = path.join(dirname, '.rmanrc');
50
- const pkgJsonFile = path.join(dirname, 'package.json');
51
- if (fs.existsSync(pkgJsonFile)) {
52
- const pkgJson = JSON.parse(fs.readFileSync(pkgJsonFile, 'utf-8'));
53
- if (pkgJson && typeof pkgJson.rman === 'object') {
54
- assertSelectorBlocks(pkgJson.rman, pkgJsonFile);
55
- if (EXTENDS_KEY in pkgJson.rman)
56
- extendsFrom = pkgJsonFile;
57
- mergeConfig(result, pkgJson.rman, pkgJsonFile);
58
- }
59
- }
60
- const ymlFile = path.join(dirname, '.rmanrc.yml');
61
- if (fs.existsSync(ymlFile)) {
62
- const obj = yaml.load(fs.readFileSync(ymlFile, 'utf-8'));
63
- if (obj && typeof obj === 'object') {
64
- assertSelectorBlocks(obj, ymlFile);
65
- if (EXTENDS_KEY in obj)
66
- extendsFrom = ymlFile;
67
- mergeConfig(result, obj, ymlFile);
68
- }
69
- }
70
- const rcFile = path.join(dirname, '.rmanrc');
71
- if (fs.existsSync(rcFile)) {
72
- const obj = JSON.parse(fs.readFileSync(rcFile, 'utf-8'));
73
- if (obj && typeof obj === 'object') {
74
- assertSelectorBlocks(obj, rcFile);
75
- if (EXTENDS_KEY in obj)
76
- extendsFrom = rcFile;
77
- mergeConfig(result, obj, rcFile);
78
- }
79
- }
80
- for (const jsFileName of JS_CONFIG_FILES) {
81
- const jsFile = path.join(dirname, jsFileName);
82
- if (fs.existsSync(jsFile)) {
83
- const obj = await loadConfigModule(jsFile);
84
- if (obj && typeof obj === 'object') {
85
- assertSelectorBlocks(obj, jsFile);
86
- if (EXTENDS_KEY in obj)
87
- extendsFrom = jsFile;
88
- mergeConfig(result, obj, jsFile);
89
- }
90
- }
91
- }
92
- /** Resolved per directory, once its own forms have been combined: `extends` is the base every
93
- * one of them sits on, and the directory chain then layers on top as it always did. Each form
94
- * was checked for a misplaced `extends` as it was read, so that error can name the file holding
95
- * it rather than whichever form happened to declare the real one. */
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;
197
- }
198
- /**
199
- * Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
200
- * it (inclusive) - each directory level overrides the ones above it, the way tsconfig's `extends`
201
- * chain does.
202
- *
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.
240
- */
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) {
246
- const result = {};
247
- const target = path.resolve(targetDir);
248
- /** The root *package* is the one whose directory is the repository root - no other test is
249
- * needed, and none would be as reliable: a name can be anything. In a single-package repository
250
- * that is the only package, so `"[/]"` reaches it and `"[*]"` reaches nothing. */
251
- const isRoot = target === path.resolve(rootDir);
252
- for (const dir of dirChain(rootDir, targetDir)) {
253
- let local = cache.get(dir);
254
- if (!local) {
255
- local = await readDirConfig(dir, {
256
- inject: path.resolve(dir) === path.resolve(rootDir) ? inject : undefined,
257
- });
258
- cache.set(dir, local);
259
- }
260
- /**
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.
266
- *
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.
270
- */
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))
276
- mergeConfig(result, block);
277
- }
278
- }
279
- return result;
280
- }
281
- /** A config key naming packages rather than settings: `"[*]"`, `"[/]"`, `"[pkg-a]"`. The
282
- * brackets are what keep this space from colliding with real config keys - no setting starts with
283
- * one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
284
- * parses as a flow sequence. */
285
- export function isSelectorKey(key) {
286
- return key.length > 2 && key.startsWith('[') && key.endsWith(']');
287
- }
288
- /**
289
- * **Which packages a selector speaks for.** Two audiences, and the second is a glob:
290
- *
291
- * | | |
292
- * | --- | --- |
293
- * | `"[/]"` | the **root package** alone |
294
- * | `"[*]"`, `"[pkg-a]"`, `"[*-dialect]"` | the packages **below** this directory that the glob matches |
295
- *
296
- * `/` for the root because that is what a repository root is called everywhere else, and it cannot
297
- * collide with a package name.
298
- *
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.
309
- */
310
- export function parseSelector(key) {
311
- const inner = key.slice(1, -1);
312
- if (inner === ROOT_SELECTOR_INNER)
313
- return { scope: 'root', test: () => true };
314
- const re = globToRegExp(stripWorkspacePrefix(inner));
315
- return { scope: 'package', test: name => re.test(name) };
316
- }
317
- /** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
318
- * `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
319
- * pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
320
- export function selectorToRegExp(key) {
321
- return globToRegExp(stripWorkspacePrefix(key.slice(1, -1)));
322
- }
323
- /**
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.
335
- *
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.
338
- */
339
- function matchingSelectors(config, selector, isRoot) {
340
- const matches = [];
341
- for (const [key, value] of Object.entries(config)) {
342
- if (!isSelectorKey(key) || !value || typeof value !== 'object')
343
- continue;
344
- const { scope, test } = parseSelector(key);
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))
348
- continue;
349
- matches.push(value);
350
- }
351
- return matches;
352
- }
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;
360
- }
361
- function globToRegExp(glob) {
362
- const source = glob
363
- .split('*')
364
- .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
365
- .join('.*');
366
- return new RegExp(`^${source}$`);
367
- }
368
- function stripSelectors(config) {
369
- const result = {};
370
- for (const [key, value] of Object.entries(config))
371
- if (!isSelectorKey(key))
372
- result[key] = value;
373
- return result;
374
- }
375
- /** `"[/]"` - the root package, spelled the way a repository root is spelled everywhere else, and
376
- * unable to collide with a package name. */
377
- const ROOT_SELECTOR_INNER = '/';
378
- /** Accepted spellings of the retired "not the root" qualifier - see `stripWorkspacePrefix`. */
379
- const WORKSPACE_PREFIXES = ['workspace:', 'ws:'];
380
- function dirChain(rootDir, targetDir) {
381
- const rel = path.relative(rootDir, targetDir);
382
- if (!rel || rel === '.' || rel.startsWith('..'))
383
- return [rootDir];
384
- const dirs = [rootDir];
385
- let dir = rootDir;
386
- for (const segment of rel.split(path.sep)) {
387
- dir = path.join(dir, segment);
388
- dirs.push(dir);
389
- }
390
- return dirs;
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
- */
397
- export function interpolateConfig(config, scope, options) {
398
- const skip = options?.skip ?? [];
399
- /**
400
- * Where `config` sits in the whole config, when a caller hands over a fragment rather than the
401
- * root - `version` interpolates its own `version.<slot>` value on its own, those three paths being
402
- * in `DEFERRED_PATHS`.
403
- *
404
- * It matters because the path is what decides whether a function is a value to compute or a step
405
- * to leave alone (`STEP_PATHS`). Without it, a fragment starts at the root and matches nothing, so
406
- * a function in a `version` hook was called while the hook was being *prepared* - measured, and it
407
- * failed inside the user's own code with `path.join` receiving undefined.
408
- */
409
- const base = options?.at ?? [];
410
- /**
411
- * Built from `scope`'s property **descriptors**, never `{ ...scope }`.
412
- *
413
- * A spread reads every property, so a lazy getter on the scope is no longer lazy the moment one
414
- * is added - and `git` is exactly that: it shells out to `git rev-parse`, and a spread here would
415
- * do it on `rman list`, `rman info` and every other command, in a repository whose config never
416
- * mentions git. (The same trap `pkg.targetVersion` documents from the other side: it is a
417
- * *throwing* getter, and being enumerable is what made a spread fire it.)
418
- */
419
- const context = vm.createContext(Object.defineProperties({}, Object.getOwnPropertyDescriptors(scope)));
420
- if (!config || typeof config !== 'object' || Array.isArray(config))
421
- return walk(config, scope, context, base, skip);
422
- /**
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
425
- * copy that drifts when the first one changes.
426
- *
427
- * Resolved **on demand**, one key at a time, and memoized. Interpolating the config in tree order
428
- * and handing an expression whatever was ready would make the answer depend on key order in the
429
- * file, which is exactly the kind of quiet wrongness this evaluator exists to prevent: a key
430
- * declared above would read as resolved and one below as raw. On demand, each key is resolved
431
- * when first read and the order in the file means nothing.
432
- */
433
- const resolved = new Map();
434
- const resolving = [];
435
- /**
436
- * A cycle is **recorded here rather than thrown from the getter**, and that is not a style
437
- * choice: a host getter that throws inside a `vm` property interceptor has its exception
438
- * swallowed, and V8 then reports the global as absent - so a self-referencing key came out as
439
- * `publish is not defined`, which sends the reader looking for a missing key instead of a loop
440
- * (measured). The getter returns `undefined`, the resulting `ReferenceError` is caught below, and
441
- * this replaces it.
442
- */
443
- let cycle;
444
- const resolve = (key) => {
445
- if (resolved.has(key))
446
- return resolved.get(key);
447
- if (resolving.includes(key)) {
448
- cycle ??= new Error(`Config expression forms a cycle: ${[...resolving, key].join(' -> ')}\n` +
449
- ` A value cannot be derived from itself, directly or through another key.`);
450
- return undefined;
451
- }
452
- resolving.push(key);
453
- try {
454
- const chain = config[PREVIOUS_VALUES];
455
- const origins = config[ORIGINS];
456
- const value = withOrigin(origins?.[key], () => walkWithPrevious(config[key], chain?.[key], scope, context, [...base, key], skip));
457
- resolved.set(key, value);
458
- return value;
459
- }
460
- catch (e) {
461
- /** Not cleared: a cycle aborts the whole interpolation, and each level up would otherwise
462
- * re-swallow its own replacement the same way - leaving it set lets the outermost frame,
463
- * the one with a real stack to throw from, report it. */
464
- if (cycle)
465
- throw new Error(`${String(e?.message).split('\n')[0]}\n ${cycle.message}`, { cause: e });
466
- throw e;
467
- }
468
- finally {
469
- resolving.pop();
470
- }
471
- };
472
- for (const key of Object.keys(config)) {
473
- /** A scope binding wins: `pkg`/`repository`/`env`/`semver` are not config keys, so nothing
474
- * collides today, and a future key that did must not silently take over the namespace. */
475
- if (key in scope || !IDENTIFIER.test(key))
476
- continue;
477
- Object.defineProperty(context, key, { enumerable: true, configurable: true, get: () => resolve(key) });
478
- }
479
- /** Built through the same memo the getters use, so every key is walked exactly once whether an
480
- * expression asked for it first or the result did. */
481
- const result = {};
482
- for (const key of Object.keys(config))
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
- */
491
- return result;
492
- }
493
- /**
494
- * Config paths left untouched when a repository's config is first resolved, and evaluated only by
495
- * the command that runs them.
496
- *
497
- * `version`'s own hooks are the one place `${{ pkg.targetVersion }}` makes sense, and the version
498
- * being written is not known until `version` has computed its plan - long after the config was
499
- * resolved. Evaluating these eagerly would throw while merely *loading* the repository, so any
500
- * command at all would fail on a config that mentions it.
501
- */
502
- export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'];
503
- /**
504
- * Paths whose value is a **step** - something to run later - rather than a setting to compute now.
505
- * `*` matches one path segment (`run.<script>.exec`).
506
- *
507
- * This is what tells a step function from a value function, and the two live side by side in one
508
- * config:
509
- *
510
- * ```js
511
- * '[*]': {
512
- * clean: { include: ({ vars, value }) => [...value, vars.buildDir] }, // a value: called here
513
- * run: { build: { after: ({ pkg }) => copyDocs(pkg) } }, // a step: called by `run`
514
- * }
515
- * ```
516
- *
517
- * **The key decides, and it already did.** `run.build.exec: 'tsc -b'` is a shell command and
518
- * `publish.npm.directory: 'build'` is a path - not because of anything about the strings, but because of
519
- * where they sit. A function inherits the same rule, so nothing new has to be learned and no marker
520
- * has to be remembered. The alternative was inspecting the function (arity, parameter names), which
521
- * is the kind of guess `loadPlugins` refuses to make about a module's export for the same reason:
522
- * guessing wrong here means running build-time code while merely loading the repository, or
523
- * silently never running it.
524
- *
525
- * A **string** at one of these paths is still interpolated - `exec: 'tsc -b ${{ file.resolve(...) }}'`
526
- * has to keep working - so this is narrower than `DEFERRED_PATHS`, which skips its paths entirely.
527
- */
528
- export const STEP_PATHS = [
529
- /** The bare-value shorthand: `run: { build: fn }` means `{ exec: fn }`, as `run: { build: 'cmd' }`
530
- * means `{ exec: 'cmd' }`. Missing it made the two spellings disagree about *when* the function
531
- * runs, which is worse than not supporting the short one at all. */
532
- 'run.*',
533
- 'run.*.before',
534
- 'run.*.exec',
535
- 'run.*.after',
536
- /** A condition, evaluated per package by `RunService` when the run reaches it. Called here
537
- * instead, it collapsed to the boolean it happened to return at load time - and `parseIfExpr`
538
- * then read that boolean as "no condition given", so the script ran unconditionally (measured). */
539
- 'run.*.if',
540
- 'version.before',
541
- 'version.exec',
542
- 'version.after',
543
- ];
544
- /**
545
- * Keys whose **whole subtree** is code rather than config, so no function under them is a value to
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.
561
- */
562
- export const CODE_SUBTREES = ['plugins', 'commands', 'publishTargets'];
563
- const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
564
- /** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
565
- * `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
566
- const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
567
- /** The `file` namespace for one package's directory - see `FileScope`. */
568
- /**
569
- * `read` in a `${{ ... }}` expression (and in a value function): a structured file's **contents**,
570
- * parsed - where `file` answers only where a path is.
571
- *
572
- * ```yaml
573
- * "[*]":
574
- * run:
575
- * build:
576
- * exec: 'tsc --outDir ${{ read("tsconfig.json").compilerOptions.outDir }}'
577
- * ```
578
- *
579
- * `cache` is shared across every package (see `Repository.configScope`) and keyed by what the file
580
- * *is*, not merely where - so the same file read by twenty packages is parsed once, and a file rman
581
- * itself rewrites mid-run is re-read rather than remembered. See `readStructuredFile`.
582
- */
583
- export function createReadScope(dirname, cache) {
584
- return (target, format) => {
585
- if (typeof target !== 'string' || !target.trim()) {
586
- throw new Error('read() needs a path - it was given ' + JSON.stringify(target));
587
- }
588
- return readStructuredFile(path.resolve(dirname, target), format, cache);
589
- };
590
- }
591
- export function createFileScope(dirname) {
592
- const locate = (target) => {
593
- if (typeof target !== 'string' || !target.trim()) {
594
- throw new Error('file.exists()/file.resolve() need a path - they were given ' + JSON.stringify(target));
595
- }
596
- const resolved = path.resolve(dirname, target);
597
- return { path: resolved, found: fs.existsSync(resolved) };
598
- };
599
- return {
600
- exists(target) {
601
- const { path: resolved, found } = locate(target);
602
- return found ? resolved : '';
603
- },
604
- resolve(target) {
605
- const { path: resolved, found } = locate(target);
606
- if (!found) {
607
- throw new Error(`file.resolve("${target}") found nothing at ${resolved}\n` +
608
- ` Use file.exists() instead if its absence is a case to handle rather than a mistake.`);
609
- }
610
- return resolved;
611
- },
612
- resolveFirst(...targets) {
613
- if (!targets.length)
614
- throw new Error('file.resolveFirst() needs at least one path');
615
- for (const target of targets) {
616
- const { path: resolved, found } = locate(target);
617
- if (found)
618
- return resolved;
619
- }
620
- throw new Error(`file.resolveFirst() found none of: ${targets.map(t => `"${t}"`).join(', ')}\n` + ` Looked in ${dirname}.`);
621
- },
622
- };
623
- }
624
- function walk(value, scope, context, at, skip) {
625
- /** Compared on the key path rather than the value, so a deferred key's whole subtree - a single
626
- * command or an array of them - is handed on untouched. */
627
- if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
628
- return value;
629
- if (typeof value === 'function') {
630
- /** Code, not a value: a step for `run`/`version` to call in its own time, or a plugin's own
631
- * function. Carried through exactly as a command string would be - calling it here would run
632
- * build-time work while merely *loading* the repository, which is the whole distinction the
633
- * function form exists to draw. */
634
- if (isCodePath(at))
635
- return value;
636
- return callValueFn(value, context, at);
637
- }
638
- if (typeof value === 'string')
639
- return interpolateString(value, context, at);
640
- if (Array.isArray(value))
641
- return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
642
- if (value && typeof value === 'object') {
643
- const chain = value[PREVIOUS_VALUES];
644
- return withScopedVars(value, scope, context, at, skip, () => {
645
- const result = {};
646
- const origins = value[ORIGINS];
647
- for (const [key, item] of Object.entries(value)) {
648
- result[key] = withOrigin(origins?.[key], () => walkWithPrevious(item, chain?.[key], scope, context, [...at, key], skip));
649
- }
650
- return result;
651
- });
652
- }
653
- return value;
654
- }
655
- /**
656
- * Runs `body` with `vars` scoped to this node: **a fresh copy at every level**, with the node's own
657
- * `vars` block - if it declares one - merged over what the level above resolved to.
658
- *
659
- * ```yaml
660
- * vars: { x: 1 }
661
- * run:
662
- * vars: { x: 2 }
663
- * clean: { before: '${{ read(vars.x + ".json") }}' } # 2.json
664
- * build:
665
- * vars: { x: 3 }
666
- * before: '${{ read(vars.x + ".json") }}' # 3.json
667
- * ```
668
- *
669
- * **Copied at every node, not only where a `vars` block appears**, and that is the difference
670
- * between scoping and leaking: a value function is handed this object, so one that writes to it
671
- * (`vars.built = Date.now()`) must not be writing into the level above. Without a copy per node,
672
- * a write inside `run.build` would land in `run`'s object and `run.clean` would see it. Merged per
673
- * key rather than replaced, so redeclaring one var keeps the rest - the rule the top-level `vars`
674
- * has always followed.
675
- *
676
- * The node's own block is resolved **against the outer scope** before being installed, so
677
- * `vars: { out: '${{ vars.x }}/dist' }` reads the `x` it is refining rather than itself.
678
- *
679
- * Installed as a plain property over the context's lazy top-level getter and restored afterwards -
680
- * `walk` is depth-first and synchronous, so the window is exactly this subtree, and a value function
681
- * called inside it reads the same object through its prototype.
682
- */
683
- function withScopedVars(node, scope, context, at, skip, body) {
684
- /**
685
- * **A `vars` block does not scope itself.** Resolving one walks its own values, and without this
686
- * that walk asks for the scope it is in the middle of producing - which the cycle guard catches
687
- * and reports as `vars -> vars`. It recovered (the guard returns `undefined`, so the block simply
688
- * saw no outer scope, which is what it should see anyway), but it left the cycle *flag* set, and
689
- * the next genuine error in that key came out wearing `Config expression forms a cycle` - found by
690
- * running a real shared config, whose `[...value]` mistake arrived with a loop attached that had
691
- * nothing to do with it.
692
- *
693
- * Any path with a `vars` segment is inside a block: its contents are values, not config nodes.
694
- */
695
- if (at.some(segment => segment === VARS_KEY))
696
- return body();
697
- const outer = context[VARS_KEY];
698
- const own = node[VARS_KEY];
699
- /** Nothing to shadow and nothing to protect: a node with no object below it can hold no function
700
- * either, so the copy would be pure cost. */
701
- if (own === undefined && !hasObjectChild(node))
702
- return body();
703
- const resolvedOwn = own === undefined ? undefined : walk(own, scope, context, [...at, VARS_KEY], skip);
704
- const scoped = { ...outer, ...(isPlainObject(resolvedOwn) ? resolvedOwn : undefined) };
705
- const previous = Object.getOwnPropertyDescriptor(context, VARS_KEY);
706
- Object.defineProperty(context, VARS_KEY, { value: scoped, enumerable: true, configurable: true, writable: true });
707
- try {
708
- return body();
709
- }
710
- finally {
711
- if (previous)
712
- Object.defineProperty(context, VARS_KEY, previous);
713
- else
714
- delete context[VARS_KEY];
715
- }
716
- }
717
- function hasObjectChild(node) {
718
- for (const item of Object.values(node)) {
719
- if (typeof item === 'function')
720
- return true;
721
- if (item && typeof item === 'object')
722
- return true;
723
- }
724
- return false;
725
- }
726
- function isPlainObject(value) {
727
- return !!value && typeof value === 'object' && !Array.isArray(value);
728
- }
729
- /** The one key that scopes rather than configures - see `withScopedVars`. Reserved at **every**
730
- * level, which costs a script that would have been called `vars`: `run.vars` is a scope, not a
731
- * script. Nothing enumerates `run`'s keys as a list of script names, so the cost stops there. */
732
- const VARS_KEY = 'vars';
733
- /**
734
- * The file the key being walked was written in, for the errors below to name.
735
- *
736
- * A config is merged from several files before anything reads it - a directory's own forms, an
737
- * `extends` base, every `"[selector]"` block, one layer per directory - so `version.commitMessage`
738
- * alone does not say where to go and look. `mergeConfig` records the file per key (`ORIGINS`); this
739
- * is the depth-first cursor over that, kept in a module variable rather than threaded through
740
- * `walk`'s signature because every error site would otherwise have to carry a parameter it only
741
- * passes on.
742
- *
743
- * Nested keys inherit the enclosing file when the merge recorded none of their own, which is what a
744
- * nested object in one file means.
745
- */
746
- let currentOrigin;
747
- function withOrigin(origin, body) {
748
- const outer = currentOrigin;
749
- if (origin !== undefined)
750
- currentOrigin = origin;
751
- try {
752
- return body();
753
- }
754
- finally {
755
- currentOrigin = outer;
756
- }
757
- }
758
- /** `"version.commitMessage" (.rmanrc.yml)`, or just the path when nothing recorded a file - a
759
- * caller interpolating a fragment it built itself, say. Relative to the repository when it sits
760
- * inside one, since an absolute path is noise in a message about the repository you are in. */
761
- function describeAt(at) {
762
- const where = at.length ? formatPath(at) : 'the config root';
763
- return currentOrigin ? `${where}" (${shortenOrigin(currentOrigin)})` : `${where}"`;
764
- }
765
- function shortenOrigin(file) {
766
- const relative = path.relative(process.cwd(), file);
767
- return !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
768
- }
769
- /**
770
- * Walks one key of an object with **`value` bound** to whatever the layers below it resolved to.
771
- *
772
- * Bound on the interpolation context rather than passed as an argument, because an expression reads
773
- * it as a global (`"${{ [...value, 'x'] }}"`) - a function then picks the same binding up through
774
- * its prototype, so the two spellings cannot disagree about what `value` is. It was function-only
775
- * at first, on the reasoning that a string cannot carry an array back; that was wrong, since a
776
- * string which is *nothing but* one expression keeps the value's own type.
777
- *
778
- * **Always bound, even with nothing underneath.** Left unbound, an expression naming it fails with
779
- * V8's `value is not defined`, which reads as "there is no such thing" rather than "nothing below
780
- * this layer set it" - two different mistakes needing two different fixes. With nothing underneath
781
- * it is `unsetValue()` rather than `undefined` - see there.
782
- *
783
- * The chain resolves bottom-up, so a layer deriving from a layer that itself derived from something
784
- * is handed the finished value rather than a half-resolved expression.
785
- */
786
- function walkWithPrevious(item, previous, scope, context, at, skip) {
787
- const resolved = previousValue(previous === undefined ? undefined : walkWithPrevious(previous.value, previous.previous, scope, context, at, skip), at);
788
- const outer = Object.getOwnPropertyDescriptor(context, VALUE_KEY);
789
- /** A getter, so the catch below can tell whether the value **actually read `value`**: the hint is
790
- * irrelevant to any other failure, and attaching it anyway is the send-the-reader-to-the-wrong-
791
- * place mistake it exists to prevent. Recorded, never matched on V8's wording. */
792
- let wasRead = false;
793
- Object.defineProperty(context, VALUE_KEY, {
794
- configurable: true,
795
- enumerable: true,
796
- get: () => {
797
- wasRead = true;
798
- return resolved;
799
- },
800
- });
801
- try {
802
- return walk(item, scope, context, at, skip);
803
- }
804
- catch (e) {
805
- /**
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.
809
- *
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.
814
- */
815
- if (wasRead && isUnsetValue(resolved) && !e?.rmanValueHint) {
816
- e.rmanValueHint = true;
817
- e.message = `${e.message}\n Note: nothing below this layer sets "${describeAt(at)}, so \`value\` is empty.`;
818
- }
819
- throw e;
820
- }
821
- finally {
822
- if (outer)
823
- Object.defineProperty(context, VALUE_KEY, outer);
824
- else
825
- delete context[VALUE_KEY];
826
- }
827
- }
828
- /** What a layer deriving from the one below it reads - see `walkWithPrevious`. */
829
- const VALUE_KEY = 'value';
830
- /**
831
- * Whether a function at `at` is **code** - a step to run later, or part of a plugin - rather than a
832
- * value to compute now.
833
- *
834
- * Array indices are dropped before matching, so a function inside a *list* of steps is still a
835
- * step; `*` in a `STEP_PATHS` entry matches any one segment (`run.<script>.exec`).
836
- */
837
- function isCodePath(at) {
838
- const segments = at.filter((p) => typeof p === 'string');
839
- if (CODE_SUBTREES.includes(segments[0]))
840
- return true;
841
- return STEP_PATHS.some(pattern => {
842
- const parts = pattern.split('.');
843
- return parts.length === segments.length && parts.every((part, i) => part === '*' || part === segments[i]);
844
- });
845
- }
846
- /**
847
- * Calls a **value** function: the JS spelling of a `${{ }}` expression, and it answers the same
848
- * question at the same moment.
849
- *
850
- * It receives one object carrying everything an expression can name - `pkg`, `repository`, `file`,
851
- * `env`, `semver`, `path`, plus the config's own top-level keys - and, in addition, **`value`**: what
852
- * this key resolved to in the layers underneath, which is what makes a derived value possible
853
- * without restating the base.
854
- *
855
- * Built with the interpolation context as its **prototype**, not copied from it. The top-level keys
856
- * are lazy getters (`resolve`, memoized, so key order in the file means nothing and a cycle is
857
- * reported rather than half-resolved); spreading them into a new object would fire every one of
858
- * them on every call, including the ones a function never reads - and one of those throwing would
859
- * blame the wrong key.
860
- *
861
- * **It must compute and return, never act.** This runs while the repository's config resolves,
862
- * which *every* command does - so a value function that writes a file writes it on `rman list`,
863
- * `rman info` and `rman config` too, N times for N packages, with no command having asked for
864
- * anything. That is the same reason `FileScope` offers no way to change anything. Work goes in a
865
- * step, which is the one thing rman runs on purpose and which can also be a function.
866
- */
867
- function callValueFn(fn, context, at) {
868
- /** `value` arrives through the prototype, bound by `walkWithPrevious` for exactly this key - so
869
- * nothing here may *read* it. Passing it in as an argument did, which tripped the "was it read"
870
- * getter before the function ran and put the `value` hint on every unrelated failure (caught by
871
- * the spec that exists for precisely that). */
872
- const arg = Object.create(context);
873
- /**
874
- * A getter only so the catch below can tell whether the function **actually read `value`**.
875
- *
876
- * Without that, the "value is undefined" hint went out with *every* failure of a first-layer
877
- * function - a frozen-object `TypeError` from `read()` arrived wearing advice about spreading an
878
- * inherited list, which is precisely the send-the-reader-to-the-wrong-place mistake the hint
879
- * exists to prevent. Recorded rather than inferred from the message, because matching on V8's
880
- * wording is the other way to get this wrong.
881
- */
882
- try {
883
- return fn(arg);
884
- }
885
- catch (e) {
886
- /** The `value` hint comes from `walkWithPrevious`, which wraps this call and is the one place
887
- * that knows whether `value` was read - so the expression spelling gets the same sentence. */
888
- throw new Error(`Config function in "${describeAt(at)} failed: ${e?.message}`, { cause: e });
889
- }
890
- }
891
- function interpolateString(value, context, at) {
892
- if (!value.includes('${{'))
893
- return value;
894
- const found = [...value.matchAll(EXPRESSION)];
895
- if (!found.length)
896
- return value;
897
- /** Counted rather than matched with an anchored `^...$` regex: a lazy quantifier still backtracks
898
- * to satisfy an end anchor, so `"${{ a }} and ${{ b }}"` looked like *one* expression whose body
899
- * ran from `a` to `b`, brace-ends and all - invalid JavaScript. */
900
- const soleExpression = found.length === 1 && found[0][0] === value.trim();
901
- // Alone, a nullish result is just "this setting is unset" - a legitimate answer.
902
- if (soleExpression)
903
- return evaluate(found[0][1], value, context, at);
904
- return value.replace(EXPRESSION, (_, expr) => {
905
- const result = evaluate(expr, value, context, at);
906
- /** Embedded in text, though, it never is: splicing in the word "undefined" produces a path or
907
- * tag like `app:undefined` that looks plausible and is wrong - the exact silent-mistake shape
908
- * this evaluator exists to avoid. `?? 'fallback'` says what was meant. */
909
- if (result === undefined || result === null) {
910
- throw new Error(`Expression in "${describeAt(at)} is ${result} inside a string: ${value.trim()}\n` +
911
- ` \${{${expr}}} has no value here - give it a fallback (\${{${expr.trim()} ?? '...'}}).`);
912
- }
913
- return String(result);
914
- });
915
- }
916
- /** Names the config path as well as the expression: an error saying only "x is not defined" sends
917
- * the reader hunting through a file that may hold dozens of them. */
918
- function evaluate(expr, source, context, at) {
919
- try {
920
- return vm.runInContext(expr, context, { timeout: EXPRESSION_TIMEOUT });
921
- }
922
- catch (e) {
923
- throw new Error(`Invalid expression in "${describeAt(at)}: ${source.trim()}\n ${e?.message ?? e}`, { cause: e });
924
- }
925
- }
926
- function formatPath(at) {
927
- return at.reduce((acc, part) => (typeof part === 'number' ? `${acc}[${part}]` : acc ? `${acc}.${part}` : String(part)), '');
928
- }
929
- /** Guards against an expression that never returns (`while(true)`) taking the whole command with
930
- * it - a typo, not an attack, but the failure mode is identical. */
931
- const EXPRESSION_TIMEOUT = 1000;
932
- /**
933
- * Reads and parses one structured file, memoized against **the identity of its contents** rather
934
- * than its path alone: the cache key is `mtimeNs:size`.
935
- *
936
- * Both halves of that were chosen against a measurement.
937
- *
938
- * - **A stat rather than a re-read**: `statSync` is 1.3µs where `readFileSync` + `JSON.parse` is
939
- * 16.1µs on a 2KB manifest - so the check costs a thirteenth of what it saves, and the same file
940
- * read by twenty packages is parsed once. (`interpolateConfig` runs once *per package*, so a
941
- * cache living in one pass would not have helped across them at all.)
942
- * - **Keyed on the stat rather than held for the run**: rman writes JSON files while it is running
943
- * - `version` rewrites every bumped manifest, then re-interpolates its own deferred hooks. A
944
- * cache that only remembered the path would hand those back as they were before the write.
945
- * `mtimeNs` is nanoseconds, so a rewrite within the same millisecond does not slip through; the
946
- * size is in the key as well because it costs nothing.
947
- *
948
- * **Frozen, deeply, once on the way into the cache.** Every package is handed the same object, so
949
- * one config mutating it would quietly change what the next package sees - the reason `pkg.manifest`
950
- * has always been a copy. Freezing is better than copying here: a copy costs 5.6µs on *every* call,
951
- * freezing costs ~1µs *once*, and it turns the mistake into a `TypeError` instead of an effect at a
952
- * distance. A caller that wants to change something spreads it first.
953
- */
954
- function readStructuredFile(file, format, cache) {
955
- let stat;
956
- try {
957
- stat = fs.statSync(file, { bigint: true });
958
- }
959
- catch {
960
- throw new Error(`read("${path.basename(file)}") found nothing at ${file}\n` +
961
- ` Use file.exists() first if its absence is a case to handle rather than a mistake.`);
962
- }
963
- if (stat.isDirectory())
964
- throw new Error(`read() was given a directory, not a file: ${file}`);
965
- const stamp = `${stat.mtimeNs}:${stat.size}`;
966
- const cached = cache.get(file);
967
- if (cached?.stamp === stamp)
968
- return cached.value;
969
- const resolved = format ?? formatOf(file);
970
- const text = fs.readFileSync(file, 'utf-8');
971
- let value;
972
- try {
973
- value = parseStructured(text, resolved);
974
- }
975
- catch (e) {
976
- /** The parser's own message says what is wrong with the syntax but never which file it was
977
- * reading - and an expression can name several. */
978
- throw new Error(`read("${path.basename(file)}") could not parse ${file} as ${resolved}: ${e?.message}`, {
979
- cause: e,
980
- });
981
- }
982
- deepFreeze(value);
983
- cache.set(file, { stamp, value });
984
- return value;
985
- }
986
- /** The extension decides, because the caller already wrote it - naming the parser as well would
987
- * restate it and let the two disagree (`json("x.yml")`). A name that says nothing takes the
988
- * explicit argument instead. */
989
- function formatOf(file) {
990
- const ext = path.extname(file).toLowerCase();
991
- if (ext === '.json')
992
- return 'json';
993
- if (ext === '.yml' || ext === '.yaml')
994
- return 'yaml';
995
- if (ext === '.ini')
996
- return 'ini';
997
- if (XML_EXTENSIONS.has(ext))
998
- return 'xml';
999
- throw new Error(`read() cannot tell what "${path.basename(file)}" is from its name.\n` +
1000
- ` Name the format: read("${path.basename(file)}", "json" | "yaml" | "ini" | "xml").`);
1001
- }
1002
- /** The XML family worth recognizing by name: a project file is XML whatever its extension calls
1003
- * itself, and `.csproj`/`.pom` are what a .NET or Maven repository actually holds. Anything else
1004
- * still reads with an explicit `read(p, 'xml')`. */
1005
- const XML_EXTENSIONS = new Set(['.xml', '.csproj', '.vbproj', '.fsproj', '.props', '.targets', '.nuspec', '.plist']);
1006
- function parseStructured(text, format) {
1007
- if (format === 'json')
1008
- return JSON.parse(text);
1009
- /** `load`, not `loadAll`: a multi-document stream has no single value to be, and js-yaml says so
1010
- * clearly enough ("expected a single document in the stream") to leave alone. */
1011
- if (format === 'yaml')
1012
- return yaml.load(text);
1013
- if (format === 'xml')
1014
- return parseXml(text);
1015
- return ini.parse(text);
1016
- }
1017
- /**
1018
- * A **DOM**, not an object - and the asymmetry with the other three formats is the honest shape
1019
- * rather than an omission.
1020
- *
1021
- * XML has no lossless object form: an element can repeat, carry attributes and hold text at the
1022
- * same time, so any flattening has to pick a convention (`$`? `_text`? array-or-not?) and be wrong
1023
- * for somebody. A DOM is the shape XML actually has, so a config reads it the way every other XML
1024
- * tool does:
1025
- *
1026
- * ```yaml
1027
- * version: '${{ read("pom.xml").getElementsByTagName("version")[0].textContent }}'
1028
- * ```
1029
- *
1030
- * **Freezing it is safe** - measured, not assumed: a frozen `@xmldom/xmldom` document still answers
1031
- * `getElementsByTagName` for a tag first asked about *after* the freeze (the live-collection case
1032
- * that would have broken it), reads attributes, resolves namespaces, walks `childNodes` and
1033
- * serialises back.
1034
- */
1035
- function parseXml(text) {
1036
- /** xmldom reports a malformed document through a handler and otherwise carries on with whatever
1037
- * it could salvage - so without this, a broken file would come back as a half-parsed DOM and the
1038
- * expression reading it would simply find nothing. `read()` throws for a broken JSON file; it has
1039
- * to throw for this one too. */
1040
- const problems = [];
1041
- const doc = new DOMParser({
1042
- onError: (level, message) => {
1043
- if (level !== 'warning')
1044
- problems.push(message.split('\n')[0]);
1045
- },
1046
- }).parseFromString(text, 'text/xml');
1047
- if (problems.length)
1048
- throw new Error(problems[0]);
1049
- return doc;
1050
- }
1051
- function deepFreeze(value) {
1052
- if (!value || typeof value !== 'object' || Object.isFrozen(value))
1053
- return;
1054
- Object.freeze(value);
1055
- for (const item of Object.values(value))
1056
- deepFreeze(item);
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');