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.
Files changed (41) hide show
  1. package/README.md +249 -0
  2. package/SPEC.md +145 -0
  3. package/dist/catalog.d.ts +104 -0
  4. package/dist/cli.d.ts +27 -0
  5. package/dist/compile.d.ts +1 -0
  6. package/dist/generate.d.ts +11 -0
  7. package/dist/index.d.ts +23 -0
  8. package/dist/parse.d.ts +27 -0
  9. package/dist/run.d.ts +54 -0
  10. package/dist/scaffold.d.ts +18 -0
  11. package/dist/types.d.ts +542 -0
  12. package/dist/validate.d.ts +28 -0
  13. package/dist/walk.d.ts +24 -0
  14. package/examples/README.md +21 -0
  15. package/examples/onboarding/README.md +16 -0
  16. package/examples/onboarding/catalog.js +29 -0
  17. package/examples/onboarding/generated/onboarding.test.mjs +15 -0
  18. package/examples/onboarding/journey.md +18 -0
  19. package/examples/onboarding/onboarding-session.mjs +21 -0
  20. package/examples/order-fulfillment/README.md +16 -0
  21. package/examples/order-fulfillment/catalog.js +59 -0
  22. package/examples/order-fulfillment/generated/order-fulfillment.test.mjs +15 -0
  23. package/examples/order-fulfillment/journey.md +32 -0
  24. package/examples/order-fulfillment/order-workflow.mjs +48 -0
  25. package/examples/vending/README.md +16 -0
  26. package/examples/vending/catalog.js +32 -0
  27. package/examples/vending/generated/vending.test.mjs +15 -0
  28. package/examples/vending/journey.md +21 -0
  29. package/examples/vending/vending-machine.mjs +16 -0
  30. package/package.json +39 -0
  31. package/src/catalog.js +485 -0
  32. package/src/cli.js +331 -0
  33. package/src/compile.js +3 -0
  34. package/src/generate.js +52 -0
  35. package/src/index.js +28 -0
  36. package/src/parse.js +263 -0
  37. package/src/run.js +258 -0
  38. package/src/scaffold.js +142 -0
  39. package/src/types.js +330 -0
  40. package/src/validate.js +171 -0
  41. 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
+ }