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/types.js ADDED
@@ -0,0 +1,330 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Structural kind inferred from a supported Mermaid node shape.
5
+ *
6
+ * @typedef {'terminal' | 'process' | 'decision'} NodeKind
7
+ */
8
+
9
+ /**
10
+ * A node in the parsed journey graph.
11
+ *
12
+ * @typedef {object} Node
13
+ * @property {string} id Stable Mermaid node id.
14
+ * @property {NodeKind} kind Structural kind inferred from shape.
15
+ * @property {string} label Human-readable label from the diagram.
16
+ */
17
+
18
+ /**
19
+ * A directed edge in source order.
20
+ *
21
+ * @typedef {object} Edge
22
+ * @property {string} from Source node id.
23
+ * @property {string} to Destination node id.
24
+ * @property {string} [label] Branch label, such as "yes" or "no".
25
+ */
26
+
27
+ /**
28
+ * Parsed journey graph. `start` is populated when validation finds exactly one
29
+ * zero-incoming terminal.
30
+ *
31
+ * @typedef {object} JourneyGraph
32
+ * @property {Map<string, Node>} nodes Node id to node.
33
+ * @property {Edge[]} edges Edges in Mermaid source order.
34
+ * @property {string} start Unique start node id, or an empty string when invalid.
35
+ */
36
+
37
+ /**
38
+ * Opaque per-node frontmatter value.
39
+ *
40
+ * @typedef {unknown} Binding
41
+ */
42
+
43
+ /**
44
+ * A stable diagnostic for author-input issues or parser notes.
45
+ *
46
+ * @typedef {object} Diagnostic
47
+ * @property {'error' | 'warning' | 'info'} severity Diagnostic severity.
48
+ * @property {string} code Stable screaming-snake diagnostic code.
49
+ * @property {string} message Human-readable explanation.
50
+ * @property {string} [node] Node id when the diagnostic is node-scoped.
51
+ */
52
+
53
+ /**
54
+ * Result returned by `parse()`.
55
+ *
56
+ * @typedef {object} ParseResult
57
+ * @property {JourneyGraph} graph Parsed graph.
58
+ * @property {Map<string, Binding>} bindings Node id to opaque frontmatter config.
59
+ * @property {Record<string, unknown>} meta Frontmatter minus `nodes`.
60
+ * @property {Diagnostic[]} diagnostics Parser and structural validation diagnostics.
61
+ */
62
+
63
+ /**
64
+ * Per-node catalog configuration read from frontmatter.
65
+ *
66
+ * `use` chooses a reusable catalog key for this graph. `as` chooses where a
67
+ * task output is stored in execution context. Extra fields are passed to the
68
+ * catalog function in `options`.
69
+ *
70
+ * @typedef {object} NodeConfig
71
+ * @property {string} use Catalog key. Defaults to node id.
72
+ * @property {unknown[]} args Positional arguments for this node.
73
+ * @property {string | undefined} as Context key override for task output.
74
+ * @property {Record<string, unknown>} options Extra per-journey options.
75
+ * @property {Binding | undefined} raw Original binding value.
76
+ */
77
+
78
+ /**
79
+ * Previous execution state supplied to catalog functions.
80
+ *
81
+ * @typedef {TaskStep | ScoreStep | GapStep | ErrorStep} ExecutedStep
82
+ */
83
+
84
+ /**
85
+ * Object passed to every task and scorer function.
86
+ *
87
+ * @typedef {object} CatalogCall
88
+ * @property {unknown[]} args Node-specific args from frontmatter.
89
+ * @property {Record<string, unknown>} context Mutable execution context.
90
+ * @property {Record<string, unknown>} meta Journey frontmatter minus `nodes`.
91
+ * @property {Node} node Source graph node.
92
+ * @property {ExecutedStep[]} previous Steps that have run before this node.
93
+ * @property {NodeConfig} config Normalized node config.
94
+ * @property {string} key Resolved catalog key.
95
+ */
96
+
97
+ /**
98
+ * Pending fragment for a known unresolved step.
99
+ *
100
+ * @typedef {object} TodoGap
101
+ * @property {'todo'} kind
102
+ * @property {string} reason Why the step is unresolved.
103
+ */
104
+
105
+ /**
106
+ * Pending fragment for missing human-supplied input.
107
+ *
108
+ * @typedef {object} HoleGap
109
+ * @property {'hole'} kind
110
+ * @property {string} reason What input is missing.
111
+ */
112
+
113
+ /**
114
+ * A catalog function may return one of these to stop execution honestly.
115
+ *
116
+ * @typedef {TodoGap | HoleGap} GapSignal
117
+ */
118
+
119
+ /**
120
+ * Function wrapped by `task(...)`.
121
+ *
122
+ * @callback TaskHandler
123
+ * @param {CatalogCall} input
124
+ * @returns {unknown | GapSignal | Promise<unknown | GapSignal>}
125
+ */
126
+
127
+ /**
128
+ * Raw values accepted from scorer functions before normalization.
129
+ *
130
+ * @typedef {boolean | number | StructuredScore | NumericScore | GapSignal} ScorerReturn
131
+ */
132
+
133
+ /**
134
+ * Structured pass/fail scorer result.
135
+ *
136
+ * @typedef {object} StructuredScore
137
+ * @property {boolean} pass
138
+ * @property {string} [message]
139
+ * @property {unknown} [actual]
140
+ * @property {unknown} [expected]
141
+ */
142
+
143
+ /**
144
+ * Numeric scorer result. The result passes when `score >= threshold`.
145
+ *
146
+ * @typedef {object} NumericScore
147
+ * @property {number} score
148
+ * @property {number} [threshold]
149
+ * @property {string} [message]
150
+ * @property {unknown} [actual]
151
+ * @property {unknown} [expected]
152
+ */
153
+
154
+ /**
155
+ * Normalized scorer result returned by Melusine.
156
+ *
157
+ * @typedef {object} ScoreResult
158
+ * @property {boolean} pass Whether the score passed.
159
+ * @property {number} [score] Numeric score when one was returned.
160
+ * @property {number} [threshold] Numeric threshold when one was used.
161
+ * @property {string} [message] Optional failure or detail message.
162
+ * @property {unknown} [actual] Optional actual value.
163
+ * @property {unknown} [expected] Optional expected value.
164
+ */
165
+
166
+ /**
167
+ * Function wrapped by `scorer(...)`.
168
+ *
169
+ * @callback ScorerHandler
170
+ * @param {CatalogCall} input
171
+ * @returns {ScorerReturn | Promise<ScorerReturn>}
172
+ */
173
+
174
+ /**
175
+ * Shared catalog entry options.
176
+ *
177
+ * @typedef {object} CatalogEntryOptions
178
+ * @property {number} [requiredArgs] Minimum number of args required before running.
179
+ * @property {string[]} [requiredOptions] Option keys required before running.
180
+ * @property {Record<string, unknown>} [meta] Entry-owned metadata.
181
+ */
182
+
183
+ /**
184
+ * Options for `task(...)`.
185
+ *
186
+ * @typedef {CatalogEntryOptions & { as?: string }} TaskOptions
187
+ */
188
+
189
+ /**
190
+ * Options for `scorer(...)`.
191
+ *
192
+ * @typedef {CatalogEntryOptions & { threshold?: number }} ScorerOptions
193
+ */
194
+
195
+ /**
196
+ * Reusable catalog task entry.
197
+ *
198
+ * @typedef {object} TaskEntry
199
+ * @property {'task'} kind
200
+ * @property {TaskHandler} run
201
+ * @property {string | undefined} as Default context storage key.
202
+ * @property {number | undefined} requiredArgs Minimum required args.
203
+ * @property {string[]} requiredOptions Required option keys.
204
+ * @property {Record<string, unknown>} meta Entry-owned metadata.
205
+ */
206
+
207
+ /**
208
+ * Reusable catalog scorer entry.
209
+ *
210
+ * @typedef {object} ScorerEntry
211
+ * @property {'scorer'} kind
212
+ * @property {ScorerHandler} run
213
+ * @property {number | undefined} threshold Default numeric score threshold.
214
+ * @property {number | undefined} requiredArgs Minimum required args.
215
+ * @property {string[]} requiredOptions Required option keys.
216
+ * @property {Record<string, unknown>} meta Entry-owned metadata.
217
+ */
218
+
219
+ /**
220
+ * A reusable catalog entry.
221
+ *
222
+ * @typedef {TaskEntry | ScorerEntry} CatalogEntry
223
+ */
224
+
225
+ /**
226
+ * ESM catalog object keyed by Mermaid node id or frontmatter `use`.
227
+ *
228
+ * @typedef {Record<string, CatalogEntry>} Catalog
229
+ */
230
+
231
+ /**
232
+ * Run/validation options for catalog execution.
233
+ *
234
+ * @typedef {object} RunOptions
235
+ * @property {boolean} [countDecisionScorers] Count decision scorers toward the
236
+ * per-path scorer coverage rule. Defaults to false.
237
+ */
238
+
239
+ /**
240
+ * A collected unresolved node.
241
+ *
242
+ * @typedef {object} Gap
243
+ * @property {string} nodeId Node that produced the gap.
244
+ * @property {'todo' | 'hole'} kind Gap kind.
245
+ * @property {string} reason Gap reason.
246
+ * @property {string} [entryKey] Catalog key involved in the gap.
247
+ */
248
+
249
+ /**
250
+ * Runtime error produced while executing a catalog entry.
251
+ *
252
+ * @typedef {object} RunError
253
+ * @property {string} nodeId Node that failed.
254
+ * @property {string} entryKey Catalog key that failed.
255
+ * @property {string} message Error message.
256
+ */
257
+
258
+ /**
259
+ * Task execution step.
260
+ *
261
+ * @typedef {object} TaskStep
262
+ * @property {'task'} type
263
+ * @property {Node} node Source node.
264
+ * @property {string} entryKey Catalog key.
265
+ * @property {unknown[]} args Args supplied to the task.
266
+ * @property {unknown} output Task return value.
267
+ * @property {string} storedAs Context key used for the task output.
268
+ */
269
+
270
+ /**
271
+ * Scorer execution step.
272
+ *
273
+ * @typedef {object} ScoreStep
274
+ * @property {'score'} type
275
+ * @property {Node} node Source node.
276
+ * @property {string} entryKey Catalog key.
277
+ * @property {'decision' | 'outcome'} role How the score was used.
278
+ * @property {unknown[]} args Args supplied to the scorer.
279
+ * @property {ScoreResult} result Normalized score result.
280
+ * @property {string} [branch] Branch label selected by a decision scorer.
281
+ */
282
+
283
+ /**
284
+ * Gap execution step.
285
+ *
286
+ * @typedef {object} GapStep
287
+ * @property {'gap'} type
288
+ * @property {Node} node Source node.
289
+ * @property {string} entryKey Catalog key.
290
+ * @property {Gap} gap Gap collected for the node.
291
+ */
292
+
293
+ /**
294
+ * Error execution step.
295
+ *
296
+ * @typedef {object} ErrorStep
297
+ * @property {'error'} type
298
+ * @property {Node} node Source node.
299
+ * @property {string} entryKey Catalog key.
300
+ * @property {RunError} error Runtime error collected for the node.
301
+ */
302
+
303
+ /**
304
+ * Result returned by `run()` and `runText()`.
305
+ *
306
+ * @typedef {object} RunResult
307
+ * @property {boolean} ok True when diagnostics, gaps, runtime errors, and
308
+ * outcome scorers all pass.
309
+ * @property {Record<string, unknown>} meta Journey frontmatter minus `nodes`.
310
+ * @property {Record<string, unknown>} context Final execution context.
311
+ * @property {ExecutedStep[]} steps Executed steps in deterministic order.
312
+ * @property {ScoreStep[]} scores Executed scorer steps.
313
+ * @property {Gap[]} gaps Reachable unresolved nodes in execution order.
314
+ * @property {RunError[]} errors Runtime errors in execution order.
315
+ * @property {Diagnostic[]} diagnostics Parser, structural, and catalog diagnostics.
316
+ */
317
+
318
+ /**
319
+ * Options used when generating a node:test wrapper.
320
+ *
321
+ * @typedef {object} GenerateOptions
322
+ * @property {string} journeyPath Path imported by the generated wrapper.
323
+ * @property {string} catalogPath Path imported by the generated wrapper.
324
+ * @property {string} [outPath] Output path, used to make relative imports.
325
+ * @property {string} [exportName] Named catalog export to use.
326
+ * @property {string} [testName] Test name. Defaults to journey metadata or file name.
327
+ * @property {string} [packageName] Package import name. Defaults to `melusine`.
328
+ */
329
+
330
+ export {};
@@ -0,0 +1,171 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {import('./types.js').JourneyGraph} JourneyGraph
5
+ * @typedef {import('./types.js').Diagnostic} Diagnostic
6
+ * @typedef {import('./types.js').Node} Node
7
+ * @typedef {import('./types.js').Edge} Edge
8
+ */
9
+
10
+ /**
11
+ * Validate the structural rules that are independent of any target.
12
+ *
13
+ * @param {JourneyGraph} graph Parsed graph to validate. `graph.start` is updated
14
+ * when the unique start terminal can be identified.
15
+ * @returns {Diagnostic[]} Structural diagnostics in deterministic order.
16
+ */
17
+ export function validate(graph) {
18
+ /** @type {Diagnostic[]} */
19
+ const diagnostics = [];
20
+ graph.start = '';
21
+
22
+ for (const edge of graph.edges) {
23
+ if (!graph.nodes.has(edge.from)) {
24
+ diagnostics.push({
25
+ severity: 'error',
26
+ code: 'JOURNEY_UNKNOWN_NODE',
27
+ message: `edge references unknown source node '${edge.from}'`,
28
+ node: edge.from,
29
+ });
30
+ }
31
+ if (!graph.nodes.has(edge.to)) {
32
+ diagnostics.push({
33
+ severity: 'error',
34
+ code: 'JOURNEY_UNKNOWN_NODE',
35
+ message: `edge references unknown destination node '${edge.to}'`,
36
+ node: edge.to,
37
+ });
38
+ }
39
+ }
40
+
41
+ const incoming = incomingByNode(graph);
42
+ const outgoing = outgoingByNode(graph);
43
+ const zeroIncoming = [...graph.nodes.values()].filter((node) => (incoming.get(node.id) ?? []).length === 0);
44
+ const terminalStarts = zeroIncoming.filter((node) => node.kind === 'terminal');
45
+
46
+ if (terminalStarts.length === 1 && zeroIncoming.length === 1) {
47
+ graph.start = terminalStarts[0].id;
48
+ } else if (zeroIncoming.length === 0 || terminalStarts.length === 0) {
49
+ diagnostics.push({
50
+ severity: 'error',
51
+ code: 'JOURNEY_NO_START',
52
+ message: 'journey must have exactly one zero-incoming terminal start',
53
+ });
54
+ } else {
55
+ diagnostics.push({
56
+ severity: 'error',
57
+ code: 'JOURNEY_MULTIPLE_STARTS',
58
+ message: `journey has multiple zero-incoming start candidates: ${zeroIncoming.map((node) => node.id).join(', ')}`,
59
+ });
60
+ }
61
+
62
+ for (const node of graph.nodes.values()) {
63
+ const incomingEdges = incoming.get(node.id) ?? [];
64
+ if (incomingEdges.length > 1) {
65
+ diagnostics.push({
66
+ severity: 'error',
67
+ code: 'JOURNEY_MERGE_UNSUPPORTED',
68
+ message: `node '${node.id}' has multiple incoming edges; merges are not supported in v1`,
69
+ node: node.id,
70
+ });
71
+ }
72
+ }
73
+
74
+ for (const node of graph.nodes.values()) {
75
+ const outgoingEdges = outgoing.get(node.id) ?? [];
76
+ if (outgoingEdges.length > 1 && node.kind !== 'decision') {
77
+ diagnostics.push({
78
+ severity: 'error',
79
+ code: 'JOURNEY_BRANCH_SHAPE',
80
+ message: `node '${node.id}' has multiple outgoing edges but is not a decision`,
81
+ node: node.id,
82
+ });
83
+ }
84
+ if (node.kind === 'decision') {
85
+ for (const edge of outgoingEdges) {
86
+ if (!edge.label) {
87
+ diagnostics.push({
88
+ severity: 'error',
89
+ code: 'JOURNEY_UNLABELED_BRANCH',
90
+ message: `decision node '${node.id}' has an unlabeled outgoing edge`,
91
+ node: node.id,
92
+ });
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ if (hasCycle(graph)) {
99
+ diagnostics.push({
100
+ severity: 'error',
101
+ code: 'JOURNEY_CYCLE',
102
+ message: 'journey graph must be acyclic',
103
+ });
104
+ }
105
+
106
+ return diagnostics;
107
+ }
108
+
109
+ /**
110
+ * @param {JourneyGraph} graph
111
+ * @returns {Map<string, Edge[]>}
112
+ */
113
+ export function incomingByNode(graph) {
114
+ /** @type {Map<string, Edge[]>} */
115
+ const incoming = new Map();
116
+ for (const id of graph.nodes.keys()) incoming.set(id, []);
117
+ for (const edge of graph.edges) {
118
+ const edges = incoming.get(edge.to);
119
+ if (edges) edges.push(edge);
120
+ }
121
+ return incoming;
122
+ }
123
+
124
+ /**
125
+ * @param {JourneyGraph} graph
126
+ * @returns {Map<string, Edge[]>}
127
+ */
128
+ export function outgoingByNode(graph) {
129
+ /** @type {Map<string, Edge[]>} */
130
+ const outgoing = new Map();
131
+ for (const id of graph.nodes.keys()) outgoing.set(id, []);
132
+ for (const edge of graph.edges) {
133
+ const edges = outgoing.get(edge.from);
134
+ if (edges) edges.push(edge);
135
+ }
136
+ return outgoing;
137
+ }
138
+
139
+ /**
140
+ * @param {JourneyGraph} graph
141
+ * @returns {boolean}
142
+ */
143
+ function hasCycle(graph) {
144
+ /** @type {Set<string>} */
145
+ const visiting = new Set();
146
+ /** @type {Set<string>} */
147
+ const visited = new Set();
148
+ const outgoing = outgoingByNode(graph);
149
+
150
+ /**
151
+ * @param {string} id
152
+ * @returns {boolean}
153
+ */
154
+ function visit(id) {
155
+ if (visiting.has(id)) return true;
156
+ if (visited.has(id)) return false;
157
+ visiting.add(id);
158
+ for (const edge of outgoing.get(id) ?? []) {
159
+ if (!graph.nodes.has(edge.to)) continue;
160
+ if (visit(edge.to)) return true;
161
+ }
162
+ visiting.delete(id);
163
+ visited.add(id);
164
+ return false;
165
+ }
166
+
167
+ for (const id of graph.nodes.keys()) {
168
+ if (visit(id)) return true;
169
+ }
170
+ return false;
171
+ }
package/src/walk.js ADDED
@@ -0,0 +1,57 @@
1
+ // @ts-check
2
+
3
+ import { outgoingByNode } from './validate.js';
4
+
5
+ /**
6
+ * @typedef {import('./types.js').Edge} Edge
7
+ * @typedef {import('./types.js').JourneyGraph} JourneyGraph
8
+ */
9
+
10
+ /**
11
+ * Enumerate all start-to-terminal graph paths in deterministic source order.
12
+ *
13
+ * @param {JourneyGraph} graph Validated acyclic graph.
14
+ * @returns {string[][]}
15
+ */
16
+ export function enumeratePaths(graph) {
17
+ if (!graph.start) return [];
18
+ const outgoing = outgoingByNode(graph);
19
+ /** @type {string[][]} */
20
+ const paths = [];
21
+
22
+ /**
23
+ * @param {string} nodeId
24
+ * @param {string[]} path
25
+ */
26
+ function visit(nodeId, path) {
27
+ const nextPath = [...path, nodeId];
28
+ const edges = outgoing.get(nodeId) ?? [];
29
+ if (edges.length === 0) {
30
+ paths.push(nextPath);
31
+ return;
32
+ }
33
+ for (const edge of edges) {
34
+ visit(edge.to, nextPath);
35
+ }
36
+ }
37
+
38
+ visit(graph.start, []);
39
+ return paths;
40
+ }
41
+
42
+ /**
43
+ * Select the outgoing decision branch for a normalized scorer result.
44
+ *
45
+ * `yes` and `no` labels are preferred. If the expected label is absent, source
46
+ * order keeps branch selection deterministic.
47
+ *
48
+ * @param {Edge[]} edges Outgoing decision edges in source order.
49
+ * @param {boolean} pass Decision scorer pass value.
50
+ * @returns {Edge | undefined}
51
+ */
52
+ export function selectDecisionEdge(edges, pass) {
53
+ const preferred = pass ? 'yes' : 'no';
54
+ const byLabel = edges.find((edge) => (edge.label ?? '').toLowerCase() === preferred);
55
+ if (byLabel) return byLabel;
56
+ return pass ? edges[0] : (edges[1] ?? edges[0]);
57
+ }