rman 1.3.0 → 2.0.0-beta.3

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 (187) hide show
  1. package/README.md +65 -31
  2. package/builtins/platforms/node/augmentation/rmanrc.augmentation.d.ts +83 -0
  3. package/builtins/platforms/node/augmentation/system-info.augmentation.d.ts +26 -0
  4. package/builtins/platforms/node/augmentation/system-info.augmentation.js +79 -0
  5. package/builtins/platforms/node/commands/ci.command.d.ts +133 -0
  6. package/builtins/platforms/node/commands/ci.command.js +60 -0
  7. package/builtins/platforms/node/commands/clean.command.d.ts +185 -0
  8. package/builtins/platforms/node/commands/clean.command.js +74 -0
  9. package/builtins/platforms/node/index.d.ts +8 -0
  10. package/builtins/platforms/node/index.js +8 -0
  11. package/builtins/platforms/node/node-config.interface.d.ts +76 -0
  12. package/builtins/platforms/node/node-config.interface.js +7 -0
  13. package/builtins/platforms/node/node-manifest.provider.d.ts +68 -0
  14. package/builtins/platforms/node/node-manifest.provider.js +125 -0
  15. package/builtins/platforms/node/node.platform.d.ts +53 -0
  16. package/builtins/platforms/node/node.platform.js +134 -0
  17. package/builtins/platforms/node/services/ci.service.d.ts +47 -0
  18. package/builtins/platforms/node/services/ci.service.js +213 -0
  19. package/builtins/platforms/node/services/clean.service.d.ts +53 -0
  20. package/builtins/platforms/node/services/clean.service.js +237 -0
  21. package/builtins/platforms/node/services/publish.service.d.ts +114 -0
  22. package/builtins/platforms/node/services/publish.service.js +371 -0
  23. package/builtins/platforms/node/services/version-plan.service.d.ts +44 -0
  24. package/builtins/platforms/node/services/version-plan.service.js +58 -0
  25. package/builtins/platforms/node/utils/workspace-range.d.ts +26 -0
  26. package/builtins/platforms/node/utils/workspace-range.js +28 -0
  27. package/builtins/presets/index.d.ts +15 -0
  28. package/builtins/presets/index.js +57 -0
  29. package/builtins/presets/node.d.ts +16 -0
  30. package/builtins/presets/node.js +57 -0
  31. package/{services → builtins/publish-targets/docker}/docker-publish.service.d.ts +52 -31
  32. package/{services → builtins/publish-targets/docker}/docker-publish.service.js +49 -26
  33. package/builtins/publish-targets/docker/docker.target.d.ts +54 -0
  34. package/builtins/publish-targets/docker/docker.target.js +40 -0
  35. package/builtins/publish-targets/docker/index.d.ts +2 -0
  36. package/builtins/publish-targets/docker/index.js +2 -0
  37. package/builtins/publish-targets/npm/index.d.ts +2 -0
  38. package/builtins/publish-targets/npm/index.js +2 -0
  39. package/builtins/publish-targets/npm/npm-publish-target.d.ts +73 -0
  40. package/builtins/publish-targets/npm/npm-publish-target.js +95 -0
  41. package/builtins/publish-targets/npm/npm-view.d.ts +48 -0
  42. package/builtins/publish-targets/npm/npm-view.js +71 -0
  43. package/cli.d.ts +8 -0
  44. package/cli.js +228 -93
  45. package/commands/build.command.d.ts +177 -3
  46. package/commands/build.command.js +20 -10
  47. package/commands/changelog.command.d.ts +296 -3
  48. package/commands/changelog.command.js +127 -43
  49. package/commands/config.command.d.ts +44 -3
  50. package/commands/config.command.js +53 -24
  51. package/commands/diff.command.d.ts +37 -3
  52. package/commands/diff.command.js +25 -16
  53. package/commands/exec.command.d.ts +193 -3
  54. package/commands/exec.command.js +60 -58
  55. package/commands/github-release.command.d.ts +154 -3
  56. package/commands/github-release.command.js +67 -37
  57. package/commands/import.command.d.ts +36 -3
  58. package/commands/import.command.js +28 -20
  59. package/commands/info.command.d.ts +35 -7
  60. package/commands/info.command.js +36 -30
  61. package/commands/lint.command.d.ts +176 -0
  62. package/commands/lint.command.js +34 -0
  63. package/commands/list.command.d.ts +163 -3
  64. package/commands/list.command.js +109 -71
  65. package/commands/publish.command.d.ts +231 -0
  66. package/commands/publish.command.js +304 -0
  67. package/commands/run.command.d.ts +186 -6
  68. package/commands/run.command.js +26 -72
  69. package/commands/test.command.d.ts +173 -3
  70. package/commands/test.command.js +16 -10
  71. package/commands/version.command.d.ts +330 -3
  72. package/commands/version.command.js +292 -78
  73. package/commands.d.ts +32 -0
  74. package/commands.js +28 -0
  75. package/constants.js +1 -1
  76. package/core/application.d.ts +116 -0
  77. package/core/application.js +160 -0
  78. package/core/classes/package.d.ts +219 -0
  79. package/core/classes/package.js +214 -0
  80. package/core/classes/registry.d.ts +30 -0
  81. package/core/classes/registry.js +47 -0
  82. package/core/{repository.d.ts → classes/repository.d.ts} +69 -20
  83. package/core/{repository.js → classes/repository.js} +195 -75
  84. package/core/classes/service.d.ts +49 -0
  85. package/core/classes/service.js +40 -0
  86. package/core/{version-scheme.d.ts → classes/version-scheme.d.ts} +23 -1
  87. package/core/{version-scheme.js → classes/version-scheme.js} +29 -1
  88. package/core/classes/workspace.d.ts +275 -0
  89. package/core/classes/workspace.js +537 -0
  90. package/core/command-builder.d.ts +14 -0
  91. package/core/command-builder.js +94 -0
  92. package/core/config/config-file-scope.d.ts +107 -0
  93. package/core/config/config-file-scope.js +226 -0
  94. package/core/config/config-interpolator.d.ts +164 -0
  95. package/core/config/config-interpolator.js +577 -0
  96. package/core/config/config-paths.d.ts +65 -0
  97. package/core/config/config-paths.js +78 -0
  98. package/core/config/config-reader.d.ts +192 -0
  99. package/core/config/config-reader.js +727 -0
  100. package/core/{merge-config.d.ts → config/merge-config.d.ts} +33 -34
  101. package/core/config/merge-config.js +260 -0
  102. package/core/{resolve-target.d.ts → config/resolve-config-target.d.ts} +1 -1
  103. package/core/{resolve-target.js → config/resolve-config-target.js} +1 -1
  104. package/core/{custom-command.d.ts → interfaces/custom-command.d.ts} +46 -10
  105. package/core/{custom-command.js → interfaces/custom-command.js} +44 -17
  106. package/core/{manifest.d.ts → interfaces/manifest.d.ts} +49 -27
  107. package/core/interfaces/manifest.js +132 -0
  108. package/core/interfaces/plugin.d.ts +174 -0
  109. package/core/interfaces/plugin.js +124 -0
  110. package/core/interfaces/publish-target.d.ts +136 -0
  111. package/core/interfaces/publish-target.js +43 -0
  112. package/core/{run-step.d.ts → interfaces/run-step.d.ts} +4 -4
  113. package/core/interfaces/run-step.js +1 -0
  114. package/core/plugin-loader.d.ts +25 -0
  115. package/core/plugin-loader.js +53 -0
  116. package/index.d.ts +122 -24
  117. package/index.js +66 -16
  118. package/interfaces/config-scope.interface.d.ts +223 -0
  119. package/interfaces/config-scope.interface.js +1 -0
  120. package/interfaces/rman-config.interface.d.ts +821 -203
  121. package/interfaces/rman-config.interface.js +78 -1
  122. package/package.json +3 -1
  123. package/services/change-hash.service.d.ts +42 -8
  124. package/services/change-hash.service.js +92 -13
  125. package/services/changelog.service.d.ts +91 -52
  126. package/services/changelog.service.js +460 -74
  127. package/services/config-service.d.ts +8 -0
  128. package/services/config-service.js +3 -0
  129. package/services/exec.service.d.ts +23 -12
  130. package/services/exec.service.js +14 -9
  131. package/services/github-release.service.d.ts +45 -34
  132. package/services/github-release.service.js +15 -12
  133. package/services/import.service.d.ts +25 -14
  134. package/services/import.service.js +9 -5
  135. package/services/list.service.d.ts +67 -13
  136. package/services/list.service.js +62 -15
  137. package/services/run.service.d.ts +35 -22
  138. package/services/run.service.js +316 -233
  139. package/services/system-info.d.ts +1 -1
  140. package/services/version-plan.service.d.ts +35 -8
  141. package/services/version-plan.service.js +96 -36
  142. package/services/version.service.d.ts +34 -14
  143. package/services/version.service.js +35 -16
  144. package/services.d.ts +1 -1
  145. package/services.js +1 -1
  146. package/utils/bin-path.d.ts +6 -7
  147. package/utils/bin-path.js +7 -18
  148. package/utils/branch-guard.d.ts +30 -1
  149. package/utils/branch-guard.js +31 -0
  150. package/utils/color-yaml.d.ts +8 -0
  151. package/utils/color-yaml.js +86 -0
  152. package/utils/exec.d.ts +10 -0
  153. package/utils/exec.js +1 -1
  154. package/utils/git.d.ts +26 -0
  155. package/utils/git.js +65 -0
  156. package/utils/logger.d.ts +2 -2
  157. package/utils/logger.js +1 -1
  158. package/utils/package-filter.d.ts +135 -8
  159. package/utils/package-filter.js +204 -16
  160. package/utils/printable-config.d.ts +9 -1
  161. package/utils/printable-config.js +46 -1
  162. package/utils/progress-panel.d.ts +9 -0
  163. package/utils/progress-panel.js +23 -1
  164. package/utils/release-version.d.ts +1 -1
  165. package/utils/run-bin.d.ts +10 -0
  166. package/utils/run-bin.js +1 -1
  167. package/utils/run-options.d.ts +97 -0
  168. package/utils/run-options.js +81 -0
  169. package/utils/version-group.d.ts +31 -0
  170. package/utils/version-group.js +43 -0
  171. package/utils/version-stamp.d.ts +1 -1
  172. package/utils/version-stamp.js +1 -1
  173. package/commands/changed.command.d.ts +0 -3
  174. package/commands/changed.command.js +0 -29
  175. package/core/config.d.ts +0 -409
  176. package/core/config.js +0 -939
  177. package/core/extends-config.d.ts +0 -27
  178. package/core/extends-config.js +0 -89
  179. package/core/manifest.js +0 -150
  180. package/core/merge-config.js +0 -219
  181. package/core/package.d.ts +0 -83
  182. package/core/package.js +0 -106
  183. package/core/plugin.d.ts +0 -112
  184. package/core/plugin.js +0 -189
  185. package/core/workspace.d.ts +0 -68
  186. package/core/workspace.js +0 -83
  187. /package/{core/run-step.js → builtins/platforms/node/augmentation/rmanrc.augmentation.js} +0 -0
