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.
- package/README.md +2 -12
- package/{plugins/node/augmentation/rman.augmentation.d.ts → builtins/platforms/node/augmentation/rmanrc.augmentation.d.ts} +4 -5
- package/{plugins → builtins/platforms}/node/augmentation/system-info.augmentation.js +1 -1
- package/{plugins → builtins/platforms}/node/commands/ci.command.d.ts +5 -3
- package/{plugins → builtins/platforms}/node/commands/ci.command.js +6 -5
- package/{plugins → builtins/platforms}/node/commands/clean.command.d.ts +6 -4
- package/{plugins → builtins/platforms}/node/commands/clean.command.js +6 -5
- package/builtins/platforms/node/index.d.ts +8 -0
- package/builtins/platforms/node/index.js +8 -0
- package/{plugins → builtins/platforms}/node/node-config.interface.d.ts +1 -2
- package/{plugins → builtins/platforms}/node/node-manifest.provider.d.ts +3 -3
- package/{plugins → builtins/platforms}/node/node-manifest.provider.js +3 -3
- package/{plugins → builtins/platforms}/node/node.platform.d.ts +3 -3
- package/{plugins → builtins/platforms}/node/services/ci.service.d.ts +3 -3
- package/{plugins → builtins/platforms}/node/services/ci.service.js +4 -4
- package/{plugins → builtins/platforms}/node/services/clean.service.d.ts +3 -3
- package/{plugins → builtins/platforms}/node/services/clean.service.js +3 -3
- package/{plugins → builtins/platforms}/node/services/publish.service.d.ts +4 -4
- package/{plugins → builtins/platforms}/node/services/publish.service.js +13 -7
- package/{plugins → builtins/platforms}/node/services/version-plan.service.d.ts +3 -3
- package/{plugins → builtins/platforms}/node/services/version-plan.service.js +2 -2
- package/builtins/presets/index.d.ts +15 -0
- package/builtins/presets/index.js +57 -0
- package/builtins/presets/node.d.ts +16 -0
- package/builtins/presets/node.js +57 -0
- package/{services → builtins/publish-targets/docker}/docker-publish.service.d.ts +6 -6
- package/{services → builtins/publish-targets/docker}/docker-publish.service.js +9 -9
- package/{targets → builtins/publish-targets/docker}/docker.target.d.ts +6 -5
- package/{targets → builtins/publish-targets/docker}/docker.target.js +1 -1
- package/builtins/publish-targets/docker/index.d.ts +2 -0
- package/builtins/publish-targets/docker/index.js +2 -0
- package/builtins/publish-targets/npm/index.d.ts +2 -0
- package/builtins/publish-targets/npm/index.js +2 -0
- package/{plugins/node → builtins/publish-targets/npm}/npm-publish-target.d.ts +4 -4
- package/{plugins/node → builtins/publish-targets/npm}/npm-publish-target.js +4 -5
- package/cli.d.ts +3 -0
- package/cli.js +16 -7
- package/commands/changelog.command.d.ts +106 -2
- package/commands/changelog.command.js +40 -0
- package/commands/config.command.js +23 -5
- package/commands/list.command.js +1 -1
- package/commands/publish.command.d.ts +1 -1
- package/commands/publish.command.js +1 -1
- package/commands/run.command.js +1 -1
- package/commands/version.command.d.ts +16 -3
- package/commands/version.command.js +143 -9
- package/commands.d.ts +0 -1
- package/commands.js +0 -1
- package/constants.js +1 -1
- package/core/application.d.ts +10 -10
- package/core/application.js +28 -11
- package/core/{package.d.ts → classes/package.d.ts} +12 -4
- package/core/{package.js → classes/package.js} +17 -1
- package/core/{repository.d.ts → classes/repository.d.ts} +22 -36
- package/core/{repository.js → classes/repository.js} +112 -241
- package/core/{service.d.ts → classes/service.d.ts} +2 -2
- package/core/classes/workspace.d.ts +277 -0
- package/core/classes/workspace.js +585 -0
- package/core/command-builder.js +17 -1
- package/core/config/config-file-scope.d.ts +107 -0
- package/core/config/config-file-scope.js +226 -0
- package/core/config/config-interpolator.d.ts +164 -0
- package/core/config/config-interpolator.js +577 -0
- package/core/config/config-paths.d.ts +65 -0
- package/core/config/config-paths.js +78 -0
- package/core/config/config-reader.d.ts +215 -0
- package/core/config/config-reader.js +851 -0
- package/core/{merge-config.js → config/merge-config.js} +4 -8
- package/core/{custom-command.d.ts → interfaces/custom-command.d.ts} +5 -5
- package/core/{manifest.d.ts → interfaces/manifest.d.ts} +3 -3
- package/core/{manifest.js → interfaces/manifest.js} +2 -2
- package/core/{plugin.d.ts → interfaces/plugin.d.ts} +44 -27
- package/core/{plugin.js → interfaces/plugin.js} +48 -10
- package/core/{publish-target.d.ts → interfaces/publish-target.d.ts} +24 -12
- package/core/{publish-target.js → interfaces/publish-target.js} +16 -3
- package/core/{run-step.d.ts → interfaces/run-step.d.ts} +4 -4
- package/core/plugin-loader.d.ts +11 -51
- package/core/plugin-loader.js +21 -202
- package/index.d.ts +29 -28
- package/index.js +20 -46
- package/interfaces/config-scope.interface.d.ts +223 -0
- package/interfaces/config-scope.interface.js +1 -0
- package/interfaces/rman-config.interface.d.ts +100 -5
- package/interfaces/rman-config.interface.js +17 -0
- package/package.json +3 -2
- package/services/change-hash.service.d.ts +40 -6
- package/services/change-hash.service.js +90 -11
- package/services/changelog.service.d.ts +31 -3
- package/services/changelog.service.js +448 -65
- package/services/config-service.d.ts +8 -0
- package/services/config-service.js +3 -0
- package/services/exec.service.d.ts +2 -2
- package/services/exec.service.js +1 -1
- package/services/github-release.service.d.ts +3 -3
- package/services/github-release.service.js +3 -3
- package/services/import.service.d.ts +2 -2
- package/services/import.service.js +1 -1
- package/services/list.service.d.ts +9 -7
- package/services/list.service.js +2 -2
- package/services/run.service.d.ts +15 -11
- package/services/run.service.js +62 -18
- package/services/system-info.d.ts +1 -1
- package/services/version-plan.service.d.ts +8 -4
- package/services/version-plan.service.js +54 -21
- package/services/version.service.d.ts +5 -5
- package/services/version.service.js +7 -4
- package/services.d.ts +1 -1
- package/services.js +1 -1
- package/utils/branch-guard.d.ts +1 -1
- package/utils/color-yaml.d.ts +8 -0
- package/utils/color-yaml.js +86 -0
- package/utils/git.d.ts +26 -0
- package/utils/git.js +65 -0
- package/utils/logger.d.ts +1 -1
- package/utils/package-filter.d.ts +8 -1
- package/utils/package-filter.js +7 -0
- package/utils/printable-config.d.ts +8 -0
- package/utils/printable-config.js +45 -0
- package/utils/progress-panel.d.ts +9 -0
- package/utils/progress-panel.js +23 -1
- package/utils/release-version.d.ts +1 -1
- package/utils/run-options.js +1 -1
- package/utils/version-group.d.ts +31 -0
- package/utils/version-group.js +43 -0
- package/commands/changed.command.d.ts +0 -80
- package/commands/changed.command.js +0 -36
- package/core/config.d.ts +0 -530
- package/core/config.js +0 -1123
- package/core/core-services.d.ts +0 -14
- package/core/core-services.js +0 -30
- package/core/core-targets.d.ts +0 -14
- package/core/core-targets.js +0 -16
- package/core/extends-config.d.ts +0 -35
- package/core/extends-config.js +0 -129
- package/core/load-config-module.d.ts +0 -28
- package/core/load-config-module.js +0 -42
- package/core/workspace.d.ts +0 -119
- package/core/workspace.js +0 -124
- package/plugins/builtins.d.ts +0 -44
- package/plugins/builtins.js +0 -33
- package/plugins/detect.d.ts +0 -78
- package/plugins/detect.js +0 -70
- package/plugins/node/index.d.ts +0 -29
- package/plugins/node/index.js +0 -40
- /package/{core/run-step.js → builtins/platforms/node/augmentation/rmanrc.augmentation.js} +0 -0
- /package/{plugins → builtins/platforms}/node/augmentation/system-info.augmentation.d.ts +0 -0
- /package/{plugins → builtins/platforms}/node/node-config.interface.js +0 -0
- /package/{plugins → builtins/platforms}/node/node.platform.js +0 -0
- /package/{plugins → builtins/platforms}/node/utils/workspace-range.d.ts +0 -0
- /package/{plugins → builtins/platforms}/node/utils/workspace-range.js +0 -0
- /package/{plugins/node/utils → builtins/publish-targets/npm}/npm-view.d.ts +0 -0
- /package/{plugins/node/utils → builtins/publish-targets/npm}/npm-view.js +0 -0
- /package/core/{registry.d.ts → classes/registry.d.ts} +0 -0
- /package/core/{registry.js → classes/registry.js} +0 -0
- /package/core/{service.js → classes/service.js} +0 -0
- /package/core/{version-scheme.d.ts → classes/version-scheme.d.ts} +0 -0
- /package/core/{version-scheme.js → classes/version-scheme.js} +0 -0
- /package/core/{merge-config.d.ts → config/merge-config.d.ts} +0 -0
- /package/core/{resolve-target.d.ts → config/resolve-config-target.d.ts} +0 -0
- /package/core/{resolve-target.js → config/resolve-config-target.js} +0 -0
- /package/core/{custom-command.js → interfaces/custom-command.js} +0 -0
- /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');
|