rman 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +402 -50
- package/core/merge-config.d.ts +13 -0
- package/core/merge-config.js +47 -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_VALUE } 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,7 @@ export function interpolateConfig(config, scope, options) {
|
|
|
369
349
|
}
|
|
370
350
|
resolving.push(key);
|
|
371
351
|
try {
|
|
372
|
-
const value = walk(config[key], scope, context, [key], skip);
|
|
352
|
+
const value = walk(config[key], scope, context, [...base, key], skip);
|
|
373
353
|
resolved.set(key, value);
|
|
374
354
|
return value;
|
|
375
355
|
}
|
|
@@ -409,11 +389,87 @@ export function interpolateConfig(config, scope, options) {
|
|
|
409
389
|
* command at all would fail on a config that mentions it.
|
|
410
390
|
*/
|
|
411
391
|
export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'];
|
|
392
|
+
/**
|
|
393
|
+
* Paths whose value is a **step** - something to run later - rather than a setting to compute now.
|
|
394
|
+
* `*` matches one path segment (`run.<script>.exec`).
|
|
395
|
+
*
|
|
396
|
+
* This is what tells a step function from a value function, and the two live side by side in one
|
|
397
|
+
* config:
|
|
398
|
+
*
|
|
399
|
+
* ```js
|
|
400
|
+
* '[ws:*]': {
|
|
401
|
+
* clean: { include: ({ vars, value }) => [...value, vars.buildDir] }, // a value: called here
|
|
402
|
+
* run: { build: { after: ({ pkg }) => copyDocs(pkg) } }, // a step: called by `run`
|
|
403
|
+
* }
|
|
404
|
+
* ```
|
|
405
|
+
*
|
|
406
|
+
* **The key decides, and it already did.** `run.build.exec: 'tsc -b'` is a shell command and
|
|
407
|
+
* `publish.directory: 'build'` is a path - not because of anything about the strings, but because of
|
|
408
|
+
* where they sit. A function inherits the same rule, so nothing new has to be learned and no marker
|
|
409
|
+
* has to be remembered. The alternative was inspecting the function (arity, parameter names), which
|
|
410
|
+
* is the kind of guess `loadPlugins` refuses to make about a module's export for the same reason:
|
|
411
|
+
* guessing wrong here means running build-time code while merely loading the repository, or
|
|
412
|
+
* silently never running it.
|
|
413
|
+
*
|
|
414
|
+
* A **string** at one of these paths is still interpolated - `exec: 'tsc -b ${{ file.resolve(...) }}'`
|
|
415
|
+
* has to keep working - so this is narrower than `DEFERRED_PATHS`, which skips its paths entirely.
|
|
416
|
+
*/
|
|
417
|
+
export const STEP_PATHS = [
|
|
418
|
+
/** The bare-value shorthand: `run: { build: fn }` means `{ exec: fn }`, as `run: { build: 'cmd' }`
|
|
419
|
+
* means `{ exec: 'cmd' }`. Missing it made the two spellings disagree about *when* the function
|
|
420
|
+
* runs, which is worse than not supporting the short one at all. */
|
|
421
|
+
'run.*',
|
|
422
|
+
'run.*.before',
|
|
423
|
+
'run.*.exec',
|
|
424
|
+
'run.*.after',
|
|
425
|
+
/** A condition, evaluated per package by `RunService` when the run reaches it. Called here
|
|
426
|
+
* instead, it collapsed to the boolean it happened to return at load time - and `parseIfExpr`
|
|
427
|
+
* then read that boolean as "no condition given", so the script ran unconditionally (measured). */
|
|
428
|
+
'run.*.if',
|
|
429
|
+
'version.before',
|
|
430
|
+
'version.exec',
|
|
431
|
+
'version.after',
|
|
432
|
+
];
|
|
433
|
+
/**
|
|
434
|
+
* Keys whose **whole subtree** is code rather than config, so no function under them is a value to
|
|
435
|
+
* compute. `plugins` is the only one, and it has to be here: an entry may be the plugin *object*
|
|
436
|
+
* itself, and an `RmanPlugin` is almost entirely functions - `manifest.read`, `workspace.resolve`,
|
|
437
|
+
* `versionPlanner`, `binPaths`, and every command's `builder` and `handler`.
|
|
438
|
+
*
|
|
439
|
+
* Measured, and it is why this exists: with `plugins` walked like any other key, resolving the
|
|
440
|
+
* config of a repository that named a plugin called that plugin's yargs builder with the config
|
|
441
|
+
* scope - `Config function in "plugins[0].commands[0].builder" failed: cmd.option is not a
|
|
442
|
+
* function`. A `plugins` entry is loaded by `loadPlugins`, never read as a setting.
|
|
443
|
+
*/
|
|
444
|
+
export const CODE_SUBTREES = ['plugins'];
|
|
412
445
|
const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
|
|
413
446
|
/** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
|
|
414
447
|
* `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
|
|
415
448
|
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
416
449
|
/** The `file` namespace for one package's directory - see `FileScope`. */
|
|
450
|
+
/**
|
|
451
|
+
* `read` in a `${{ ... }}` expression (and in a value function): a structured file's **contents**,
|
|
452
|
+
* parsed - where `file` answers only where a path is.
|
|
453
|
+
*
|
|
454
|
+
* ```yaml
|
|
455
|
+
* "[*]":
|
|
456
|
+
* run:
|
|
457
|
+
* build:
|
|
458
|
+
* exec: 'tsc --outDir ${{ read("tsconfig.json").compilerOptions.outDir }}'
|
|
459
|
+
* ```
|
|
460
|
+
*
|
|
461
|
+
* `cache` is shared across every package (see `Repository.configScope`) and keyed by what the file
|
|
462
|
+
* *is*, not merely where - so the same file read by twenty packages is parsed once, and a file rman
|
|
463
|
+
* itself rewrites mid-run is re-read rather than remembered. See `readStructuredFile`.
|
|
464
|
+
*/
|
|
465
|
+
export function createReadScope(dirname, cache) {
|
|
466
|
+
return (target, format) => {
|
|
467
|
+
if (typeof target !== 'string' || !target.trim()) {
|
|
468
|
+
throw new Error('read() needs a path - it was given ' + JSON.stringify(target));
|
|
469
|
+
}
|
|
470
|
+
return readStructuredFile(path.resolve(dirname, target), format, cache);
|
|
471
|
+
};
|
|
472
|
+
}
|
|
417
473
|
export function createFileScope(dirname) {
|
|
418
474
|
const locate = (target) => {
|
|
419
475
|
if (typeof target !== 'string' || !target.trim()) {
|
|
@@ -452,18 +508,188 @@ function walk(value, scope, context, at, skip) {
|
|
|
452
508
|
* command or an array of them - is handed on untouched. */
|
|
453
509
|
if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
|
|
454
510
|
return value;
|
|
511
|
+
if (typeof value === 'function') {
|
|
512
|
+
/** Code, not a value: a step for `run`/`version` to call in its own time, or a plugin's own
|
|
513
|
+
* function. Carried through exactly as a command string would be - calling it here would run
|
|
514
|
+
* build-time work while merely *loading* the repository, which is the whole distinction the
|
|
515
|
+
* function form exists to draw. */
|
|
516
|
+
if (isCodePath(at))
|
|
517
|
+
return value;
|
|
518
|
+
return callValueFn(value, scope, context, at, skip);
|
|
519
|
+
}
|
|
455
520
|
if (typeof value === 'string')
|
|
456
521
|
return interpolateString(value, context, at);
|
|
457
522
|
if (Array.isArray(value))
|
|
458
523
|
return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
|
|
459
524
|
if (value && typeof value === 'object') {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
525
|
+
return withScopedVars(value, scope, context, at, skip, () => {
|
|
526
|
+
const result = {};
|
|
527
|
+
for (const [key, item] of Object.entries(value))
|
|
528
|
+
result[key] = walk(item, scope, context, [...at, key], skip);
|
|
529
|
+
return result;
|
|
530
|
+
});
|
|
464
531
|
}
|
|
465
532
|
return value;
|
|
466
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Runs `body` with `vars` scoped to this node: **a fresh copy at every level**, with the node's own
|
|
536
|
+
* `vars` block - if it declares one - merged over what the level above resolved to.
|
|
537
|
+
*
|
|
538
|
+
* ```yaml
|
|
539
|
+
* vars: { x: 1 }
|
|
540
|
+
* run:
|
|
541
|
+
* vars: { x: 2 }
|
|
542
|
+
* clean: { before: '${{ read(vars.x + ".json") }}' } # 2.json
|
|
543
|
+
* build:
|
|
544
|
+
* vars: { x: 3 }
|
|
545
|
+
* before: '${{ read(vars.x + ".json") }}' # 3.json
|
|
546
|
+
* ```
|
|
547
|
+
*
|
|
548
|
+
* **Copied at every node, not only where a `vars` block appears**, and that is the difference
|
|
549
|
+
* between scoping and leaking: a value function is handed this object, so one that writes to it
|
|
550
|
+
* (`vars.built = Date.now()`) must not be writing into the level above. Without a copy per node,
|
|
551
|
+
* a write inside `run.build` would land in `run`'s object and `run.clean` would see it. Merged per
|
|
552
|
+
* key rather than replaced, so redeclaring one var keeps the rest - the rule the top-level `vars`
|
|
553
|
+
* has always followed.
|
|
554
|
+
*
|
|
555
|
+
* The node's own block is resolved **against the outer scope** before being installed, so
|
|
556
|
+
* `vars: { out: '${{ vars.x }}/dist' }` reads the `x` it is refining rather than itself.
|
|
557
|
+
*
|
|
558
|
+
* Installed as a plain property over the context's lazy top-level getter and restored afterwards -
|
|
559
|
+
* `walk` is depth-first and synchronous, so the window is exactly this subtree, and a value function
|
|
560
|
+
* called inside it reads the same object through its prototype.
|
|
561
|
+
*/
|
|
562
|
+
function withScopedVars(node, scope, context, at, skip, body) {
|
|
563
|
+
/**
|
|
564
|
+
* **A `vars` block does not scope itself.** Resolving one walks its own values, and without this
|
|
565
|
+
* that walk asks for the scope it is in the middle of producing - which the cycle guard catches
|
|
566
|
+
* and reports as `vars -> vars`. It recovered (the guard returns `undefined`, so the block simply
|
|
567
|
+
* saw no outer scope, which is what it should see anyway), but it left the cycle *flag* set, and
|
|
568
|
+
* the next genuine error in that key came out wearing `Config expression forms a cycle` - found by
|
|
569
|
+
* running a real shared config, whose `[...value]` mistake arrived with a loop attached that had
|
|
570
|
+
* nothing to do with it.
|
|
571
|
+
*
|
|
572
|
+
* Any path with a `vars` segment is inside a block: its contents are values, not config nodes.
|
|
573
|
+
*/
|
|
574
|
+
if (at.some(segment => segment === VARS_KEY))
|
|
575
|
+
return body();
|
|
576
|
+
const outer = context[VARS_KEY];
|
|
577
|
+
const own = node[VARS_KEY];
|
|
578
|
+
/** Nothing to shadow and nothing to protect: a node with no object below it can hold no function
|
|
579
|
+
* either, so the copy would be pure cost. */
|
|
580
|
+
if (own === undefined && !hasObjectChild(node))
|
|
581
|
+
return body();
|
|
582
|
+
const resolvedOwn = own === undefined ? undefined : walk(own, scope, context, [...at, VARS_KEY], skip);
|
|
583
|
+
const scoped = { ...outer, ...(isPlainObject(resolvedOwn) ? resolvedOwn : undefined) };
|
|
584
|
+
const previous = Object.getOwnPropertyDescriptor(context, VARS_KEY);
|
|
585
|
+
Object.defineProperty(context, VARS_KEY, { value: scoped, enumerable: true, configurable: true, writable: true });
|
|
586
|
+
try {
|
|
587
|
+
return body();
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
if (previous)
|
|
591
|
+
Object.defineProperty(context, VARS_KEY, previous);
|
|
592
|
+
else
|
|
593
|
+
delete context[VARS_KEY];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function hasObjectChild(node) {
|
|
597
|
+
for (const item of Object.values(node)) {
|
|
598
|
+
if (typeof item === 'function')
|
|
599
|
+
return true;
|
|
600
|
+
if (item && typeof item === 'object')
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
function isPlainObject(value) {
|
|
606
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
607
|
+
}
|
|
608
|
+
/** The one key that scopes rather than configures - see `withScopedVars`. Reserved at **every**
|
|
609
|
+
* level, which costs a script that would have been called `vars`: `run.vars` is a scope, not a
|
|
610
|
+
* script. Nothing enumerates `run`'s keys as a list of script names, so the cost stops there. */
|
|
611
|
+
const VARS_KEY = 'vars';
|
|
612
|
+
/**
|
|
613
|
+
* Whether a function at `at` is **code** - a step to run later, or part of a plugin - rather than a
|
|
614
|
+
* value to compute now.
|
|
615
|
+
*
|
|
616
|
+
* Array indices are dropped before matching, so a function inside a *list* of steps is still a
|
|
617
|
+
* step; `*` in a `STEP_PATHS` entry matches any one segment (`run.<script>.exec`).
|
|
618
|
+
*/
|
|
619
|
+
function isCodePath(at) {
|
|
620
|
+
const segments = at.filter((p) => typeof p === 'string');
|
|
621
|
+
if (CODE_SUBTREES.includes(segments[0]))
|
|
622
|
+
return true;
|
|
623
|
+
return STEP_PATHS.some(pattern => {
|
|
624
|
+
const parts = pattern.split('.');
|
|
625
|
+
return parts.length === segments.length && parts.every((part, i) => part === '*' || part === segments[i]);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Calls a **value** function: the JS spelling of a `${{ }}` expression, and it answers the same
|
|
630
|
+
* question at the same moment.
|
|
631
|
+
*
|
|
632
|
+
* It receives one object carrying everything an expression can name - `pkg`, `repository`, `file`,
|
|
633
|
+
* `env`, `semver`, `path`, plus the config's own top-level keys - and, in addition, **`value`**: what
|
|
634
|
+
* this key resolved to in the layers underneath, which is what makes a derived value possible
|
|
635
|
+
* without restating the base.
|
|
636
|
+
*
|
|
637
|
+
* Built with the interpolation context as its **prototype**, not copied from it. The top-level keys
|
|
638
|
+
* are lazy getters (`resolve`, memoized, so key order in the file means nothing and a cycle is
|
|
639
|
+
* reported rather than half-resolved); spreading them into a new object would fire every one of
|
|
640
|
+
* them on every call, including the ones a function never reads - and one of those throwing would
|
|
641
|
+
* blame the wrong key.
|
|
642
|
+
*
|
|
643
|
+
* **It must compute and return, never act.** This runs while the repository's config resolves,
|
|
644
|
+
* which *every* command does - so a value function that writes a file writes it on `rman list`,
|
|
645
|
+
* `rman info` and `rman config` too, N times for N packages, with no command having asked for
|
|
646
|
+
* anything. That is the same reason `FileScope` offers no way to change anything. Work goes in a
|
|
647
|
+
* step, which is the one thing rman runs on purpose and which can also be a function.
|
|
648
|
+
*/
|
|
649
|
+
function callValueFn(fn, scope, context, at, skip) {
|
|
650
|
+
const previous = fn[PREVIOUS_VALUE];
|
|
651
|
+
const arg = Object.create(context);
|
|
652
|
+
/** Resolved the same way any other value is, so an inherited `${{ }}` string or a function under
|
|
653
|
+
* it is already finished by the time this one is handed it. */
|
|
654
|
+
const resolvedPrevious = previous === undefined ? undefined : walk(previous, scope, context, at, skip);
|
|
655
|
+
/**
|
|
656
|
+
* A getter only so the catch below can tell whether the function **actually read `value`**.
|
|
657
|
+
*
|
|
658
|
+
* Without that, the "value is undefined" hint went out with *every* failure of a first-layer
|
|
659
|
+
* function - a frozen-object `TypeError` from `read()` arrived wearing advice about spreading an
|
|
660
|
+
* inherited list, which is precisely the send-the-reader-to-the-wrong-place mistake the hint
|
|
661
|
+
* exists to prevent. Recorded rather than inferred from the message, because matching on V8's
|
|
662
|
+
* wording is the other way to get this wrong.
|
|
663
|
+
*/
|
|
664
|
+
let valueRead = false;
|
|
665
|
+
Object.defineProperty(arg, 'value', {
|
|
666
|
+
enumerable: true,
|
|
667
|
+
get: () => {
|
|
668
|
+
valueRead = true;
|
|
669
|
+
return resolvedPrevious;
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
try {
|
|
673
|
+
return fn(arg);
|
|
674
|
+
}
|
|
675
|
+
catch (e) {
|
|
676
|
+
const where = at.length ? formatPath(at) : 'the config root';
|
|
677
|
+
/**
|
|
678
|
+
* **`value` is `undefined` when no layer underneath set this key.** A function written to extend
|
|
679
|
+
* an inherited list (`[...value, x]`) is also the *first* layer in a repository that inherits
|
|
680
|
+
* nothing, and V8's report for that is `value is not iterable` - which names neither the key nor
|
|
681
|
+
* the reason, and sends the reader looking at their spread instead of at what is missing.
|
|
682
|
+
*
|
|
683
|
+
* Told rather than papered over: defaulting `value` to `[]` would be a guess about the key's
|
|
684
|
+
* type, and wrong for every key that is not a list.
|
|
685
|
+
*/
|
|
686
|
+
const hint = valueRead && resolvedPrevious === undefined
|
|
687
|
+
? `\n \`value\` is undefined here - nothing below this layer sets "${where}".` +
|
|
688
|
+
`\n Write \`value ?? []\` (or \`?? ''\`) if the function has to work as the first layer too.`
|
|
689
|
+
: '';
|
|
690
|
+
throw new Error(`Config function in "${where}" failed: ${e?.message}${hint}`, { cause: e });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
467
693
|
function interpolateString(value, context, at) {
|
|
468
694
|
if (!value.includes('${{'))
|
|
469
695
|
return value;
|
|
@@ -507,3 +733,129 @@ function formatPath(at) {
|
|
|
507
733
|
/** Guards against an expression that never returns (`while(true)`) taking the whole command with
|
|
508
734
|
* it - a typo, not an attack, but the failure mode is identical. */
|
|
509
735
|
const EXPRESSION_TIMEOUT = 1000;
|
|
736
|
+
/**
|
|
737
|
+
* Reads and parses one structured file, memoized against **the identity of its contents** rather
|
|
738
|
+
* than its path alone: the cache key is `mtimeNs:size`.
|
|
739
|
+
*
|
|
740
|
+
* Both halves of that were chosen against a measurement.
|
|
741
|
+
*
|
|
742
|
+
* - **A stat rather than a re-read**: `statSync` is 1.3µs where `readFileSync` + `JSON.parse` is
|
|
743
|
+
* 16.1µs on a 2KB manifest - so the check costs a thirteenth of what it saves, and the same file
|
|
744
|
+
* read by twenty packages is parsed once. (`interpolateConfig` runs once *per package*, so a
|
|
745
|
+
* cache living in one pass would not have helped across them at all.)
|
|
746
|
+
* - **Keyed on the stat rather than held for the run**: rman writes JSON files while it is running
|
|
747
|
+
* - `version` rewrites every bumped manifest, then re-interpolates its own deferred hooks. A
|
|
748
|
+
* cache that only remembered the path would hand those back as they were before the write.
|
|
749
|
+
* `mtimeNs` is nanoseconds, so a rewrite within the same millisecond does not slip through; the
|
|
750
|
+
* size is in the key as well because it costs nothing.
|
|
751
|
+
*
|
|
752
|
+
* **Frozen, deeply, once on the way into the cache.** Every package is handed the same object, so
|
|
753
|
+
* one config mutating it would quietly change what the next package sees - the reason `pkg.manifest`
|
|
754
|
+
* has always been a copy. Freezing is better than copying here: a copy costs 5.6µs on *every* call,
|
|
755
|
+
* freezing costs ~1µs *once*, and it turns the mistake into a `TypeError` instead of an effect at a
|
|
756
|
+
* distance. A caller that wants to change something spreads it first.
|
|
757
|
+
*/
|
|
758
|
+
function readStructuredFile(file, format, cache) {
|
|
759
|
+
let stat;
|
|
760
|
+
try {
|
|
761
|
+
stat = fs.statSync(file, { bigint: true });
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
throw new Error(`read("${path.basename(file)}") found nothing at ${file}\n` +
|
|
765
|
+
` Use file.exists() first if its absence is a case to handle rather than a mistake.`);
|
|
766
|
+
}
|
|
767
|
+
if (stat.isDirectory())
|
|
768
|
+
throw new Error(`read() was given a directory, not a file: ${file}`);
|
|
769
|
+
const stamp = `${stat.mtimeNs}:${stat.size}`;
|
|
770
|
+
const cached = cache.get(file);
|
|
771
|
+
if (cached?.stamp === stamp)
|
|
772
|
+
return cached.value;
|
|
773
|
+
const resolved = format ?? formatOf(file);
|
|
774
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
775
|
+
let value;
|
|
776
|
+
try {
|
|
777
|
+
value = parseStructured(text, resolved);
|
|
778
|
+
}
|
|
779
|
+
catch (e) {
|
|
780
|
+
/** The parser's own message says what is wrong with the syntax but never which file it was
|
|
781
|
+
* reading - and an expression can name several. */
|
|
782
|
+
throw new Error(`read("${path.basename(file)}") could not parse ${file} as ${resolved}: ${e?.message}`, {
|
|
783
|
+
cause: e,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
deepFreeze(value);
|
|
787
|
+
cache.set(file, { stamp, value });
|
|
788
|
+
return value;
|
|
789
|
+
}
|
|
790
|
+
/** The extension decides, because the caller already wrote it - naming the parser as well would
|
|
791
|
+
* restate it and let the two disagree (`json("x.yml")`). A name that says nothing takes the
|
|
792
|
+
* explicit argument instead. */
|
|
793
|
+
function formatOf(file) {
|
|
794
|
+
const ext = path.extname(file).toLowerCase();
|
|
795
|
+
if (ext === '.json')
|
|
796
|
+
return 'json';
|
|
797
|
+
if (ext === '.yml' || ext === '.yaml')
|
|
798
|
+
return 'yaml';
|
|
799
|
+
if (ext === '.ini')
|
|
800
|
+
return 'ini';
|
|
801
|
+
if (XML_EXTENSIONS.has(ext))
|
|
802
|
+
return 'xml';
|
|
803
|
+
throw new Error(`read() cannot tell what "${path.basename(file)}" is from its name.\n` +
|
|
804
|
+
` Name the format: read("${path.basename(file)}", "json" | "yaml" | "ini" | "xml").`);
|
|
805
|
+
}
|
|
806
|
+
/** The XML family worth recognizing by name: a project file is XML whatever its extension calls
|
|
807
|
+
* itself, and `.csproj`/`.pom` are what a .NET or Maven repository actually holds. Anything else
|
|
808
|
+
* still reads with an explicit `read(p, 'xml')`. */
|
|
809
|
+
const XML_EXTENSIONS = new Set(['.xml', '.csproj', '.vbproj', '.fsproj', '.props', '.targets', '.nuspec', '.plist']);
|
|
810
|
+
function parseStructured(text, format) {
|
|
811
|
+
if (format === 'json')
|
|
812
|
+
return JSON.parse(text);
|
|
813
|
+
/** `load`, not `loadAll`: a multi-document stream has no single value to be, and js-yaml says so
|
|
814
|
+
* clearly enough ("expected a single document in the stream") to leave alone. */
|
|
815
|
+
if (format === 'yaml')
|
|
816
|
+
return yaml.load(text);
|
|
817
|
+
if (format === 'xml')
|
|
818
|
+
return parseXml(text);
|
|
819
|
+
return ini.parse(text);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* A **DOM**, not an object - and the asymmetry with the other three formats is the honest shape
|
|
823
|
+
* rather than an omission.
|
|
824
|
+
*
|
|
825
|
+
* XML has no lossless object form: an element can repeat, carry attributes and hold text at the
|
|
826
|
+
* same time, so any flattening has to pick a convention (`$`? `_text`? array-or-not?) and be wrong
|
|
827
|
+
* for somebody. A DOM is the shape XML actually has, so a config reads it the way every other XML
|
|
828
|
+
* tool does:
|
|
829
|
+
*
|
|
830
|
+
* ```yaml
|
|
831
|
+
* version: '${{ read("pom.xml").getElementsByTagName("version")[0].textContent }}'
|
|
832
|
+
* ```
|
|
833
|
+
*
|
|
834
|
+
* **Freezing it is safe** - measured, not assumed: a frozen `@xmldom/xmldom` document still answers
|
|
835
|
+
* `getElementsByTagName` for a tag first asked about *after* the freeze (the live-collection case
|
|
836
|
+
* that would have broken it), reads attributes, resolves namespaces, walks `childNodes` and
|
|
837
|
+
* serialises back.
|
|
838
|
+
*/
|
|
839
|
+
function parseXml(text) {
|
|
840
|
+
/** xmldom reports a malformed document through a handler and otherwise carries on with whatever
|
|
841
|
+
* it could salvage - so without this, a broken file would come back as a half-parsed DOM and the
|
|
842
|
+
* expression reading it would simply find nothing. `read()` throws for a broken JSON file; it has
|
|
843
|
+
* to throw for this one too. */
|
|
844
|
+
const problems = [];
|
|
845
|
+
const doc = new DOMParser({
|
|
846
|
+
onError: (level, message) => {
|
|
847
|
+
if (level !== 'warning')
|
|
848
|
+
problems.push(message.split('\n')[0]);
|
|
849
|
+
},
|
|
850
|
+
}).parseFromString(text, 'text/xml');
|
|
851
|
+
if (problems.length)
|
|
852
|
+
throw new Error(problems[0]);
|
|
853
|
+
return doc;
|
|
854
|
+
}
|
|
855
|
+
function deepFreeze(value) {
|
|
856
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value))
|
|
857
|
+
return;
|
|
858
|
+
Object.freeze(value);
|
|
859
|
+
for (const item of Object.values(value))
|
|
860
|
+
deepFreeze(item);
|
|
861
|
+
}
|
package/core/merge-config.d.ts
CHANGED
|
@@ -14,6 +14,19 @@ 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 value function keeps the value it is replacing, so it can be handed back as `value`.
|
|
19
|
+
*
|
|
20
|
+
* A **symbol on a forwarding wrapper**, rather than a class or a `{fn, prev}` object, for one
|
|
21
|
+
* concrete reason: every walker in this file and in `config.ts` decides what to do by asking
|
|
22
|
+
* `isPlainObject`, and a wrapper object would answer yes - `finalizeConfig` would rebuild it as a
|
|
23
|
+
* plain object and lose the function, and `mergeConfig` would try to merge into it key by key. A
|
|
24
|
+
* function is not a plain object, so it travels through all of them untouched.
|
|
25
|
+
*
|
|
26
|
+
* The user's own function is never mutated: two packages inheriting the same shared-config function
|
|
27
|
+
* would otherwise share - and overwrite - one `prev`.
|
|
28
|
+
*/
|
|
29
|
+
export declare const PREVIOUS_VALUE: unique symbol;
|
|
17
30
|
/** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
|
|
18
31
|
export declare function appendTarget(key: string): string | undefined;
|
|
19
32
|
/**
|
package/core/merge-config.js
CHANGED
|
@@ -14,6 +14,19 @@ 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 value function keeps the value it is replacing, so it can be handed back as `value`.
|
|
19
|
+
*
|
|
20
|
+
* A **symbol on a forwarding wrapper**, rather than a class or a `{fn, prev}` object, for one
|
|
21
|
+
* concrete reason: every walker in this file and in `config.ts` decides what to do by asking
|
|
22
|
+
* `isPlainObject`, and a wrapper object would answer yes - `finalizeConfig` would rebuild it as a
|
|
23
|
+
* plain object and lose the function, and `mergeConfig` would try to merge into it key by key. A
|
|
24
|
+
* function is not a plain object, so it travels through all of them untouched.
|
|
25
|
+
*
|
|
26
|
+
* The user's own function is never mutated: two packages inheriting the same shared-config function
|
|
27
|
+
* would otherwise share - and overwrite - one `prev`.
|
|
28
|
+
*/
|
|
29
|
+
export const PREVIOUS_VALUE = Symbol('rman.previousValue');
|
|
17
30
|
/** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
|
|
18
31
|
export function appendTarget(key) {
|
|
19
32
|
return key.length > APPEND_PREFIX.length && key.startsWith(APPEND_PREFIX)
|
|
@@ -117,8 +130,42 @@ function assignMerged(target, key, value) {
|
|
|
117
130
|
mergeConfig(target[key], value);
|
|
118
131
|
return;
|
|
119
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* A function **replaces** like any other value - and remembers what it replaced, so it can be
|
|
135
|
+
* given it back as `value` when the config resolves:
|
|
136
|
+
*
|
|
137
|
+
* ```js
|
|
138
|
+
* '[*]': { clean: { include: ({ vars }) => [vars.buildDir] } }
|
|
139
|
+
* '[ws:*]': { clean: { include: ({ value, pkg }) => [...value, pkg.basename + '.log'] } }
|
|
140
|
+
* ```
|
|
141
|
+
*
|
|
142
|
+
* Chained here rather than at resolution time because only the merge knows the order of the
|
|
143
|
+
* layers - by the time `interpolateConfig` sees the config they have collapsed into one object,
|
|
144
|
+
* and whatever a closer layer said has already taken the place of what it was derived from.
|
|
145
|
+
*/
|
|
146
|
+
if (typeof value === 'function') {
|
|
147
|
+
target[key] = chainValueFn(value, target[key]);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
120
150
|
target[key] = Array.isArray(value) ? [...value] : value;
|
|
121
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Wraps `fn` so it carries `previous`, leaving `fn` itself alone.
|
|
154
|
+
*
|
|
155
|
+
* The wrapper forwards every argument unchanged, which is what lets one rule cover both kinds of
|
|
156
|
+
* function a config can hold: a **value** function is called by `interpolateConfig` with the config
|
|
157
|
+
* scope, a **step** function by `RunService` with a `RunStepContext`, and neither needs to know it
|
|
158
|
+
* has been wrapped. `name` is copied over because a step's label is its function's name.
|
|
159
|
+
*/
|
|
160
|
+
function chainValueFn(fn, previous) {
|
|
161
|
+
const wrapper = (...args) => fn(...args);
|
|
162
|
+
Object.defineProperty(wrapper, 'name', { value: fn.name, configurable: true });
|
|
163
|
+
/** Only when there *is* one: an own property set to `undefined` is indistinguishable from an
|
|
164
|
+
* inherited value that genuinely resolved to nothing. */
|
|
165
|
+
if (previous !== undefined)
|
|
166
|
+
Object.defineProperty(wrapper, PREVIOUS_VALUE, { value: previous });
|
|
167
|
+
return wrapper;
|
|
168
|
+
}
|
|
122
169
|
/**
|
|
123
170
|
* Appends `value` to `target[key]`, de-duplicating **only** an `ALWAYS_APPEND` key.
|
|
124
171
|
*
|
package/core/repository.d.ts
CHANGED
|
@@ -24,6 +24,18 @@ export declare class Repository extends Package {
|
|
|
24
24
|
* An internal cache has no business being walked anyway.
|
|
25
25
|
*/
|
|
26
26
|
private _repoScope?;
|
|
27
|
+
/** Cached `${{ git.* }}` facts - see `_gitScope`. Non-enumerable for the same reason as above,
|
|
28
|
+
* and because reading it is a subprocess: a deep walk of a package must not spawn one. */
|
|
29
|
+
private _git?;
|
|
30
|
+
/**
|
|
31
|
+
* Files `${{ read(...) }}` has parsed, shared by every package's scope and keyed by the identity
|
|
32
|
+
* of the bytes - see `readStructuredFile`.
|
|
33
|
+
*
|
|
34
|
+
* **On the repository rather than per scope, and that is the whole point of it**: `configScope`
|
|
35
|
+
* is built once per package, so a cache living there would re-read a repository-level file once
|
|
36
|
+
* for every package that mentions it.
|
|
37
|
+
*/
|
|
38
|
+
private readonly _readCache;
|
|
27
39
|
protected constructor(dirname: string, monorepo: boolean, packages: Package[],
|
|
28
40
|
/** The directory `Repository.create()` was actually invoked from - unlike `dirname` (the
|
|
29
41
|
* resolved repository root, possibly several levels up), this is where the user's shell
|
|
@@ -64,6 +76,8 @@ export declare class Repository extends Package {
|
|
|
64
76
|
configScope(pkg: Package, options?: {
|
|
65
77
|
targetVersion?: string;
|
|
66
78
|
}): ConfigScope;
|
|
79
|
+
/** `${{ git.* }}`, read at most once per repository per process. */
|
|
80
|
+
protected _gitScope(): GitScope;
|
|
67
81
|
/**
|
|
68
82
|
* Resolves the effective rman config for the repository root and every package, cascading
|
|
69
83
|
* root -> intermediate directories -> package directory, so a `.rmanrc` placed anywhere along
|
|
@@ -104,7 +118,7 @@ export declare class Repository extends Package {
|
|
|
104
118
|
*/
|
|
105
119
|
protected _resolveDeclaredPackage(entry: string): Package | undefined;
|
|
106
120
|
protected _updateDependencies(): void;
|
|
107
|
-
/** `git` facts for a `${{
|
|
121
|
+
/** `git` facts for a `${{ git.* }}` expression. Synchronous on purpose: it backs a lazy
|
|
108
122
|
* getter, and a getter cannot await. Everything is `undefined` outside a git checkout - not an
|
|
109
123
|
* error, just a repository without one. */
|
|
110
124
|
protected _readGitScope(dirname: string): GitScope;
|