melusine 0.1.0
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 +249 -0
- package/SPEC.md +145 -0
- package/dist/catalog.d.ts +104 -0
- package/dist/cli.d.ts +27 -0
- package/dist/compile.d.ts +1 -0
- package/dist/generate.d.ts +11 -0
- package/dist/index.d.ts +23 -0
- package/dist/parse.d.ts +27 -0
- package/dist/run.d.ts +54 -0
- package/dist/scaffold.d.ts +18 -0
- package/dist/types.d.ts +542 -0
- package/dist/validate.d.ts +28 -0
- package/dist/walk.d.ts +24 -0
- package/examples/README.md +21 -0
- package/examples/onboarding/README.md +16 -0
- package/examples/onboarding/catalog.js +29 -0
- package/examples/onboarding/generated/onboarding.test.mjs +15 -0
- package/examples/onboarding/journey.md +18 -0
- package/examples/onboarding/onboarding-session.mjs +21 -0
- package/examples/order-fulfillment/README.md +16 -0
- package/examples/order-fulfillment/catalog.js +59 -0
- package/examples/order-fulfillment/generated/order-fulfillment.test.mjs +15 -0
- package/examples/order-fulfillment/journey.md +32 -0
- package/examples/order-fulfillment/order-workflow.mjs +48 -0
- package/examples/vending/README.md +16 -0
- package/examples/vending/catalog.js +32 -0
- package/examples/vending/generated/vending.test.mjs +15 -0
- package/examples/vending/journey.md +21 -0
- package/examples/vending/vending-machine.mjs +16 -0
- package/package.json +39 -0
- package/src/catalog.js +485 -0
- package/src/cli.js +331 -0
- package/src/compile.js +3 -0
- package/src/generate.js +52 -0
- package/src/index.js +28 -0
- package/src/parse.js +263 -0
- package/src/run.js +258 -0
- package/src/scaffold.js +142 -0
- package/src/types.js +330 -0
- package/src/validate.js +171 -0
- package/src/walk.js +57 -0
package/src/catalog.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { enumeratePaths } from './walk.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('./types.js').Binding} Binding
|
|
7
|
+
* @typedef {import('./types.js').Catalog} Catalog
|
|
8
|
+
* @typedef {import('./types.js').CatalogEntry} CatalogEntry
|
|
9
|
+
* @typedef {import('./types.js').Diagnostic} Diagnostic
|
|
10
|
+
* @typedef {import('./types.js').GapSignal} GapSignal
|
|
11
|
+
* @typedef {import('./types.js').JourneyGraph} JourneyGraph
|
|
12
|
+
* @typedef {import('./types.js').Node} Node
|
|
13
|
+
* @typedef {import('./types.js').NodeConfig} NodeConfig
|
|
14
|
+
* @typedef {import('./types.js').NumericScore} NumericScore
|
|
15
|
+
* @typedef {import('./types.js').RunOptions} RunOptions
|
|
16
|
+
* @typedef {import('./types.js').ScoreResult} ScoreResult
|
|
17
|
+
* @typedef {import('./types.js').ScorerOptions} ScorerOptions
|
|
18
|
+
* @typedef {import('./types.js').StructuredScore} StructuredScore
|
|
19
|
+
* @typedef {import('./types.js').TaskOptions} TaskOptions
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const ENTRY_BRAND = Symbol('melusine.catalogEntry');
|
|
23
|
+
const RESERVED_CONFIG_KEYS = new Set(['use', 'args', 'as', 'options']);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wrap a reusable project action for use by one or more journeys.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('./types.js').TaskHandler} handler Function to run.
|
|
29
|
+
* @param {TaskOptions} [options] Task metadata and requirements.
|
|
30
|
+
* @returns {import('./types.js').TaskEntry}
|
|
31
|
+
*/
|
|
32
|
+
export function task(handler, options = {}) {
|
|
33
|
+
if (typeof handler !== 'function') {
|
|
34
|
+
throw new TypeError('task() requires a function');
|
|
35
|
+
}
|
|
36
|
+
if (options.as !== undefined && typeof options.as !== 'string') {
|
|
37
|
+
throw new TypeError('task() option `as` must be a string when provided');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return brandEntry({
|
|
41
|
+
kind: 'task',
|
|
42
|
+
run: handler,
|
|
43
|
+
as: options.as,
|
|
44
|
+
requiredArgs: normalizeRequiredArgs(options.requiredArgs),
|
|
45
|
+
requiredOptions: normalizeRequiredOptions(options.requiredOptions),
|
|
46
|
+
meta: normalizeMeta(options.meta),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wrap a reusable project scorer for use by one or more journeys.
|
|
52
|
+
*
|
|
53
|
+
* @param {import('./types.js').ScorerHandler} handler Function to score.
|
|
54
|
+
* @param {ScorerOptions} [options] Scorer metadata and requirements.
|
|
55
|
+
* @returns {import('./types.js').ScorerEntry}
|
|
56
|
+
*/
|
|
57
|
+
export function scorer(handler, options = {}) {
|
|
58
|
+
if (typeof handler !== 'function') {
|
|
59
|
+
throw new TypeError('scorer() requires a function');
|
|
60
|
+
}
|
|
61
|
+
if (options.threshold !== undefined && !isFiniteNumber(options.threshold)) {
|
|
62
|
+
throw new TypeError('scorer() option `threshold` must be a finite number when provided');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return brandEntry({
|
|
66
|
+
kind: 'scorer',
|
|
67
|
+
run: handler,
|
|
68
|
+
threshold: options.threshold,
|
|
69
|
+
requiredArgs: normalizeRequiredArgs(options.requiredArgs),
|
|
70
|
+
requiredOptions: normalizeRequiredOptions(options.requiredOptions),
|
|
71
|
+
meta: normalizeMeta(options.meta),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create an explicit unresolved task/scorer result.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} reason Why the step is pending.
|
|
79
|
+
* @returns {GapSignal}
|
|
80
|
+
*/
|
|
81
|
+
export function todo(reason) {
|
|
82
|
+
if (typeof reason !== 'string' || reason.length === 0) {
|
|
83
|
+
throw new TypeError('todo() requires a non-empty reason');
|
|
84
|
+
}
|
|
85
|
+
return { kind: 'todo', reason };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create an explicit missing-input result.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} reason What input is missing.
|
|
92
|
+
* @returns {GapSignal}
|
|
93
|
+
*/
|
|
94
|
+
export function hole(reason) {
|
|
95
|
+
if (typeof reason !== 'string' || reason.length === 0) {
|
|
96
|
+
throw new TypeError('hole() requires a non-empty reason');
|
|
97
|
+
}
|
|
98
|
+
return { kind: 'hole', reason };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate catalog references and graph/catalog role compatibility.
|
|
103
|
+
*
|
|
104
|
+
* @param {JourneyGraph} graph Parsed graph.
|
|
105
|
+
* @param {Map<string, Binding>} bindings Node id to frontmatter config.
|
|
106
|
+
* @param {Catalog} catalog Catalog object.
|
|
107
|
+
* @param {RunOptions} [options] Validation options.
|
|
108
|
+
* @returns {Diagnostic[]}
|
|
109
|
+
*/
|
|
110
|
+
export function validateCatalog(graph, bindings, catalog, options = {}) {
|
|
111
|
+
assertCatalog(catalog);
|
|
112
|
+
/** @type {Diagnostic[]} */
|
|
113
|
+
const diagnostics = [];
|
|
114
|
+
|
|
115
|
+
for (const node of graph.nodes.values()) {
|
|
116
|
+
const config = readNodeConfig(bindings.get(node.id), node.id, diagnostics);
|
|
117
|
+
const entry = Reflect.get(catalog, config.use);
|
|
118
|
+
|
|
119
|
+
if (entry === undefined) {
|
|
120
|
+
diagnostics.push({
|
|
121
|
+
severity: 'error',
|
|
122
|
+
code: 'JOURNEY_CATALOG_MISSING',
|
|
123
|
+
message: `node '${node.id}' references missing catalog entry '${config.use}'`,
|
|
124
|
+
node: node.id,
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
assertCatalogEntry(entry, config.use);
|
|
130
|
+
const expected = expectedRole(node, graph.start);
|
|
131
|
+
if (entry.kind !== expected) {
|
|
132
|
+
diagnostics.push({
|
|
133
|
+
severity: 'error',
|
|
134
|
+
code: 'JOURNEY_CATALOG_ROLE',
|
|
135
|
+
message: `node '${node.id}' is a ${node.kind} and expects a ${expected}, but catalog entry '${config.use}' is a ${entry.kind}`,
|
|
136
|
+
node: node.id,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const path of enumeratePaths(graph)) {
|
|
142
|
+
if (!pathHasRequiredScorer(path, graph, bindings, catalog, options)) {
|
|
143
|
+
const last = path.at(-1);
|
|
144
|
+
diagnostics.push({
|
|
145
|
+
severity: 'error',
|
|
146
|
+
code: 'JOURNEY_PATH_WITHOUT_SCORER',
|
|
147
|
+
message: `path ending at '${last ?? graph.start}' does not include a required outcome scorer`,
|
|
148
|
+
...(last ? { node: last } : {}),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return diagnostics;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read one node's frontmatter binding into the catalog config model.
|
|
158
|
+
*
|
|
159
|
+
* @param {Binding | undefined} binding Raw frontmatter binding.
|
|
160
|
+
* @param {string} nodeId Source node id.
|
|
161
|
+
* @param {Diagnostic[]} [diagnostics] Diagnostics sink.
|
|
162
|
+
* @returns {NodeConfig}
|
|
163
|
+
*/
|
|
164
|
+
export function readNodeConfig(binding, nodeId, diagnostics = []) {
|
|
165
|
+
/** @type {NodeConfig} */
|
|
166
|
+
const config = {
|
|
167
|
+
use: nodeId,
|
|
168
|
+
args: [],
|
|
169
|
+
as: undefined,
|
|
170
|
+
options: {},
|
|
171
|
+
raw: binding,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (binding === undefined) return config;
|
|
175
|
+
|
|
176
|
+
if (!isRecord(binding)) {
|
|
177
|
+
diagnostics.push({
|
|
178
|
+
severity: 'error',
|
|
179
|
+
code: 'JOURNEY_NODE_CONFIG_INVALID',
|
|
180
|
+
message: `node '${nodeId}' frontmatter config must be an object`,
|
|
181
|
+
node: nodeId,
|
|
182
|
+
});
|
|
183
|
+
return config;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (binding.use !== undefined) {
|
|
187
|
+
if (typeof binding.use === 'string' && binding.use.length > 0) {
|
|
188
|
+
config.use = binding.use;
|
|
189
|
+
} else {
|
|
190
|
+
diagnostics.push({
|
|
191
|
+
severity: 'error',
|
|
192
|
+
code: 'JOURNEY_NODE_CONFIG_INVALID',
|
|
193
|
+
message: `node '${nodeId}' frontmatter field 'use' must be a non-empty string`,
|
|
194
|
+
node: nodeId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (binding.args !== undefined) {
|
|
200
|
+
if (Array.isArray(binding.args)) {
|
|
201
|
+
config.args = binding.args;
|
|
202
|
+
} else {
|
|
203
|
+
diagnostics.push({
|
|
204
|
+
severity: 'error',
|
|
205
|
+
code: 'JOURNEY_NODE_CONFIG_INVALID',
|
|
206
|
+
message: `node '${nodeId}' frontmatter field 'args' must be an array`,
|
|
207
|
+
node: nodeId,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (binding.as !== undefined) {
|
|
213
|
+
if (typeof binding.as === 'string' && binding.as.length > 0) {
|
|
214
|
+
config.as = binding.as;
|
|
215
|
+
} else {
|
|
216
|
+
diagnostics.push({
|
|
217
|
+
severity: 'error',
|
|
218
|
+
code: 'JOURNEY_NODE_CONFIG_INVALID',
|
|
219
|
+
message: `node '${nodeId}' frontmatter field 'as' must be a non-empty string`,
|
|
220
|
+
node: nodeId,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (binding.options !== undefined) {
|
|
226
|
+
if (isRecord(binding.options)) {
|
|
227
|
+
config.options = { ...binding.options };
|
|
228
|
+
} else {
|
|
229
|
+
diagnostics.push({
|
|
230
|
+
severity: 'error',
|
|
231
|
+
code: 'JOURNEY_NODE_CONFIG_INVALID',
|
|
232
|
+
message: `node '${nodeId}' frontmatter field 'options' must be an object`,
|
|
233
|
+
node: nodeId,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const [key, value] of Object.entries(binding)) {
|
|
239
|
+
if (!RESERVED_CONFIG_KEYS.has(key)) {
|
|
240
|
+
config.options[key] = value;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return config;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Resolve one catalog entry and validate its shape.
|
|
249
|
+
*
|
|
250
|
+
* @param {Catalog} catalog Catalog object.
|
|
251
|
+
* @param {string} key Catalog key.
|
|
252
|
+
* @returns {CatalogEntry}
|
|
253
|
+
*/
|
|
254
|
+
export function getCatalogEntry(catalog, key) {
|
|
255
|
+
assertCatalog(catalog);
|
|
256
|
+
const entry = Reflect.get(catalog, key);
|
|
257
|
+
assertCatalogEntry(entry, key);
|
|
258
|
+
return entry;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {unknown} value
|
|
263
|
+
* @returns {value is GapSignal}
|
|
264
|
+
*/
|
|
265
|
+
export function isGapSignal(value) {
|
|
266
|
+
return isRecord(value)
|
|
267
|
+
&& (value.kind === 'todo' || value.kind === 'hole')
|
|
268
|
+
&& typeof value.reason === 'string';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Normalize a scorer return value into Melusine's result model.
|
|
273
|
+
*
|
|
274
|
+
* @param {unknown} value Raw scorer return.
|
|
275
|
+
* @param {number | undefined} entryThreshold Default threshold from scorer metadata.
|
|
276
|
+
* @returns {ScoreResult}
|
|
277
|
+
*/
|
|
278
|
+
export function normalizeScoreResult(value, entryThreshold) {
|
|
279
|
+
if (typeof value === 'boolean') {
|
|
280
|
+
return { pass: value };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (typeof value === 'number') {
|
|
284
|
+
assertFiniteScore(value);
|
|
285
|
+
const threshold = entryThreshold ?? 1;
|
|
286
|
+
return { pass: value >= threshold, score: value, threshold };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!isRecord(value)) {
|
|
290
|
+
throw new TypeError('scorer returned an invalid result');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (typeof value.pass === 'boolean') {
|
|
294
|
+
return withScoreDetails({ pass: value.pass }, value);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (typeof value.score === 'number') {
|
|
298
|
+
assertFiniteScore(value.score);
|
|
299
|
+
const threshold = value.threshold === undefined ? (entryThreshold ?? 1) : value.threshold;
|
|
300
|
+
if (!isFiniteNumber(threshold)) {
|
|
301
|
+
throw new TypeError('scorer returned an invalid numeric threshold');
|
|
302
|
+
}
|
|
303
|
+
return withScoreDetails({ pass: value.score >= threshold, score: value.score, threshold }, value);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
throw new TypeError('scorer returned an invalid result');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {Node} node
|
|
311
|
+
* @param {string} start
|
|
312
|
+
* @returns {'task' | 'scorer'}
|
|
313
|
+
*/
|
|
314
|
+
export function expectedRole(node, start) {
|
|
315
|
+
if (node.kind === 'process') return 'task';
|
|
316
|
+
if (node.kind === 'decision') return 'scorer';
|
|
317
|
+
return node.id === start ? 'task' : 'scorer';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {CatalogEntry} entry
|
|
322
|
+
* @param {NodeConfig} config
|
|
323
|
+
* @param {string} nodeId
|
|
324
|
+
* @returns {GapSignal | undefined}
|
|
325
|
+
*/
|
|
326
|
+
export function missingRequiredInput(entry, config, nodeId) {
|
|
327
|
+
if (entry.requiredArgs !== undefined && config.args.length < entry.requiredArgs) {
|
|
328
|
+
return hole(`node '${nodeId}' requires at least ${entry.requiredArgs} arg(s)`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const key of entry.requiredOptions) {
|
|
332
|
+
if (!Object.hasOwn(config.options, key)) {
|
|
333
|
+
return hole(`node '${nodeId}' requires option '${key}'`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {CatalogEntry} entry
|
|
342
|
+
* @param {NodeConfig} config
|
|
343
|
+
* @param {Node} node
|
|
344
|
+
* @returns {string}
|
|
345
|
+
*/
|
|
346
|
+
export function storageKeyFor(entry, config, node) {
|
|
347
|
+
return config.as ?? (entry.kind === 'task' ? entry.as : undefined) ?? node.id;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {unknown} catalog
|
|
352
|
+
* @returns {asserts catalog is Catalog}
|
|
353
|
+
*/
|
|
354
|
+
function assertCatalog(catalog) {
|
|
355
|
+
if (!isRecord(catalog)) {
|
|
356
|
+
throw new TypeError('catalog must be an object');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {unknown} entry
|
|
362
|
+
* @param {string} key
|
|
363
|
+
* @returns {asserts entry is CatalogEntry}
|
|
364
|
+
*/
|
|
365
|
+
function assertCatalogEntry(entry, key) {
|
|
366
|
+
if (!isCatalogEntry(entry)) {
|
|
367
|
+
throw new TypeError(`catalog entry '${key}' must be created with task() or scorer()`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* @template {CatalogEntry} T
|
|
373
|
+
* @param {T} entry
|
|
374
|
+
* @returns {T}
|
|
375
|
+
*/
|
|
376
|
+
function brandEntry(entry) {
|
|
377
|
+
Object.defineProperty(entry, ENTRY_BRAND, { value: true });
|
|
378
|
+
return Object.freeze(entry);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {number | undefined} value
|
|
383
|
+
* @returns {number | undefined}
|
|
384
|
+
*/
|
|
385
|
+
function normalizeRequiredArgs(value) {
|
|
386
|
+
if (value === undefined) return undefined;
|
|
387
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
388
|
+
throw new TypeError('requiredArgs must be a non-negative integer');
|
|
389
|
+
}
|
|
390
|
+
return value;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @param {string[] | undefined} value
|
|
395
|
+
* @returns {string[]}
|
|
396
|
+
*/
|
|
397
|
+
function normalizeRequiredOptions(value) {
|
|
398
|
+
if (value === undefined) return [];
|
|
399
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.length === 0)) {
|
|
400
|
+
throw new TypeError('requiredOptions must be an array of non-empty strings');
|
|
401
|
+
}
|
|
402
|
+
return [...value];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* @param {Record<string, unknown> | undefined} value
|
|
407
|
+
* @returns {Record<string, unknown>}
|
|
408
|
+
*/
|
|
409
|
+
function normalizeMeta(value) {
|
|
410
|
+
if (value === undefined) return {};
|
|
411
|
+
if (!isRecord(value)) throw new TypeError('meta must be an object');
|
|
412
|
+
return { ...value };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* @param {string[]} path
|
|
417
|
+
* @param {JourneyGraph} graph
|
|
418
|
+
* @param {Map<string, Binding>} bindings
|
|
419
|
+
* @param {Catalog} catalog
|
|
420
|
+
* @param {RunOptions} options
|
|
421
|
+
* @returns {boolean}
|
|
422
|
+
*/
|
|
423
|
+
function pathHasRequiredScorer(path, graph, bindings, catalog, options) {
|
|
424
|
+
for (const nodeId of path) {
|
|
425
|
+
const node = graph.nodes.get(nodeId);
|
|
426
|
+
if (!node) continue;
|
|
427
|
+
const config = readNodeConfig(bindings.get(node.id), node.id);
|
|
428
|
+
const entry = Reflect.get(catalog, config.use);
|
|
429
|
+
if (!isCatalogEntry(entry) || entry.kind !== 'scorer') continue;
|
|
430
|
+
|
|
431
|
+
if (node.kind === 'terminal' && node.id !== graph.start) return true;
|
|
432
|
+
if (options.countDecisionScorers === true && node.kind === 'decision') return true;
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @param {unknown} value
|
|
439
|
+
* @returns {value is CatalogEntry}
|
|
440
|
+
*/
|
|
441
|
+
function isCatalogEntry(value) {
|
|
442
|
+
return isRecord(value)
|
|
443
|
+
&& value[ENTRY_BRAND] === true
|
|
444
|
+
&& (value.kind === 'task' || value.kind === 'scorer')
|
|
445
|
+
&& typeof value.run === 'function';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @param {ScoreResult} base
|
|
450
|
+
* @param {Record<string, unknown>} source
|
|
451
|
+
* @returns {ScoreResult}
|
|
452
|
+
*/
|
|
453
|
+
function withScoreDetails(base, source) {
|
|
454
|
+
return {
|
|
455
|
+
...base,
|
|
456
|
+
...(typeof source.message === 'string' ? { message: source.message } : {}),
|
|
457
|
+
...(Object.hasOwn(source, 'actual') ? { actual: source.actual } : {}),
|
|
458
|
+
...(Object.hasOwn(source, 'expected') ? { expected: source.expected } : {}),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @param {number} score
|
|
464
|
+
*/
|
|
465
|
+
function assertFiniteScore(score) {
|
|
466
|
+
if (!isFiniteNumber(score)) {
|
|
467
|
+
throw new TypeError('scorer returned a non-finite score');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* @param {unknown} value
|
|
473
|
+
* @returns {value is number}
|
|
474
|
+
*/
|
|
475
|
+
function isFiniteNumber(value) {
|
|
476
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* @param {unknown} value
|
|
481
|
+
* @returns {value is Record<string | symbol, unknown>}
|
|
482
|
+
*/
|
|
483
|
+
function isRecord(value) {
|
|
484
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
485
|
+
}
|