package/core/config.js DELETED
@@ -1,939 +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 { createRequire } from 'module';
6
- import path from 'path';
7
- import semver from 'semver';
8
- import { pathToFileURL } from 'url';
9
- import vm from 'vm';
10
- import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
11
- import { finalizeConfig, mergeConfig, ORIGINS, PREVIOUS_VALUES } from './merge-config.js';
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
- const requireJsConfig = createRequire(import.meta.url);
35
- /**
36
- * Loads `file`'s config object. Tries `require()` first - not just an optimization: a CommonJS
37
- * module's `module.exports` is more reliably observed this way than through dynamic `import()`'s
38
- * CJS-interop synthesis, which some ESM loader hooks (e.g. ts-node/swc-node-style transpilers
39
- * registered via `--import`) can end up short-circuiting into an empty object. `require()` throws
40
- * `ERR_REQUIRE_ESM` for a genuinely-ESM file (`.mjs`, or `.js` under `"type": "module"`) - only
41
- * then does this fall back to `import()`, the one case that actually needs it. Either path can
42
- * hand back an ES module namespace instead of a plain object (Node's `require(esm)` support does
43
- * this too, not just `import()`), so `.default` is preferred whenever present.
44
- */
45
- async function loadJsConfig(file) {
46
- let mod;
47
- try {
48
- mod = requireJsConfig(file);
49
- }
50
- catch (e) {
51
- if (e?.code !== 'ERR_REQUIRE_ESM')
52
- throw e;
53
- mod = await import(pathToFileURL(file).href);
54
- }
55
- return mod?.default ?? mod;
56
- }
57
- /**
58
- * Reads the rman configuration defined at a single directory level, merging
59
- * (in increasing precedence): `package.json#rman`, `.rmanrc.yml`, `.rmanrc`,
60
- * then `.rmanrc.cjs`/`.rmanrc.mjs`/`.rmanrc.js` (whichever exist, in that order).
61
- */
62
- export async function readDirConfig(dirname) {
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');
67
- const pkgJsonFile = path.join(dirname, 'package.json');
68
- if (fs.existsSync(pkgJsonFile)) {
69
- const pkgJson = JSON.parse(fs.readFileSync(pkgJsonFile, 'utf-8'));
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, pkgJsonFile);
75
- }
76
- }
77
- const ymlFile = path.join(dirname, '.rmanrc.yml');
78
- if (fs.existsSync(ymlFile)) {
79
- const obj = yaml.load(fs.readFileSync(ymlFile, 'utf-8'));
80
- if (obj && typeof obj === 'object') {
81
- assertNoSelectorExtends(obj, ymlFile);
82
- if (EXTENDS_KEY in obj)
83
- extendsFrom = ymlFile;
84
- mergeConfig(result, obj, ymlFile);
85
- }
86
- }
87
- const rcFile = path.join(dirname, '.rmanrc');
88
- if (fs.existsSync(rcFile)) {
89
- const obj = JSON.parse(fs.readFileSync(rcFile, 'utf-8'));
90
- if (obj && typeof obj === 'object') {
91
- assertNoSelectorExtends(obj, rcFile);
92
- if (EXTENDS_KEY in obj)
93
- extendsFrom = rcFile;
94
- mergeConfig(result, obj, rcFile);
95
- }
96
- }
97
- for (const jsFileName of JS_CONFIG_FILES) {
98
- const jsFile = path.join(dirname, jsFileName);
99
- if (fs.existsSync(jsFile)) {
100
- const obj = await loadJsConfig(jsFile);
101
- if (obj && typeof obj === 'object') {
102
- assertNoSelectorExtends(obj, jsFile);
103
- if (EXTENDS_KEY in obj)
104
- extendsFrom = jsFile;
105
- mergeConfig(result, obj, jsFile);
106
- }
107
- }
108
- }
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);
114
- }
115
- /**
116
- * Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
117
- * it (inclusive) - each directory level overrides the ones above it, the way tsconfig's `extends`
118
- * chain does.
119
- *
120
- * Every level contributes in two ways:
121
- *
122
- * - **An unmarked key configures that directory and every package under it.** What a parent says
123
- * reaches the children, which is what every directory-scoped config in the ecosystem does and
124
- * what a reader expects without being told.
125
- * - **A `"[selector]"` block narrows the audience** - `"[/]"` to the root package alone, `"[*]"` or
126
- * a glob to the packages below (never the root, which is nobody's child). See `parseSelector`.
127
- *
128
- * **The root used to be the one directory whose unmarked config did *not* cascade**, on the
129
- * reasoning that a setting means different things to a package and to the repository - and the
130
- * reasoning is sound, but the rule it produced was not readable: an intermediate `packages/`
131
- * cascaded while the root did not, so what a file meant depended on whether a `package.json` sat
132
- * beside it. `vars` then had to be carved out as an exception, which is what a rule fighting itself
133
- * looks like. One sentence now covers both: what is written above reaches below, and `"[/]"` is how
134
- * a statement stays at the root.
135
- *
136
- * **The cost is real and lands on one subtree.** `run.<script>`'s hooks on the root are a repo-wide
137
- * bookend, run once at the repository root; on a package they are that package's own hook, run in
138
- * its directory. Cascaded, one declaration is both - once at the root and once per package. A
139
- * repo-wide bookend therefore belongs under `"[/]"`, where its audience is visible; that is the
140
- * migration this change asks for, and the only one that is not mechanical.
141
- *
142
- * `packageName` is what selectors match against; without it, selector blocks contribute nothing at
143
- * all. The root package passes its own, since `"[/]"` speaks to it.
144
- */
145
- export async function resolveConfig(rootDir, targetDir, cache = new Map(), packageName) {
146
- const result = {};
147
- const target = path.resolve(targetDir);
148
- /** The root *package* is the one whose directory is the repository root - no other test is
149
- * needed, and none would be as reliable: a name can be anything. In a single-package repository
150
- * that is the only package, so `"[/]"` reaches it and `"[*]"` reaches nothing. */
151
- const isRoot = target === path.resolve(rootDir);
152
- for (const dir of dirChain(rootDir, targetDir)) {
153
- let local = cache.get(dir);
154
- if (!local) {
155
- local = await readDirConfig(dir);
156
- cache.set(dir, local);
157
- }
158
- /**
159
- * **Unmarked first, because it is the widest thing this level says** - and that is an inversion
160
- * of the order this loop used to run in, where a directory's own plain config beat a selector
161
- * declared beside it. Under the old reading "unmarked" meant *this package* and so was the
162
- * narrower of the two; it now means *this package and everything below*, which is the wider.
163
- * Precedence follows the audience, not the spelling, so it had to move.
164
- *
165
- * Its position in the file is deliberately not consulted: a selector block written above the
166
- * plain keys still wins. Unmarked is not a fourth selector - it is the level's floor, and the
167
- * layer that feeds the directories below it.
168
- */
169
- mergeConfig(result, stripSelectors(local));
170
- /** Then the selector blocks, **in the order they were written** - see `matchingSelectors`. */
171
- if (packageName) {
172
- for (const block of matchingSelectors(local, packageName, isRoot))
173
- mergeConfig(result, block);
174
- }
175
- }
176
- /** Every layer has had its turn, so an append still outstanding has nothing left to attach to
177
- * and becomes the value itself. Done here rather than per layer: until the chain is finished,
178
- * the key it appends to may still be coming. */
179
- return finalizeConfig(result);
180
- }
181
- /** A config key naming packages rather than settings: `"[*]"`, `"[/]"`, `"[pkg-a]"`. The
182
- * brackets are what keep this space from colliding with real config keys - no setting starts with
183
- * one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
184
- * parses as a flow sequence. */
185
- export function isSelectorKey(key) {
186
- return key.length > 2 && key.startsWith('[') && key.endsWith(']');
187
- }
188
- /**
189
- * **Which packages a selector speaks for.** Two audiences, and the second is a glob:
190
- *
191
- * | | |
192
- * | --- | --- |
193
- * | `"[/]"` | the **root package** alone |
194
- * | `"[*]"`, `"[pkg-a]"`, `"[*-dialect]"` | the packages **below** this directory that the glob matches |
195
- *
196
- * `/` for the root because that is what a repository root is called everywhere else, and it cannot
197
- * collide with a package name.
198
- *
199
- * **The root is never selected by name, and that one rule removes two traps.** A glob matches
200
- * package names, and the root is nobody's child - so `"[my-*]"` cannot quietly pick up a repository
201
- * whose root package happens to be called `my-repo`, and `"[*]"` cannot hand a package-shaped
202
- * setting to a root that has no build directory to apply it to. The root is addressed structurally
203
- * or not at all.
204
- *
205
- * **`"[ws:*]"` / `"[workspace:*]"` is accepted and means exactly `"[*]"`.** The qualifier existed to
206
- * say "not the root" back when a bare glob included it; the shape of the set says that now, so it
207
- * has nothing left to add. Accepted rather than rejected because the two spellings resolve to the
208
- * same packages - an error would be friction with no reader to protect.
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
- const re = globToRegExp(stripWorkspacePrefix(inner));
215
- return { scope: 'package', test: name => re.test(name) };
216
- }
217
- /** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
218
- * `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
219
- * pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
220
- export function selectorToRegExp(key) {
221
- return globToRegExp(stripWorkspacePrefix(key.slice(1, -1)));
222
- }
223
- /**
224
- * Every selector block in `config` that speaks for this package, **in the order they were written**
225
- * - later wins, the way `overrides` works in eslint, prettier and babel, and the way a `.gitignore`
226
- * rule does.
227
- *
228
- * **There used to be a ranking** (`"[*]"` lowest, then a catch-all `"[ws:*]"`, then the rest by
229
- * declaration), so that "everything" could not beat a rule about one package by being written last.
230
- * It was dropped because the ordering it implies does not exist: specificity only ranks sets that
231
- * nest, and globs do not. For a package called `pkg-dialect`, neither `"[pkg-*]"` nor
232
- * `"[*-dialect]"` contains the other, so any answer is an invented tiebreak - and an invented
233
- * tiebreak is worse than the order the author typed. What was left was already declaration order
234
- * with one case lifted out of it; this removes the exception rather than generalizing it.
235
- *
236
- * The cost, which the docs state rather than hide: a catch-all written *below* a narrower block now
237
- * overrides it. Writing catch-alls first is a convention, not a rule - the file reads top to bottom.
238
- */
239
- function matchingSelectors(config, packageName, isRoot) {
240
- const matches = [];
241
- for (const [key, value] of Object.entries(config)) {
242
- if (!isSelectorKey(key) || !value || typeof value !== 'object')
243
- continue;
244
- const { scope, test } = parseSelector(key);
245
- if (scope === 'root' ? !isRoot : isRoot || !test(packageName))
246
- continue;
247
- matches.push(value);
248
- }
249
- return matches;
250
- }
251
- /** `"[ws:*]"` and `"[workspace:*]"` are the pre-2.x spelling of "not the root", kept working
252
- * because they now name the same set a bare glob does. Stripped here so one code path serves both. */
253
- function stripWorkspacePrefix(inner) {
254
- for (const prefix of WORKSPACE_PREFIXES)
255
- if (inner.startsWith(prefix))
256
- return inner.slice(prefix.length);
257
- return inner;
258
- }
259
- function globToRegExp(glob) {
260
- const source = glob
261
- .split('*')
262
- .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
263
- .join('.*');
264
- return new RegExp(`^${source}$`);
265
- }
266
- function stripSelectors(config) {
267
- const result = {};
268
- for (const [key, value] of Object.entries(config))
269
- if (!isSelectorKey(key))
270
- result[key] = value;
271
- return result;
272
- }
273
- /** `"[/]"` - the root package, spelled the way a repository root is spelled everywhere else, and
274
- * unable to collide with a package name. */
275
- const ROOT_SELECTOR_INNER = '/';
276
- /** Accepted spellings of the retired "not the root" qualifier - see `stripWorkspacePrefix`. */
277
- const WORKSPACE_PREFIXES = ['workspace:', 'ws:'];
278
- function dirChain(rootDir, targetDir) {
279
- const rel = path.relative(rootDir, targetDir);
280
- if (!rel || rel === '.' || rel.startsWith('..'))
281
- return [rootDir];
282
- const dirs = [rootDir];
283
- let dir = rootDir;
284
- for (const segment of rel.split(path.sep)) {
285
- dir = path.join(dir, segment);
286
- dirs.push(dir);
287
- }
288
- return dirs;
289
- }
290
- export function interpolateConfig(config, scope, options) {
291
- const skip = options?.skip ?? [];
292
- /**
293
- * Where `config` sits in the whole config, when a caller hands over a fragment rather than the
294
- * root - `version` interpolates its own `version.<slot>` value on its own, those three paths being
295
- * in `DEFERRED_PATHS`.
296
- *
297
- * It matters because the path is what decides whether a function is a value to compute or a step
298
- * to leave alone (`STEP_PATHS`). Without it, a fragment starts at the root and matches nothing, so
299
- * a function in a `version` hook was called while the hook was being *prepared* - measured, and it
300
- * failed inside the user's own code with `path.join` receiving undefined.
301
- */
302
- const base = options?.at ?? [];
303
- /**
304
- * Built from `scope`'s property **descriptors**, never `{ ...scope }`.
305
- *
306
- * A spread reads every property, so a lazy getter on the scope is no longer lazy the moment one
307
- * is added - and `git` is exactly that: it shells out to `git rev-parse`, and a spread here would
308
- * do it on `rman list`, `rman info` and every other command, in a repository whose config never
309
- * mentions git. (The same trap `pkg.targetVersion` documents from the other side: it is a
310
- * *throwing* getter, and being enumerable is what made a spread fire it.)
311
- */
312
- const context = vm.createContext(Object.defineProperties({}, Object.getOwnPropertyDescriptors(scope)));
313
- if (!config || typeof config !== 'object' || Array.isArray(config))
314
- return walk(config, scope, context, base, skip);
315
- /**
316
- * The config's own top-level keys, readable bare: `${{ publish.directory }}`. So a value that
317
- * restates another - `after: "cp README.md ${{ publish.directory }}/"` - stops being a second
318
- * copy that drifts when the first one changes.
319
- *
320
- * Resolved **on demand**, one key at a time, and memoized. Interpolating the config in tree order
321
- * and handing an expression whatever was ready would make the answer depend on key order in the
322
- * file, which is exactly the kind of quiet wrongness this evaluator exists to prevent: a key
323
- * declared above would read as resolved and one below as raw. On demand, each key is resolved
324
- * when first read and the order in the file means nothing.
325
- */
326
- const resolved = new Map();
327
- const resolving = [];
328
- /**
329
- * A cycle is **recorded here rather than thrown from the getter**, and that is not a style
330
- * choice: a host getter that throws inside a `vm` property interceptor has its exception
331
- * swallowed, and V8 then reports the global as absent - so a self-referencing key came out as
332
- * `publish is not defined`, which sends the reader looking for a missing key instead of a loop
333
- * (measured). The getter returns `undefined`, the resulting `ReferenceError` is caught below, and
334
- * this replaces it.
335
- */
336
- let cycle;
337
- const resolve = (key) => {
338
- if (resolved.has(key))
339
- return resolved.get(key);
340
- if (resolving.includes(key)) {
341
- cycle ??= new Error(`Config expression forms a cycle: ${[...resolving, key].join(' -> ')}\n` +
342
- ` A value cannot be derived from itself, directly or through another key.`);
343
- return undefined;
344
- }
345
- resolving.push(key);
346
- try {
347
- const chain = config[PREVIOUS_VALUES];
348
- const origins = config[ORIGINS];
349
- const value = withOrigin(origins?.[key], () => walkWithPrevious(config[key], chain?.[key], scope, context, [...base, key], skip));
350
- resolved.set(key, value);
351
- return value;
352
- }
353
- catch (e) {
354
- /** Not cleared: a cycle aborts the whole interpolation, and each level up would otherwise
355
- * re-swallow its own replacement the same way - leaving it set lets the outermost frame,
356
- * the one with a real stack to throw from, report it. */
357
- if (cycle)
358
- throw new Error(`${String(e?.message).split('\n')[0]}\n ${cycle.message}`, { cause: e });
359
- throw e;
360
- }
361
- finally {
362
- resolving.pop();
363
- }
364
- };
365
- for (const key of Object.keys(config)) {
366
- /** A scope binding wins: `pkg`/`repository`/`env`/`semver` are not config keys, so nothing
367
- * collides today, and a future key that did must not silently take over the namespace. */
368
- if (key in scope || !IDENTIFIER.test(key))
369
- continue;
370
- Object.defineProperty(context, key, { enumerable: true, configurable: true, get: () => resolve(key) });
371
- }
372
- /** Built through the same memo the getters use, so every key is walked exactly once whether an
373
- * expression asked for it first or the result did. */
374
- const result = {};
375
- for (const key of Object.keys(config))
376
- result[key] = resolve(key);
377
- return result;
378
- }
379
- /**
380
- * Config paths left untouched when a repository's config is first resolved, and evaluated only by
381
- * the command that runs them.
382
- *
383
- * `version`'s own hooks are the one place `${{ pkg.targetVersion }}` makes sense, and the version
384
- * being written is not known until `version` has computed its plan - long after the config was
385
- * resolved. Evaluating these eagerly would throw while merely *loading* the repository, so any
386
- * command at all would fail on a config that mentions it.
387
- */
388
- export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'];
389
- /**
390
- * Paths whose value is a **step** - something to run later - rather than a setting to compute now.
391
- * `*` matches one path segment (`run.<script>.exec`).
392
- *
393
- * This is what tells a step function from a value function, and the two live side by side in one
394
- * config:
395
- *
396
- * ```js
397
- * '[*]': {
398
- * clean: { include: ({ vars, value }) => [...value, vars.buildDir] }, // a value: called here
399
- * run: { build: { after: ({ pkg }) => copyDocs(pkg) } }, // a step: called by `run`
400
- * }
401
- * ```
402
- *
403
- * **The key decides, and it already did.** `run.build.exec: 'tsc -b'` is a shell command and
404
- * `publish.directory: 'build'` is a path - not because of anything about the strings, but because of
405
- * where they sit. A function inherits the same rule, so nothing new has to be learned and no marker
406
- * has to be remembered. The alternative was inspecting the function (arity, parameter names), which
407
- * is the kind of guess `loadPlugins` refuses to make about a module's export for the same reason:
408
- * guessing wrong here means running build-time code while merely loading the repository, or
409
- * silently never running it.
410
- *
411
- * A **string** at one of these paths is still interpolated - `exec: 'tsc -b ${{ file.resolve(...) }}'`
412
- * has to keep working - so this is narrower than `DEFERRED_PATHS`, which skips its paths entirely.
413
- */
414
- export const STEP_PATHS = [
415
- /** The bare-value shorthand: `run: { build: fn }` means `{ exec: fn }`, as `run: { build: 'cmd' }`
416
- * means `{ exec: 'cmd' }`. Missing it made the two spellings disagree about *when* the function
417
- * runs, which is worse than not supporting the short one at all. */
418
- 'run.*',
419
- 'run.*.before',
420
- 'run.*.exec',
421
- 'run.*.after',
422
- /** A condition, evaluated per package by `RunService` when the run reaches it. Called here
423
- * instead, it collapsed to the boolean it happened to return at load time - and `parseIfExpr`
424
- * then read that boolean as "no condition given", so the script ran unconditionally (measured). */
425
- 'run.*.if',
426
- 'version.before',
427
- 'version.exec',
428
- 'version.after',
429
- ];
430
- /**
431
- * Keys whose **whole subtree** is code rather than config, so no function under them is a value to
432
- * compute. `plugins` is the only one, and it has to be here: an entry may be the plugin *object*
433
- * itself, and an `RmanPlugin` is almost entirely functions - `manifest.read`, `workspace.resolve`,
434
- * `versionPlanner`, `binPaths`, and every command's `builder` and `handler`.
435
- *
436
- * Measured, and it is why this exists: with `plugins` walked like any other key, resolving the
437
- * config of a repository that named a plugin called that plugin's yargs builder with the config
438
- * scope - `Config function in "plugins[0].commands[0].builder" failed: cmd.option is not a
439
- * function`. A `plugins` entry is loaded by `loadPlugins`, never read as a setting.
440
- */
441
- export const CODE_SUBTREES = ['plugins'];
442
- const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
443
- /** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
444
- * `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
445
- const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
446
- /** The `file` namespace for one package's directory - see `FileScope`. */
447
- /**
448
- * `read` in a `${{ ... }}` expression (and in a value function): a structured file's **contents**,
449
- * parsed - where `file` answers only where a path is.
450
- *
451
- * ```yaml
452
- * "[*]":
453
- * run:
454
- * build:
455
- * exec: 'tsc --outDir ${{ read("tsconfig.json").compilerOptions.outDir }}'
456
- * ```
457
- *
458
- * `cache` is shared across every package (see `Repository.configScope`) and keyed by what the file
459
- * *is*, not merely where - so the same file read by twenty packages is parsed once, and a file rman
460
- * itself rewrites mid-run is re-read rather than remembered. See `readStructuredFile`.
461
- */
462
- export function createReadScope(dirname, cache) {
463
- return (target, format) => {
464
- if (typeof target !== 'string' || !target.trim()) {
465
- throw new Error('read() needs a path - it was given ' + JSON.stringify(target));
466
- }
467
- return readStructuredFile(path.resolve(dirname, target), format, cache);
468
- };
469
- }
470
- export function createFileScope(dirname) {
471
- const locate = (target) => {
472
- if (typeof target !== 'string' || !target.trim()) {
473
- throw new Error('file.exists()/file.resolve() need a path - they were given ' + JSON.stringify(target));
474
- }
475
- const resolved = path.resolve(dirname, target);
476
- return { path: resolved, found: fs.existsSync(resolved) };
477
- };
478
- return {
479
- exists(target) {
480
- const { path: resolved, found } = locate(target);
481
- return found ? resolved : '';
482
- },
483
- resolve(target) {
484
- const { path: resolved, found } = locate(target);
485
- if (!found) {
486
- throw new Error(`file.resolve("${target}") found nothing at ${resolved}\n` +
487
- ` Use file.exists() instead if its absence is a case to handle rather than a mistake.`);
488
- }
489
- return resolved;
490
- },
491
- resolveFirst(...targets) {
492
- if (!targets.length)
493
- throw new Error('file.resolveFirst() needs at least one path');
494
- for (const target of targets) {
495
- const { path: resolved, found } = locate(target);
496
- if (found)
497
- return resolved;
498
- }
499
- throw new Error(`file.resolveFirst() found none of: ${targets.map(t => `"${t}"`).join(', ')}\n` + ` Looked in ${dirname}.`);
500
- },
501
- };
502
- }
503
- function walk(value, scope, context, at, skip) {
504
- /** Compared on the key path rather than the value, so a deferred key's whole subtree - a single
505
- * command or an array of them - is handed on untouched. */
506
- if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
507
- return value;
508
- if (typeof value === 'function') {
509
- /** Code, not a value: a step for `run`/`version` to call in its own time, or a plugin's own
510
- * function. Carried through exactly as a command string would be - calling it here would run
511
- * build-time work while merely *loading* the repository, which is the whole distinction the
512
- * function form exists to draw. */
513
- if (isCodePath(at))
514
- return value;
515
- return callValueFn(value, context, at);
516
- }
517
- if (typeof value === 'string')
518
- return interpolateString(value, context, at);
519
- if (Array.isArray(value))
520
- return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
521
- if (value && typeof value === 'object') {
522
- const chain = value[PREVIOUS_VALUES];
523
- return withScopedVars(value, scope, context, at, skip, () => {
524
- const result = {};
525
- const origins = value[ORIGINS];
526
- for (const [key, item] of Object.entries(value)) {
527
- result[key] = withOrigin(origins?.[key], () => walkWithPrevious(item, chain?.[key], scope, context, [...at, key], skip));
528
- }
529
- return result;
530
- });
531
- }
532
- return value;
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
- * The file the key being walked was written in, for the errors below to name.
614
- *
615
- * A config is merged from several files before anything reads it - a directory's own forms, an
616
- * `extends` base, every `"[selector]"` block, one layer per directory - so `version.commitMessage`
617
- * alone does not say where to go and look. `mergeConfig` records the file per key (`ORIGINS`); this
618
- * is the depth-first cursor over that, kept in a module variable rather than threaded through
619
- * `walk`'s signature because every error site would otherwise have to carry a parameter it only
620
- * passes on.
621
- *
622
- * Nested keys inherit the enclosing file when the merge recorded none of their own, which is what a
623
- * nested object in one file means.
624
- */
625
- let currentOrigin;
626
- function withOrigin(origin, body) {
627
- const outer = currentOrigin;
628
- if (origin !== undefined)
629
- currentOrigin = origin;
630
- try {
631
- return body();
632
- }
633
- finally {
634
- currentOrigin = outer;
635
- }
636
- }
637
- /** `"version.commitMessage" (.rmanrc.yml)`, or just the path when nothing recorded a file - a
638
- * caller interpolating a fragment it built itself, say. Relative to the repository when it sits
639
- * inside one, since an absolute path is noise in a message about the repository you are in. */
640
- function describeAt(at) {
641
- const where = at.length ? formatPath(at) : 'the config root';
642
- return currentOrigin ? `${where}" (${shortenOrigin(currentOrigin)})` : `${where}"`;
643
- }
644
- function shortenOrigin(file) {
645
- const relative = path.relative(process.cwd(), file);
646
- return !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
647
- }
648
- /**
649
- * Walks one key of an object with **`value` bound** to whatever the layers below it resolved to.
650
- *
651
- * Bound on the interpolation context rather than passed as an argument, because an expression reads
652
- * it as a global (`"${{ [...value, 'x'] }}"`) - a function then picks the same binding up through
653
- * its prototype, so the two spellings cannot disagree about what `value` is. It was function-only
654
- * at first, on the reasoning that a string cannot carry an array back; that was wrong, since a
655
- * string which is *nothing but* one expression keeps the value's own type.
656
- *
657
- * **Always bound, even with nothing underneath.** Left unbound, an expression naming it fails with
658
- * V8's `value is not defined`, which reads as "there is no such thing" rather than "nothing below
659
- * this layer set it" - two different mistakes needing two different fixes.
660
- *
661
- * The chain resolves bottom-up, so a layer deriving from a layer that itself derived from something
662
- * is handed the finished value rather than a half-resolved expression.
663
- */
664
- function walkWithPrevious(item, previous, scope, context, at, skip) {
665
- const resolved = previous === undefined ? undefined : walkWithPrevious(previous.value, previous.previous, scope, context, at, skip);
666
- const outer = Object.getOwnPropertyDescriptor(context, VALUE_KEY);
667
- /** A getter, so the catch below can tell whether the value **actually read `value`**: the hint is
668
- * irrelevant to any other failure, and attaching it anyway is the send-the-reader-to-the-wrong-
669
- * place mistake it exists to prevent. Recorded, never matched on V8's wording. */
670
- let wasRead = false;
671
- Object.defineProperty(context, VALUE_KEY, {
672
- configurable: true,
673
- enumerable: true,
674
- get: () => {
675
- wasRead = true;
676
- return resolved;
677
- },
678
- });
679
- try {
680
- return walk(item, scope, context, at, skip);
681
- }
682
- catch (e) {
683
- /**
684
- * **`value` is `undefined` when no layer underneath set this key**, and a value written to
685
- * extend an inherited list is also the *first* layer in a repository that inherits nothing.
686
- * V8 reports that as `value is not iterable`, naming neither the key nor the reason.
687
- *
688
- * Here rather than in `callValueFn`, so the expression and the function spelling get the same
689
- * sentence from the same place. `rmanValueHint` keeps a rethrow from stacking it twice as the
690
- * error passes back up through the enclosing keys.
691
- *
692
- * Not papered over by defaulting `value` to `[]`: that would be a guess about the key's type,
693
- * and wrong for every key that is not a list.
694
- */
695
- if (wasRead && resolved === undefined && !e?.rmanValueHint) {
696
- e.rmanValueHint = true;
697
- e.message =
698
- `${e.message}\n \`value\` is undefined here - nothing below this layer sets "${describeAt(at)}.` +
699
- `\n Write \`value ?? []\` (or \`?? ''\`) if it has to work as the first layer too.`;
700
- }
701
- throw e;
702
- }
703
- finally {
704
- if (outer)
705
- Object.defineProperty(context, VALUE_KEY, outer);
706
- else
707
- delete context[VALUE_KEY];
708
- }
709
- }
710
- /** What a layer deriving from the one below it reads - see `walkWithPrevious`. */
711
- const VALUE_KEY = 'value';
712
- /**
713
- * Whether a function at `at` is **code** - a step to run later, or part of a plugin - rather than a
714
- * value to compute now.
715
- *
716
- * Array indices are dropped before matching, so a function inside a *list* of steps is still a
717
- * step; `*` in a `STEP_PATHS` entry matches any one segment (`run.<script>.exec`).
718
- */
719
- function isCodePath(at) {
720
- const segments = at.filter((p) => typeof p === 'string');
721
- if (CODE_SUBTREES.includes(segments[0]))
722
- return true;
723
- return STEP_PATHS.some(pattern => {
724
- const parts = pattern.split('.');
725
- return parts.length === segments.length && parts.every((part, i) => part === '*' || part === segments[i]);
726
- });
727
- }
728
- /**
729
- * Calls a **value** function: the JS spelling of a `${{ }}` expression, and it answers the same
730
- * question at the same moment.
731
- *
732
- * It receives one object carrying everything an expression can name - `pkg`, `repository`, `file`,
733
- * `env`, `semver`, `path`, plus the config's own top-level keys - and, in addition, **`value`**: what
734
- * this key resolved to in the layers underneath, which is what makes a derived value possible
735
- * without restating the base.
736
- *
737
- * Built with the interpolation context as its **prototype**, not copied from it. The top-level keys
738
- * are lazy getters (`resolve`, memoized, so key order in the file means nothing and a cycle is
739
- * reported rather than half-resolved); spreading them into a new object would fire every one of
740
- * them on every call, including the ones a function never reads - and one of those throwing would
741
- * blame the wrong key.
742
- *
743
- * **It must compute and return, never act.** This runs while the repository's config resolves,
744
- * which *every* command does - so a value function that writes a file writes it on `rman list`,
745
- * `rman info` and `rman config` too, N times for N packages, with no command having asked for
746
- * anything. That is the same reason `FileScope` offers no way to change anything. Work goes in a
747
- * step, which is the one thing rman runs on purpose and which can also be a function.
748
- */
749
- function callValueFn(fn, context, at) {
750
- /** `value` arrives through the prototype, bound by `walkWithPrevious` for exactly this key - so
751
- * nothing here may *read* it. Passing it in as an argument did, which tripped the "was it read"
752
- * getter before the function ran and put the `value` hint on every unrelated failure (caught by
753
- * the spec that exists for precisely that). */
754
- const arg = Object.create(context);
755
- /**
756
- * A getter only so the catch below can tell whether the function **actually read `value`**.
757
- *
758
- * Without that, the "value is undefined" hint went out with *every* failure of a first-layer
759
- * function - a frozen-object `TypeError` from `read()` arrived wearing advice about spreading an
760
- * inherited list, which is precisely the send-the-reader-to-the-wrong-place mistake the hint
761
- * exists to prevent. Recorded rather than inferred from the message, because matching on V8's
762
- * wording is the other way to get this wrong.
763
- */
764
- try {
765
- return fn(arg);
766
- }
767
- catch (e) {
768
- /** The `value` hint comes from `walkWithPrevious`, which wraps this call and is the one place
769
- * that knows whether `value` was read - so the expression spelling gets the same sentence. */
770
- throw new Error(`Config function in "${describeAt(at)} failed: ${e?.message}`, { cause: e });
771
- }
772
- }
773
- function interpolateString(value, context, at) {
774
- if (!value.includes('${{'))
775
- return value;
776
- const found = [...value.matchAll(EXPRESSION)];
777
- if (!found.length)
778
- return value;
779
- /** Counted rather than matched with an anchored `^...$` regex: a lazy quantifier still backtracks
780
- * to satisfy an end anchor, so `"${{ a }} and ${{ b }}"` looked like *one* expression whose body
781
- * ran from `a` to `b`, brace-ends and all - invalid JavaScript. */
782
- const soleExpression = found.length === 1 && found[0][0] === value.trim();
783
- // Alone, a nullish result is just "this setting is unset" - a legitimate answer.
784
- if (soleExpression)
785
- return evaluate(found[0][1], value, context, at);
786
- return value.replace(EXPRESSION, (_, expr) => {
787
- const result = evaluate(expr, value, context, at);
788
- /** Embedded in text, though, it never is: splicing in the word "undefined" produces a path or
789
- * tag like `app:undefined` that looks plausible and is wrong - the exact silent-mistake shape
790
- * this evaluator exists to avoid. `?? 'fallback'` says what was meant. */
791
- if (result === undefined || result === null) {
792
- throw new Error(`Expression in "${describeAt(at)} is ${result} inside a string: ${value.trim()}\n` +
793
- ` \${{${expr}}} has no value here - give it a fallback (\${{${expr.trim()} ?? '...'}}).`);
794
- }
795
- return String(result);
796
- });
797
- }
798
- /** Names the config path as well as the expression: an error saying only "x is not defined" sends
799
- * the reader hunting through a file that may hold dozens of them. */
800
- function evaluate(expr, source, context, at) {
801
- try {
802
- return vm.runInContext(expr, context, { timeout: EXPRESSION_TIMEOUT });
803
- }
804
- catch (e) {
805
- throw new Error(`Invalid expression in "${describeAt(at)}: ${source.trim()}\n ${e?.message ?? e}`, { cause: e });
806
- }
807
- }
808
- function formatPath(at) {
809
- return at.reduce((acc, part) => (typeof part === 'number' ? `${acc}[${part}]` : acc ? `${acc}.${part}` : String(part)), '');
810
- }
811
- /** Guards against an expression that never returns (`while(true)`) taking the whole command with
812
- * it - a typo, not an attack, but the failure mode is identical. */
813
- const EXPRESSION_TIMEOUT = 1000;
814
- /**
815
- * Reads and parses one structured file, memoized against **the identity of its contents** rather
816
- * than its path alone: the cache key is `mtimeNs:size`.
817
- *
818
- * Both halves of that were chosen against a measurement.
819
- *
820
- * - **A stat rather than a re-read**: `statSync` is 1.3µs where `readFileSync` + `JSON.parse` is
821
- * 16.1µs on a 2KB manifest - so the check costs a thirteenth of what it saves, and the same file
822
- * read by twenty packages is parsed once. (`interpolateConfig` runs once *per package*, so a
823
- * cache living in one pass would not have helped across them at all.)
824
- * - **Keyed on the stat rather than held for the run**: rman writes JSON files while it is running
825
- * - `version` rewrites every bumped manifest, then re-interpolates its own deferred hooks. A
826
- * cache that only remembered the path would hand those back as they were before the write.
827
- * `mtimeNs` is nanoseconds, so a rewrite within the same millisecond does not slip through; the
828
- * size is in the key as well because it costs nothing.
829
- *
830
- * **Frozen, deeply, once on the way into the cache.** Every package is handed the same object, so
831
- * one config mutating it would quietly change what the next package sees - the reason `pkg.manifest`
832
- * has always been a copy. Freezing is better than copying here: a copy costs 5.6µs on *every* call,
833
- * freezing costs ~1µs *once*, and it turns the mistake into a `TypeError` instead of an effect at a
834
- * distance. A caller that wants to change something spreads it first.
835
- */
836
- function readStructuredFile(file, format, cache) {
837
- let stat;
838
- try {
839
- stat = fs.statSync(file, { bigint: true });
840
- }
841
- catch {
842
- throw new Error(`read("${path.basename(file)}") found nothing at ${file}\n` +
843
- ` Use file.exists() first if its absence is a case to handle rather than a mistake.`);
844
- }
845
- if (stat.isDirectory())
846
- throw new Error(`read() was given a directory, not a file: ${file}`);
847
- const stamp = `${stat.mtimeNs}:${stat.size}`;
848
- const cached = cache.get(file);
849
- if (cached?.stamp === stamp)
850
- return cached.value;
851
- const resolved = format ?? formatOf(file);
852
- const text = fs.readFileSync(file, 'utf-8');
853
- let value;
854
- try {
855
- value = parseStructured(text, resolved);
856
- }
857
- catch (e) {
858
- /** The parser's own message says what is wrong with the syntax but never which file it was
859
- * reading - and an expression can name several. */
860
- throw new Error(`read("${path.basename(file)}") could not parse ${file} as ${resolved}: ${e?.message}`, {
861
- cause: e,
862
- });
863
- }
864
- deepFreeze(value);
865
- cache.set(file, { stamp, value });
866
- return value;
867
- }
868
- /** The extension decides, because the caller already wrote it - naming the parser as well would
869
- * restate it and let the two disagree (`json("x.yml")`). A name that says nothing takes the
870
- * explicit argument instead. */
871
- function formatOf(file) {
872
- const ext = path.extname(file).toLowerCase();
873
- if (ext === '.json')
874
- return 'json';
875
- if (ext === '.yml' || ext === '.yaml')
876
- return 'yaml';
877
- if (ext === '.ini')
878
- return 'ini';
879
- if (XML_EXTENSIONS.has(ext))
880
- return 'xml';
881
- throw new Error(`read() cannot tell what "${path.basename(file)}" is from its name.\n` +
882
- ` Name the format: read("${path.basename(file)}", "json" | "yaml" | "ini" | "xml").`);
883
- }
884
- /** The XML family worth recognizing by name: a project file is XML whatever its extension calls
885
- * itself, and `.csproj`/`.pom` are what a .NET or Maven repository actually holds. Anything else
886
- * still reads with an explicit `read(p, 'xml')`. */
887
- const XML_EXTENSIONS = new Set(['.xml', '.csproj', '.vbproj', '.fsproj', '.props', '.targets', '.nuspec', '.plist']);
888
- function parseStructured(text, format) {
889
- if (format === 'json')
890
- return JSON.parse(text);
891
- /** `load`, not `loadAll`: a multi-document stream has no single value to be, and js-yaml says so
892
- * clearly enough ("expected a single document in the stream") to leave alone. */
893
- if (format === 'yaml')
894
- return yaml.load(text);
895
- if (format === 'xml')
896
- return parseXml(text);
897
- return ini.parse(text);
898
- }
899
- /**
900
- * A **DOM**, not an object - and the asymmetry with the other three formats is the honest shape
901
- * rather than an omission.
902
- *
903
- * XML has no lossless object form: an element can repeat, carry attributes and hold text at the
904
- * same time, so any flattening has to pick a convention (`$`? `_text`? array-or-not?) and be wrong
905
- * for somebody. A DOM is the shape XML actually has, so a config reads it the way every other XML
906
- * tool does:
907
- *
908
- * ```yaml
909
- * version: '${{ read("pom.xml").getElementsByTagName("version")[0].textContent }}'
910
- * ```
911
- *
912
- * **Freezing it is safe** - measured, not assumed: a frozen `@xmldom/xmldom` document still answers
913
- * `getElementsByTagName` for a tag first asked about *after* the freeze (the live-collection case
914
- * that would have broken it), reads attributes, resolves namespaces, walks `childNodes` and
915
- * serialises back.
916
- */
917
- function parseXml(text) {
918
- /** xmldom reports a malformed document through a handler and otherwise carries on with whatever
919
- * it could salvage - so without this, a broken file would come back as a half-parsed DOM and the
920
- * expression reading it would simply find nothing. `read()` throws for a broken JSON file; it has
921
- * to throw for this one too. */
922
- const problems = [];
923
- const doc = new DOMParser({
924
- onError: (level, message) => {
925
- if (level !== 'warning')
926
- problems.push(message.split('\n')[0]);
927
- },
928
- }).parseFromString(text, 'text/xml');
929
- if (problems.length)
930
- throw new Error(problems[0]);
931
- return doc;
932
- }
933
- function deepFreeze(value) {
934
- if (!value || typeof value !== 'object' || Object.isFrozen(value))
935
- return;
936
- Object.freeze(value);
937
- for (const item of Object.values(value))
938
- deepFreeze(item);
939
- }