rman 1.1.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +3 -2
- package/commands/config.command.js +3 -2
- package/constants.js +1 -1
- package/core/config.d.ts +125 -6
- package/core/config.js +451 -50
- package/core/merge-config.d.ts +24 -0
- package/core/merge-config.js +43 -0
- package/core/repository.d.ts +15 -1
- package/core/repository.js +40 -8
- package/core/run-step.d.ts +75 -0
- package/core/run-step.js +1 -0
- package/index.d.ts +1 -0
- package/interfaces/rman-config.interface.d.ts +58 -17
- package/package.json +2 -1
- package/services/run.service.d.ts +66 -20
- package/services/run.service.js +171 -39
- package/services/version.service.d.ts +13 -3
- package/services/version.service.js +17 -11
- package/utils/printable-config.d.ts +15 -0
- package/utils/printable-config.js +42 -0
package/core/config.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { DOMParser } from '@xmldom/xmldom';
|
|
1
2
|
import fs from 'fs';
|
|
3
|
+
import ini from 'ini';
|
|
2
4
|
import * as yaml from 'js-yaml';
|
|
3
5
|
import { createRequire } from 'module';
|
|
4
6
|
import path from 'path';
|
|
@@ -6,7 +8,7 @@ import semver from 'semver';
|
|
|
6
8
|
import { pathToFileURL } from 'url';
|
|
7
9
|
import vm from 'vm';
|
|
8
10
|
import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
|
|
9
|
-
import { finalizeConfig, mergeConfig } from './merge-config.js';
|
|
11
|
+
import { finalizeConfig, mergeConfig, PREVIOUS_VALUES } from './merge-config.js';
|
|
10
12
|
/**
|
|
11
13
|
* Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
|
|
12
14
|
* autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
|
|
@@ -290,53 +292,31 @@ function dirChain(rootDir, targetDir) {
|
|
|
290
292
|
}
|
|
291
293
|
return dirs;
|
|
292
294
|
}
|
|
293
|
-
/**
|
|
294
|
-
* Evaluates every `${{ ... }}` expression in **every** string value of a resolved config, against
|
|
295
|
-
* the package it was resolved for:
|
|
296
|
-
*
|
|
297
|
-
* ```yaml
|
|
298
|
-
* "[*]":
|
|
299
|
-
* clean:
|
|
300
|
-
* include: ["build", "../../coverage/${{ pkg.basename }}"]
|
|
301
|
-
* publish:
|
|
302
|
-
* directory: build
|
|
303
|
-
* docker:
|
|
304
|
-
* image: "panates/${{ pkg.basename }}:${{ semver.major(pkg.version) }}"
|
|
305
|
-
* run:
|
|
306
|
-
* build:
|
|
307
|
-
* # the config's own keys are in scope, so this is not a second copy of "build"
|
|
308
|
-
* after: "cp README.md ${{ publish.directory }}/"
|
|
309
|
-
* ```
|
|
310
|
-
*
|
|
311
|
-
* Every string, with no list of "interpolated keys" to memorize - a rule with exceptions is a rule
|
|
312
|
-
* nobody remembers.
|
|
313
|
-
*
|
|
314
|
-
* The contents are **real JavaScript**, not a template mini-language, so there is no growing list
|
|
315
|
-
* of substitutions to keep adding (`{{major}}`, `{{scope}}`, ...) - see `ConfigScope` for what is
|
|
316
|
-
* in scope.
|
|
317
|
-
*
|
|
318
|
-
* **`${{ }}`, deliberately not `{{ }}`.** A config value may legitimately carry `{{...}}` meant for
|
|
319
|
-
* something else entirely (`helm template --set tag={{.Values.tag}}`); with the plainer delimiter
|
|
320
|
-
* rman would try to evaluate it. To emit a literal, let an expression produce it, the way GitHub
|
|
321
|
-
* Actions does: `${{ '${{' }}`.
|
|
322
|
-
*
|
|
323
|
-
* A string that is *nothing but* one expression keeps the value's own type (`"${{ pkg.private }}"`
|
|
324
|
-
* -> a boolean), since otherwise this could only ever produce strings and settings like
|
|
325
|
-
* `run.<script>.skip` would be unreachable. Embedded in surrounding text it is stringified.
|
|
326
|
-
*
|
|
327
|
-
* Evaluation happens in a fresh V8 context holding only the scope's bindings. That is a clean
|
|
328
|
-
* scope, **not a sandbox** - `node:vm` is explicitly not a security mechanism, and no sandbox is
|
|
329
|
-
* called for here anyway: a `.rmanrc` that can say `exec: "..."` already runs arbitrary shell, so
|
|
330
|
-
* the expression evaluator adds no trust boundary that wasn't already wide open.
|
|
331
|
-
*
|
|
332
|
-
* A failing expression throws with the config path that holds it, rather than being left in place:
|
|
333
|
-
* silently passing through a mistake is how a config ends up quietly doing nothing.
|
|
334
|
-
*/
|
|
335
295
|
export function interpolateConfig(config, scope, options) {
|
|
336
296
|
const skip = options?.skip ?? [];
|
|
337
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Where `config` sits in the whole config, when a caller hands over a fragment rather than the
|
|
299
|
+
* root - `version` interpolates its own `version.<slot>` value on its own, those three paths being
|
|
300
|
+
* in `DEFERRED_PATHS`.
|
|
301
|
+
*
|
|
302
|
+
* It matters because the path is what decides whether a function is a value to compute or a step
|
|
303
|
+
* to leave alone (`STEP_PATHS`). Without it, a fragment starts at the root and matches nothing, so
|
|
304
|
+
* a function in a `version` hook was called while the hook was being *prepared* - measured, and it
|
|
305
|
+
* failed inside the user's own code with `path.join` receiving undefined.
|
|
306
|
+
*/
|
|
307
|
+
const base = options?.at ?? [];
|
|
308
|
+
/**
|
|
309
|
+
* Built from `scope`'s property **descriptors**, never `{ ...scope }`.
|
|
310
|
+
*
|
|
311
|
+
* A spread reads every property, so a lazy getter on the scope is no longer lazy the moment one
|
|
312
|
+
* is added - and `git` is exactly that: it shells out to `git rev-parse`, and a spread here would
|
|
313
|
+
* do it on `rman list`, `rman info` and every other command, in a repository whose config never
|
|
314
|
+
* mentions git. (The same trap `pkg.targetVersion` documents from the other side: it is a
|
|
315
|
+
* *throwing* getter, and being enumerable is what made a spread fire it.)
|
|
316
|
+
*/
|
|
317
|
+
const context = vm.createContext(Object.defineProperties({}, Object.getOwnPropertyDescriptors(scope)));
|
|
338
318
|
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
339
|
-
return walk(config, scope, context,
|
|
319
|
+
return walk(config, scope, context, base, skip);
|
|
340
320
|
/**
|
|
341
321
|
* The config's own top-level keys, readable bare: `${{ publish.directory }}`. So a value that
|
|
342
322
|
* restates another - `after: "cp README.md ${{ publish.directory }}/"` - stops being a second
|
|
@@ -369,7 +349,8 @@ export function interpolateConfig(config, scope, options) {
|
|
|
369
349
|
}
|
|
370
350
|
resolving.push(key);
|
|
371
351
|
try {
|
|
372
|
-
const
|
|
352
|
+
const chain = config[PREVIOUS_VALUES];
|
|
353
|
+
const value = walkWithPrevious(config[key], chain?.[key], scope, context, [...base, key], skip);
|
|
373
354
|
resolved.set(key, value);
|
|
374
355
|
return value;
|
|
375
356
|
}
|
|
@@ -409,11 +390,87 @@ export function interpolateConfig(config, scope, options) {
|
|
|
409
390
|
* command at all would fail on a config that mentions it.
|
|
410
391
|
*/
|
|
411
392
|
export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'];
|
|
393
|
+
/**
|
|
394
|
+
* Paths whose value is a **step** - something to run later - rather than a setting to compute now.
|
|
395
|
+
* `*` matches one path segment (`run.<script>.exec`).
|
|
396
|
+
*
|
|
397
|
+
* This is what tells a step function from a value function, and the two live side by side in one
|
|
398
|
+
* config:
|
|
399
|
+
*
|
|
400
|
+
* ```js
|
|
401
|
+
* '[ws:*]': {
|
|
402
|
+
* clean: { include: ({ vars, value }) => [...value, vars.buildDir] }, // a value: called here
|
|
403
|
+
* run: { build: { after: ({ pkg }) => copyDocs(pkg) } }, // a step: called by `run`
|
|
404
|
+
* }
|
|
405
|
+
* ```
|
|
406
|
+
*
|
|
407
|
+
* **The key decides, and it already did.** `run.build.exec: 'tsc -b'` is a shell command and
|
|
408
|
+
* `publish.directory: 'build'` is a path - not because of anything about the strings, but because of
|
|
409
|
+
* where they sit. A function inherits the same rule, so nothing new has to be learned and no marker
|
|
410
|
+
* has to be remembered. The alternative was inspecting the function (arity, parameter names), which
|
|
411
|
+
* is the kind of guess `loadPlugins` refuses to make about a module's export for the same reason:
|
|
412
|
+
* guessing wrong here means running build-time code while merely loading the repository, or
|
|
413
|
+
* silently never running it.
|
|
414
|
+
*
|
|
415
|
+
* A **string** at one of these paths is still interpolated - `exec: 'tsc -b ${{ file.resolve(...) }}'`
|
|
416
|
+
* has to keep working - so this is narrower than `DEFERRED_PATHS`, which skips its paths entirely.
|
|
417
|
+
*/
|
|
418
|
+
export const STEP_PATHS = [
|
|
419
|
+
/** The bare-value shorthand: `run: { build: fn }` means `{ exec: fn }`, as `run: { build: 'cmd' }`
|
|
420
|
+
* means `{ exec: 'cmd' }`. Missing it made the two spellings disagree about *when* the function
|
|
421
|
+
* runs, which is worse than not supporting the short one at all. */
|
|
422
|
+
'run.*',
|
|
423
|
+
'run.*.before',
|
|
424
|
+
'run.*.exec',
|
|
425
|
+
'run.*.after',
|
|
426
|
+
/** A condition, evaluated per package by `RunService` when the run reaches it. Called here
|
|
427
|
+
* instead, it collapsed to the boolean it happened to return at load time - and `parseIfExpr`
|
|
428
|
+
* then read that boolean as "no condition given", so the script ran unconditionally (measured). */
|
|
429
|
+
'run.*.if',
|
|
430
|
+
'version.before',
|
|
431
|
+
'version.exec',
|
|
432
|
+
'version.after',
|
|
433
|
+
];
|
|
434
|
+
/**
|
|
435
|
+
* Keys whose **whole subtree** is code rather than config, so no function under them is a value to
|
|
436
|
+
* compute. `plugins` is the only one, and it has to be here: an entry may be the plugin *object*
|
|
437
|
+
* itself, and an `RmanPlugin` is almost entirely functions - `manifest.read`, `workspace.resolve`,
|
|
438
|
+
* `versionPlanner`, `binPaths`, and every command's `builder` and `handler`.
|
|
439
|
+
*
|
|
440
|
+
* Measured, and it is why this exists: with `plugins` walked like any other key, resolving the
|
|
441
|
+
* config of a repository that named a plugin called that plugin's yargs builder with the config
|
|
442
|
+
* scope - `Config function in "plugins[0].commands[0].builder" failed: cmd.option is not a
|
|
443
|
+
* function`. A `plugins` entry is loaded by `loadPlugins`, never read as a setting.
|
|
444
|
+
*/
|
|
445
|
+
export const CODE_SUBTREES = ['plugins'];
|
|
412
446
|
const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
|
|
413
447
|
/** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
|
|
414
448
|
* `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
|
|
415
449
|
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
416
450
|
/** The `file` namespace for one package's directory - see `FileScope`. */
|
|
451
|
+
/**
|
|
452
|
+
* `read` in a `${{ ... }}` expression (and in a value function): a structured file's **contents**,
|
|
453
|
+
* parsed - where `file` answers only where a path is.
|
|
454
|
+
*
|
|
455
|
+
* ```yaml
|
|
456
|
+
* "[*]":
|
|
457
|
+
* run:
|
|
458
|
+
* build:
|
|
459
|
+
* exec: 'tsc --outDir ${{ read("tsconfig.json").compilerOptions.outDir }}'
|
|
460
|
+
* ```
|
|
461
|
+
*
|
|
462
|
+
* `cache` is shared across every package (see `Repository.configScope`) and keyed by what the file
|
|
463
|
+
* *is*, not merely where - so the same file read by twenty packages is parsed once, and a file rman
|
|
464
|
+
* itself rewrites mid-run is re-read rather than remembered. See `readStructuredFile`.
|
|
465
|
+
*/
|
|
466
|
+
export function createReadScope(dirname, cache) {
|
|
467
|
+
return (target, format) => {
|
|
468
|
+
if (typeof target !== 'string' || !target.trim()) {
|
|
469
|
+
throw new Error('read() needs a path - it was given ' + JSON.stringify(target));
|
|
470
|
+
}
|
|
471
|
+
return readStructuredFile(path.resolve(dirname, target), format, cache);
|
|
472
|
+
};
|
|
473
|
+
}
|
|
417
474
|
export function createFileScope(dirname) {
|
|
418
475
|
const locate = (target) => {
|
|
419
476
|
if (typeof target !== 'string' || !target.trim()) {
|
|
@@ -452,18 +509,236 @@ function walk(value, scope, context, at, skip) {
|
|
|
452
509
|
* command or an array of them - is handed on untouched. */
|
|
453
510
|
if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
|
|
454
511
|
return value;
|
|
512
|
+
if (typeof value === 'function') {
|
|
513
|
+
/** Code, not a value: a step for `run`/`version` to call in its own time, or a plugin's own
|
|
514
|
+
* function. Carried through exactly as a command string would be - calling it here would run
|
|
515
|
+
* build-time work while merely *loading* the repository, which is the whole distinction the
|
|
516
|
+
* function form exists to draw. */
|
|
517
|
+
if (isCodePath(at))
|
|
518
|
+
return value;
|
|
519
|
+
return callValueFn(value, context, at);
|
|
520
|
+
}
|
|
455
521
|
if (typeof value === 'string')
|
|
456
522
|
return interpolateString(value, context, at);
|
|
457
523
|
if (Array.isArray(value))
|
|
458
524
|
return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
|
|
459
525
|
if (value && typeof value === 'object') {
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
result
|
|
463
|
-
|
|
526
|
+
const chain = value[PREVIOUS_VALUES];
|
|
527
|
+
return withScopedVars(value, scope, context, at, skip, () => {
|
|
528
|
+
const result = {};
|
|
529
|
+
for (const [key, item] of Object.entries(value)) {
|
|
530
|
+
result[key] = walkWithPrevious(item, chain?.[key], scope, context, [...at, key], skip);
|
|
531
|
+
}
|
|
532
|
+
return result;
|
|
533
|
+
});
|
|
464
534
|
}
|
|
465
535
|
return value;
|
|
466
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* Runs `body` with `vars` scoped to this node: **a fresh copy at every level**, with the node's own
|
|
539
|
+
* `vars` block - if it declares one - merged over what the level above resolved to.
|
|
540
|
+
*
|
|
541
|
+
* ```yaml
|
|
542
|
+
* vars: { x: 1 }
|
|
543
|
+
* run:
|
|
544
|
+
* vars: { x: 2 }
|
|
545
|
+
* clean: { before: '${{ read(vars.x + ".json") }}' } # 2.json
|
|
546
|
+
* build:
|
|
547
|
+
* vars: { x: 3 }
|
|
548
|
+
* before: '${{ read(vars.x + ".json") }}' # 3.json
|
|
549
|
+
* ```
|
|
550
|
+
*
|
|
551
|
+
* **Copied at every node, not only where a `vars` block appears**, and that is the difference
|
|
552
|
+
* between scoping and leaking: a value function is handed this object, so one that writes to it
|
|
553
|
+
* (`vars.built = Date.now()`) must not be writing into the level above. Without a copy per node,
|
|
554
|
+
* a write inside `run.build` would land in `run`'s object and `run.clean` would see it. Merged per
|
|
555
|
+
* key rather than replaced, so redeclaring one var keeps the rest - the rule the top-level `vars`
|
|
556
|
+
* has always followed.
|
|
557
|
+
*
|
|
558
|
+
* The node's own block is resolved **against the outer scope** before being installed, so
|
|
559
|
+
* `vars: { out: '${{ vars.x }}/dist' }` reads the `x` it is refining rather than itself.
|
|
560
|
+
*
|
|
561
|
+
* Installed as a plain property over the context's lazy top-level getter and restored afterwards -
|
|
562
|
+
* `walk` is depth-first and synchronous, so the window is exactly this subtree, and a value function
|
|
563
|
+
* called inside it reads the same object through its prototype.
|
|
564
|
+
*/
|
|
565
|
+
function withScopedVars(node, scope, context, at, skip, body) {
|
|
566
|
+
/**
|
|
567
|
+
* **A `vars` block does not scope itself.** Resolving one walks its own values, and without this
|
|
568
|
+
* that walk asks for the scope it is in the middle of producing - which the cycle guard catches
|
|
569
|
+
* and reports as `vars -> vars`. It recovered (the guard returns `undefined`, so the block simply
|
|
570
|
+
* saw no outer scope, which is what it should see anyway), but it left the cycle *flag* set, and
|
|
571
|
+
* the next genuine error in that key came out wearing `Config expression forms a cycle` - found by
|
|
572
|
+
* running a real shared config, whose `[...value]` mistake arrived with a loop attached that had
|
|
573
|
+
* nothing to do with it.
|
|
574
|
+
*
|
|
575
|
+
* Any path with a `vars` segment is inside a block: its contents are values, not config nodes.
|
|
576
|
+
*/
|
|
577
|
+
if (at.some(segment => segment === VARS_KEY))
|
|
578
|
+
return body();
|
|
579
|
+
const outer = context[VARS_KEY];
|
|
580
|
+
const own = node[VARS_KEY];
|
|
581
|
+
/** Nothing to shadow and nothing to protect: a node with no object below it can hold no function
|
|
582
|
+
* either, so the copy would be pure cost. */
|
|
583
|
+
if (own === undefined && !hasObjectChild(node))
|
|
584
|
+
return body();
|
|
585
|
+
const resolvedOwn = own === undefined ? undefined : walk(own, scope, context, [...at, VARS_KEY], skip);
|
|
586
|
+
const scoped = { ...outer, ...(isPlainObject(resolvedOwn) ? resolvedOwn : undefined) };
|
|
587
|
+
const previous = Object.getOwnPropertyDescriptor(context, VARS_KEY);
|
|
588
|
+
Object.defineProperty(context, VARS_KEY, { value: scoped, enumerable: true, configurable: true, writable: true });
|
|
589
|
+
try {
|
|
590
|
+
return body();
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
if (previous)
|
|
594
|
+
Object.defineProperty(context, VARS_KEY, previous);
|
|
595
|
+
else
|
|
596
|
+
delete context[VARS_KEY];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function hasObjectChild(node) {
|
|
600
|
+
for (const item of Object.values(node)) {
|
|
601
|
+
if (typeof item === 'function')
|
|
602
|
+
return true;
|
|
603
|
+
if (item && typeof item === 'object')
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
function isPlainObject(value) {
|
|
609
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
610
|
+
}
|
|
611
|
+
/** The one key that scopes rather than configures - see `withScopedVars`. Reserved at **every**
|
|
612
|
+
* level, which costs a script that would have been called `vars`: `run.vars` is a scope, not a
|
|
613
|
+
* script. Nothing enumerates `run`'s keys as a list of script names, so the cost stops there. */
|
|
614
|
+
const VARS_KEY = 'vars';
|
|
615
|
+
/**
|
|
616
|
+
* Walks one key of an object with **`value` bound** to whatever the layers below it resolved to.
|
|
617
|
+
*
|
|
618
|
+
* Bound on the interpolation context rather than passed as an argument, because an expression reads
|
|
619
|
+
* it as a global (`"${{ [...value, 'x'] }}"`) - a function then picks the same binding up through
|
|
620
|
+
* its prototype, so the two spellings cannot disagree about what `value` is. It was function-only
|
|
621
|
+
* at first, on the reasoning that a string cannot carry an array back; that was wrong, since a
|
|
622
|
+
* string which is *nothing but* one expression keeps the value's own type.
|
|
623
|
+
*
|
|
624
|
+
* **Always bound, even with nothing underneath.** Left unbound, an expression naming it fails with
|
|
625
|
+
* V8's `value is not defined`, which reads as "there is no such thing" rather than "nothing below
|
|
626
|
+
* this layer set it" - two different mistakes needing two different fixes.
|
|
627
|
+
*
|
|
628
|
+
* The chain resolves bottom-up, so a layer deriving from a layer that itself derived from something
|
|
629
|
+
* is handed the finished value rather than a half-resolved expression.
|
|
630
|
+
*/
|
|
631
|
+
function walkWithPrevious(item, previous, scope, context, at, skip) {
|
|
632
|
+
const resolved = previous === undefined ? undefined : walkWithPrevious(previous.value, previous.previous, scope, context, at, skip);
|
|
633
|
+
const outer = Object.getOwnPropertyDescriptor(context, VALUE_KEY);
|
|
634
|
+
/** A getter, so the catch below can tell whether the value **actually read `value`**: the hint is
|
|
635
|
+
* irrelevant to any other failure, and attaching it anyway is the send-the-reader-to-the-wrong-
|
|
636
|
+
* place mistake it exists to prevent. Recorded, never matched on V8's wording. */
|
|
637
|
+
let wasRead = false;
|
|
638
|
+
Object.defineProperty(context, VALUE_KEY, {
|
|
639
|
+
configurable: true,
|
|
640
|
+
enumerable: true,
|
|
641
|
+
get: () => {
|
|
642
|
+
wasRead = true;
|
|
643
|
+
return resolved;
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
try {
|
|
647
|
+
return walk(item, scope, context, at, skip);
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
/**
|
|
651
|
+
* **`value` is `undefined` when no layer underneath set this key**, and a value written to
|
|
652
|
+
* extend an inherited list is also the *first* layer in a repository that inherits nothing.
|
|
653
|
+
* V8 reports that as `value is not iterable`, naming neither the key nor the reason.
|
|
654
|
+
*
|
|
655
|
+
* Here rather than in `callValueFn`, so the expression and the function spelling get the same
|
|
656
|
+
* sentence from the same place. `rmanValueHint` keeps a rethrow from stacking it twice as the
|
|
657
|
+
* error passes back up through the enclosing keys.
|
|
658
|
+
*
|
|
659
|
+
* Not papered over by defaulting `value` to `[]`: that would be a guess about the key's type,
|
|
660
|
+
* and wrong for every key that is not a list.
|
|
661
|
+
*/
|
|
662
|
+
if (wasRead && resolved === undefined && !e?.rmanValueHint) {
|
|
663
|
+
const where = at.length ? formatPath(at) : 'the config root';
|
|
664
|
+
e.rmanValueHint = true;
|
|
665
|
+
e.message =
|
|
666
|
+
`${e.message}\n \`value\` is undefined here - nothing below this layer sets "${where}".` +
|
|
667
|
+
`\n Write \`value ?? []\` (or \`?? ''\`) if it has to work as the first layer too.`;
|
|
668
|
+
}
|
|
669
|
+
throw e;
|
|
670
|
+
}
|
|
671
|
+
finally {
|
|
672
|
+
if (outer)
|
|
673
|
+
Object.defineProperty(context, VALUE_KEY, outer);
|
|
674
|
+
else
|
|
675
|
+
delete context[VALUE_KEY];
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/** What a layer deriving from the one below it reads - see `walkWithPrevious`. */
|
|
679
|
+
const VALUE_KEY = 'value';
|
|
680
|
+
/**
|
|
681
|
+
* Whether a function at `at` is **code** - a step to run later, or part of a plugin - rather than a
|
|
682
|
+
* value to compute now.
|
|
683
|
+
*
|
|
684
|
+
* Array indices are dropped before matching, so a function inside a *list* of steps is still a
|
|
685
|
+
* step; `*` in a `STEP_PATHS` entry matches any one segment (`run.<script>.exec`).
|
|
686
|
+
*/
|
|
687
|
+
function isCodePath(at) {
|
|
688
|
+
const segments = at.filter((p) => typeof p === 'string');
|
|
689
|
+
if (CODE_SUBTREES.includes(segments[0]))
|
|
690
|
+
return true;
|
|
691
|
+
return STEP_PATHS.some(pattern => {
|
|
692
|
+
const parts = pattern.split('.');
|
|
693
|
+
return parts.length === segments.length && parts.every((part, i) => part === '*' || part === segments[i]);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Calls a **value** function: the JS spelling of a `${{ }}` expression, and it answers the same
|
|
698
|
+
* question at the same moment.
|
|
699
|
+
*
|
|
700
|
+
* It receives one object carrying everything an expression can name - `pkg`, `repository`, `file`,
|
|
701
|
+
* `env`, `semver`, `path`, plus the config's own top-level keys - and, in addition, **`value`**: what
|
|
702
|
+
* this key resolved to in the layers underneath, which is what makes a derived value possible
|
|
703
|
+
* without restating the base.
|
|
704
|
+
*
|
|
705
|
+
* Built with the interpolation context as its **prototype**, not copied from it. The top-level keys
|
|
706
|
+
* are lazy getters (`resolve`, memoized, so key order in the file means nothing and a cycle is
|
|
707
|
+
* reported rather than half-resolved); spreading them into a new object would fire every one of
|
|
708
|
+
* them on every call, including the ones a function never reads - and one of those throwing would
|
|
709
|
+
* blame the wrong key.
|
|
710
|
+
*
|
|
711
|
+
* **It must compute and return, never act.** This runs while the repository's config resolves,
|
|
712
|
+
* which *every* command does - so a value function that writes a file writes it on `rman list`,
|
|
713
|
+
* `rman info` and `rman config` too, N times for N packages, with no command having asked for
|
|
714
|
+
* anything. That is the same reason `FileScope` offers no way to change anything. Work goes in a
|
|
715
|
+
* step, which is the one thing rman runs on purpose and which can also be a function.
|
|
716
|
+
*/
|
|
717
|
+
function callValueFn(fn, context, at) {
|
|
718
|
+
/** `value` arrives through the prototype, bound by `walkWithPrevious` for exactly this key - so
|
|
719
|
+
* nothing here may *read* it. Passing it in as an argument did, which tripped the "was it read"
|
|
720
|
+
* getter before the function ran and put the `value` hint on every unrelated failure (caught by
|
|
721
|
+
* the spec that exists for precisely that). */
|
|
722
|
+
const arg = Object.create(context);
|
|
723
|
+
/**
|
|
724
|
+
* A getter only so the catch below can tell whether the function **actually read `value`**.
|
|
725
|
+
*
|
|
726
|
+
* Without that, the "value is undefined" hint went out with *every* failure of a first-layer
|
|
727
|
+
* function - a frozen-object `TypeError` from `read()` arrived wearing advice about spreading an
|
|
728
|
+
* inherited list, which is precisely the send-the-reader-to-the-wrong-place mistake the hint
|
|
729
|
+
* exists to prevent. Recorded rather than inferred from the message, because matching on V8's
|
|
730
|
+
* wording is the other way to get this wrong.
|
|
731
|
+
*/
|
|
732
|
+
try {
|
|
733
|
+
return fn(arg);
|
|
734
|
+
}
|
|
735
|
+
catch (e) {
|
|
736
|
+
const where = at.length ? formatPath(at) : 'the config root';
|
|
737
|
+
/** The `value` hint comes from `walkWithPrevious`, which wraps this call and is the one place
|
|
738
|
+
* that knows whether `value` was read - so the expression spelling gets the same sentence. */
|
|
739
|
+
throw new Error(`Config function in "${where}" failed: ${e?.message}`, { cause: e });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
467
742
|
function interpolateString(value, context, at) {
|
|
468
743
|
if (!value.includes('${{'))
|
|
469
744
|
return value;
|
|
@@ -507,3 +782,129 @@ function formatPath(at) {
|
|
|
507
782
|
/** Guards against an expression that never returns (`while(true)`) taking the whole command with
|
|
508
783
|
* it - a typo, not an attack, but the failure mode is identical. */
|
|
509
784
|
const EXPRESSION_TIMEOUT = 1000;
|
|
785
|
+
/**
|
|
786
|
+
* Reads and parses one structured file, memoized against **the identity of its contents** rather
|
|
787
|
+
* than its path alone: the cache key is `mtimeNs:size`.
|
|
788
|
+
*
|
|
789
|
+
* Both halves of that were chosen against a measurement.
|
|
790
|
+
*
|
|
791
|
+
* - **A stat rather than a re-read**: `statSync` is 1.3µs where `readFileSync` + `JSON.parse` is
|
|
792
|
+
* 16.1µs on a 2KB manifest - so the check costs a thirteenth of what it saves, and the same file
|
|
793
|
+
* read by twenty packages is parsed once. (`interpolateConfig` runs once *per package*, so a
|
|
794
|
+
* cache living in one pass would not have helped across them at all.)
|
|
795
|
+
* - **Keyed on the stat rather than held for the run**: rman writes JSON files while it is running
|
|
796
|
+
* - `version` rewrites every bumped manifest, then re-interpolates its own deferred hooks. A
|
|
797
|
+
* cache that only remembered the path would hand those back as they were before the write.
|
|
798
|
+
* `mtimeNs` is nanoseconds, so a rewrite within the same millisecond does not slip through; the
|
|
799
|
+
* size is in the key as well because it costs nothing.
|
|
800
|
+
*
|
|
801
|
+
* **Frozen, deeply, once on the way into the cache.** Every package is handed the same object, so
|
|
802
|
+
* one config mutating it would quietly change what the next package sees - the reason `pkg.manifest`
|
|
803
|
+
* has always been a copy. Freezing is better than copying here: a copy costs 5.6µs on *every* call,
|
|
804
|
+
* freezing costs ~1µs *once*, and it turns the mistake into a `TypeError` instead of an effect at a
|
|
805
|
+
* distance. A caller that wants to change something spreads it first.
|
|
806
|
+
*/
|
|
807
|
+
function readStructuredFile(file, format, cache) {
|
|
808
|
+
let stat;
|
|
809
|
+
try {
|
|
810
|
+
stat = fs.statSync(file, { bigint: true });
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
throw new Error(`read("${path.basename(file)}") found nothing at ${file}\n` +
|
|
814
|
+
` Use file.exists() first if its absence is a case to handle rather than a mistake.`);
|
|
815
|
+
}
|
|
816
|
+
if (stat.isDirectory())
|
|
817
|
+
throw new Error(`read() was given a directory, not a file: ${file}`);
|
|
818
|
+
const stamp = `${stat.mtimeNs}:${stat.size}`;
|
|
819
|
+
const cached = cache.get(file);
|
|
820
|
+
if (cached?.stamp === stamp)
|
|
821
|
+
return cached.value;
|
|
822
|
+
const resolved = format ?? formatOf(file);
|
|
823
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
824
|
+
let value;
|
|
825
|
+
try {
|
|
826
|
+
value = parseStructured(text, resolved);
|
|
827
|
+
}
|
|
828
|
+
catch (e) {
|
|
829
|
+
/** The parser's own message says what is wrong with the syntax but never which file it was
|
|
830
|
+
* reading - and an expression can name several. */
|
|
831
|
+
throw new Error(`read("${path.basename(file)}") could not parse ${file} as ${resolved}: ${e?.message}`, {
|
|
832
|
+
cause: e,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
deepFreeze(value);
|
|
836
|
+
cache.set(file, { stamp, value });
|
|
837
|
+
return value;
|
|
838
|
+
}
|
|
839
|
+
/** The extension decides, because the caller already wrote it - naming the parser as well would
|
|
840
|
+
* restate it and let the two disagree (`json("x.yml")`). A name that says nothing takes the
|
|
841
|
+
* explicit argument instead. */
|
|
842
|
+
function formatOf(file) {
|
|
843
|
+
const ext = path.extname(file).toLowerCase();
|
|
844
|
+
if (ext === '.json')
|
|
845
|
+
return 'json';
|
|
846
|
+
if (ext === '.yml' || ext === '.yaml')
|
|
847
|
+
return 'yaml';
|
|
848
|
+
if (ext === '.ini')
|
|
849
|
+
return 'ini';
|
|
850
|
+
if (XML_EXTENSIONS.has(ext))
|
|
851
|
+
return 'xml';
|
|
852
|
+
throw new Error(`read() cannot tell what "${path.basename(file)}" is from its name.\n` +
|
|
853
|
+
` Name the format: read("${path.basename(file)}", "json" | "yaml" | "ini" | "xml").`);
|
|
854
|
+
}
|
|
855
|
+
/** The XML family worth recognizing by name: a project file is XML whatever its extension calls
|
|
856
|
+
* itself, and `.csproj`/`.pom` are what a .NET or Maven repository actually holds. Anything else
|
|
857
|
+
* still reads with an explicit `read(p, 'xml')`. */
|
|
858
|
+
const XML_EXTENSIONS = new Set(['.xml', '.csproj', '.vbproj', '.fsproj', '.props', '.targets', '.nuspec', '.plist']);
|
|
859
|
+
function parseStructured(text, format) {
|
|
860
|
+
if (format === 'json')
|
|
861
|
+
return JSON.parse(text);
|
|
862
|
+
/** `load`, not `loadAll`: a multi-document stream has no single value to be, and js-yaml says so
|
|
863
|
+
* clearly enough ("expected a single document in the stream") to leave alone. */
|
|
864
|
+
if (format === 'yaml')
|
|
865
|
+
return yaml.load(text);
|
|
866
|
+
if (format === 'xml')
|
|
867
|
+
return parseXml(text);
|
|
868
|
+
return ini.parse(text);
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* A **DOM**, not an object - and the asymmetry with the other three formats is the honest shape
|
|
872
|
+
* rather than an omission.
|
|
873
|
+
*
|
|
874
|
+
* XML has no lossless object form: an element can repeat, carry attributes and hold text at the
|
|
875
|
+
* same time, so any flattening has to pick a convention (`$`? `_text`? array-or-not?) and be wrong
|
|
876
|
+
* for somebody. A DOM is the shape XML actually has, so a config reads it the way every other XML
|
|
877
|
+
* tool does:
|
|
878
|
+
*
|
|
879
|
+
* ```yaml
|
|
880
|
+
* version: '${{ read("pom.xml").getElementsByTagName("version")[0].textContent }}'
|
|
881
|
+
* ```
|
|
882
|
+
*
|
|
883
|
+
* **Freezing it is safe** - measured, not assumed: a frozen `@xmldom/xmldom` document still answers
|
|
884
|
+
* `getElementsByTagName` for a tag first asked about *after* the freeze (the live-collection case
|
|
885
|
+
* that would have broken it), reads attributes, resolves namespaces, walks `childNodes` and
|
|
886
|
+
* serialises back.
|
|
887
|
+
*/
|
|
888
|
+
function parseXml(text) {
|
|
889
|
+
/** xmldom reports a malformed document through a handler and otherwise carries on with whatever
|
|
890
|
+
* it could salvage - so without this, a broken file would come back as a half-parsed DOM and the
|
|
891
|
+
* expression reading it would simply find nothing. `read()` throws for a broken JSON file; it has
|
|
892
|
+
* to throw for this one too. */
|
|
893
|
+
const problems = [];
|
|
894
|
+
const doc = new DOMParser({
|
|
895
|
+
onError: (level, message) => {
|
|
896
|
+
if (level !== 'warning')
|
|
897
|
+
problems.push(message.split('\n')[0]);
|
|
898
|
+
},
|
|
899
|
+
}).parseFromString(text, 'text/xml');
|
|
900
|
+
if (problems.length)
|
|
901
|
+
throw new Error(problems[0]);
|
|
902
|
+
return doc;
|
|
903
|
+
}
|
|
904
|
+
function deepFreeze(value) {
|
|
905
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value))
|
|
906
|
+
return;
|
|
907
|
+
Object.freeze(value);
|
|
908
|
+
for (const item of Object.values(value))
|
|
909
|
+
deepFreeze(item);
|
|
910
|
+
}
|
package/core/merge-config.d.ts
CHANGED
|
@@ -14,6 +14,30 @@ export declare const APPEND_PREFIX = "+";
|
|
|
14
14
|
* layer, which is only acceptable where the value is a set of contributions rather than a decision.
|
|
15
15
|
*/
|
|
16
16
|
export declare const ALWAYS_APPEND: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Where a key keeps what it is replacing, so the replacement can be handed it back as `value`.
|
|
19
|
+
*
|
|
20
|
+
* A **symbol-keyed chain on the containing object**, one entry per key, rather than something
|
|
21
|
+
* attached to the value itself. It started as a wrapper around a *function*, which is the only kind
|
|
22
|
+
* of value you can hang a property on - and that is exactly why it had to change: `value` belongs to
|
|
23
|
+
* an expression (`"${{ [...value, 'x'] }}"`) just as much as to a function, and a string cannot
|
|
24
|
+
* carry one.
|
|
25
|
+
*
|
|
26
|
+
* A symbol is invisible to `Object.entries`, `JSON.stringify` and js-yaml, so the chain travels
|
|
27
|
+
* through `mergeConfig`, `finalizeConfig` and `rman config` without any of them having to know it
|
|
28
|
+
* is there.
|
|
29
|
+
*
|
|
30
|
+
* Each entry is a link, not a single slot: three layers each deriving from the one below need
|
|
31
|
+
* `A <- expr2 <- expr3`, and one slot would have lost `A` the moment `expr3` arrived.
|
|
32
|
+
*/
|
|
33
|
+
export declare const PREVIOUS_VALUES: unique symbol;
|
|
34
|
+
/** One link: the raw value this key held, and whatever *it* was derived from. */
|
|
35
|
+
export interface PreviousValue {
|
|
36
|
+
value: unknown;
|
|
37
|
+
previous?: PreviousValue;
|
|
38
|
+
}
|
|
39
|
+
/** Only these two can ask for `value`, so only these two are worth remembering a previous for. */
|
|
40
|
+
export declare function carriesPreviousValue(value: unknown): boolean;
|
|
17
41
|
/** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
|
|
18
42
|
export declare function appendTarget(key: string): string | undefined;
|
|
19
43
|
/**
|
package/core/merge-config.js
CHANGED
|
@@ -14,6 +14,27 @@ export const APPEND_PREFIX = '+';
|
|
|
14
14
|
* layer, which is only acceptable where the value is a set of contributions rather than a decision.
|
|
15
15
|
*/
|
|
16
16
|
export const ALWAYS_APPEND = ['plugins'];
|
|
17
|
+
/**
|
|
18
|
+
* Where a key keeps what it is replacing, so the replacement can be handed it back as `value`.
|
|
19
|
+
*
|
|
20
|
+
* A **symbol-keyed chain on the containing object**, one entry per key, rather than something
|
|
21
|
+
* attached to the value itself. It started as a wrapper around a *function*, which is the only kind
|
|
22
|
+
* of value you can hang a property on - and that is exactly why it had to change: `value` belongs to
|
|
23
|
+
* an expression (`"${{ [...value, 'x'] }}"`) just as much as to a function, and a string cannot
|
|
24
|
+
* carry one.
|
|
25
|
+
*
|
|
26
|
+
* A symbol is invisible to `Object.entries`, `JSON.stringify` and js-yaml, so the chain travels
|
|
27
|
+
* through `mergeConfig`, `finalizeConfig` and `rman config` without any of them having to know it
|
|
28
|
+
* is there.
|
|
29
|
+
*
|
|
30
|
+
* Each entry is a link, not a single slot: three layers each deriving from the one below need
|
|
31
|
+
* `A <- expr2 <- expr3`, and one slot would have lost `A` the moment `expr3` arrived.
|
|
32
|
+
*/
|
|
33
|
+
export const PREVIOUS_VALUES = Symbol('rman.previousValues');
|
|
34
|
+
/** Only these two can ask for `value`, so only these two are worth remembering a previous for. */
|
|
35
|
+
export function carriesPreviousValue(value) {
|
|
36
|
+
return typeof value === 'function' || (typeof value === 'string' && value.includes('${{'));
|
|
37
|
+
}
|
|
17
38
|
/** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
|
|
18
39
|
export function appendTarget(key) {
|
|
19
40
|
return key.length > APPEND_PREFIX.length && key.startsWith(APPEND_PREFIX)
|
|
@@ -108,6 +129,11 @@ export function finalizeConfig(config) {
|
|
|
108
129
|
const pending = finalizeConfig(value);
|
|
109
130
|
result[plain] = plain in result ? [...toList(result[plain]), ...toList(pending)] : toList(pending);
|
|
110
131
|
}
|
|
132
|
+
/** Carried across by hand: this rebuilds the object from `Object.entries`, which does not see a
|
|
133
|
+
* symbol - and dropping it here would lose every `value` chain the merge just recorded. */
|
|
134
|
+
const chain = config[PREVIOUS_VALUES];
|
|
135
|
+
if (chain)
|
|
136
|
+
Object.defineProperty(result, PREVIOUS_VALUES, { value: chain });
|
|
111
137
|
return result;
|
|
112
138
|
}
|
|
113
139
|
function assignMerged(target, key, value) {
|
|
@@ -117,6 +143,23 @@ function assignMerged(target, key, value) {
|
|
|
117
143
|
mergeConfig(target[key], value);
|
|
118
144
|
return;
|
|
119
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* A value that can ask for `value` **replaces** like any other - and remembers what it replaced:
|
|
148
|
+
*
|
|
149
|
+
* ```js
|
|
150
|
+
* '[*]': { clean: { include: ({ vars }) => [vars.buildDir] } }
|
|
151
|
+
* '[ws:*]': { clean: { include: "${{ [...value, pkg.basename + '.log'] }}" } }
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* Chained here rather than at resolution time because only the merge knows the order of the
|
|
155
|
+
* layers - by the time `interpolateConfig` sees the config they have collapsed into one object,
|
|
156
|
+
* and whatever a closer layer said has already taken the place of what it was derived from.
|
|
157
|
+
*/
|
|
158
|
+
if (carriesPreviousValue(value) && key in target) {
|
|
159
|
+
const carrier = target;
|
|
160
|
+
const chain = (carrier[PREVIOUS_VALUES] ??= {});
|
|
161
|
+
chain[key] = { value: target[key], previous: chain[key] };
|
|
162
|
+
}
|
|
120
163
|
target[key] = Array.isArray(value) ? [...value] : value;
|
|
121
164
|
}
|
|
122
165
|
/**
|