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/run.js ADDED
@@ -0,0 +1,258 @@
1
+ // @ts-check
2
+
3
+ import {
4
+ expectedRole,
5
+ getCatalogEntry,
6
+ isGapSignal,
7
+ missingRequiredInput,
8
+ normalizeScoreResult,
9
+ readNodeConfig,
10
+ storageKeyFor,
11
+ validateCatalog,
12
+ } from './catalog.js';
13
+ import { parse } from './parse.js';
14
+ import { outgoingByNode } from './validate.js';
15
+ import { selectDecisionEdge } from './walk.js';
16
+
17
+ /**
18
+ * @typedef {import('./types.js').Catalog} Catalog
19
+ * @typedef {import('./types.js').Diagnostic} Diagnostic
20
+ * @typedef {import('./types.js').ExecutedStep} ExecutedStep
21
+ * @typedef {import('./types.js').Gap} Gap
22
+ * @typedef {import('./types.js').GapSignal} GapSignal
23
+ * @typedef {import('./types.js').Node} Node
24
+ * @typedef {import('./types.js').ParseResult} ParseResult
25
+ * @typedef {import('./types.js').RunError} RunError
26
+ * @typedef {import('./types.js').RunOptions} RunOptions
27
+ * @typedef {import('./types.js').RunResult} RunResult
28
+ * @typedef {import('./types.js').ScoreStep} ScoreStep
29
+ */
30
+
31
+ /**
32
+ * Execute a parsed journey directly against a reusable catalog.
33
+ *
34
+ * Structural and catalog validation errors short-circuit before catalog
35
+ * functions run. Programmer errors in catalog definitions or scorer return
36
+ * shapes throw.
37
+ *
38
+ * @param {ParseResult} parsed Parsed journey.
39
+ * @param {Catalog} catalog Reusable catalog.
40
+ * @param {RunOptions} [options] Run options.
41
+ * @returns {Promise<RunResult>}
42
+ */
43
+ export async function run(parsed, catalog, options = {}) {
44
+ const structuralDiagnostics = parsed.diagnostics;
45
+ if (hasErrors(structuralDiagnostics)) {
46
+ return emptyResult(parsed, structuralDiagnostics);
47
+ }
48
+
49
+ const diagnostics = structuralDiagnostics.concat(validateCatalog(parsed.graph, parsed.bindings, catalog, options));
50
+ if (hasErrors(diagnostics)) {
51
+ return emptyResult(parsed, diagnostics);
52
+ }
53
+
54
+ const outgoing = outgoingByNode(parsed.graph);
55
+ /** @type {Record<string, unknown>} */
56
+ const context = {};
57
+ /** @type {ExecutedStep[]} */
58
+ const steps = [];
59
+ /** @type {ScoreStep[]} */
60
+ const scores = [];
61
+ /** @type {Gap[]} */
62
+ const gaps = [];
63
+ /** @type {RunError[]} */
64
+ const errors = [];
65
+
66
+ let current = parsed.graph.start;
67
+ while (current) {
68
+ const node = parsed.graph.nodes.get(current);
69
+ if (!node) break;
70
+
71
+ const config = readNodeConfig(parsed.bindings.get(node.id), node.id);
72
+ const entry = getCatalogEntry(catalog, config.use);
73
+ const missing = missingRequiredInput(entry, config, node.id);
74
+ if (missing) {
75
+ collectGap(node, config.use, missing, gaps, steps);
76
+ break;
77
+ }
78
+
79
+ const call = {
80
+ args: config.args,
81
+ context,
82
+ meta: parsed.meta,
83
+ node,
84
+ previous: steps,
85
+ config,
86
+ key: config.use,
87
+ };
88
+
89
+ if (entry.kind === 'task') {
90
+ try {
91
+ const output = await entry.run(call);
92
+ if (isGapSignal(output)) {
93
+ collectGap(node, config.use, output, gaps, steps);
94
+ break;
95
+ }
96
+
97
+ const storedAs = storageKeyFor(entry, config, node);
98
+ context[storedAs] = output;
99
+ steps.push({
100
+ type: 'task',
101
+ node,
102
+ entryKey: config.use,
103
+ args: config.args,
104
+ output,
105
+ storedAs,
106
+ });
107
+ current = (outgoing.get(node.id) ?? [])[0]?.to ?? '';
108
+ } catch (error) {
109
+ const runError = {
110
+ nodeId: node.id,
111
+ entryKey: config.use,
112
+ message: error instanceof Error ? error.message : String(error),
113
+ };
114
+ errors.push(runError);
115
+ steps.push({ type: 'error', node, entryKey: config.use, error: runError });
116
+ break;
117
+ }
118
+ } else {
119
+ let rawScore;
120
+ try {
121
+ rawScore = await entry.run(call);
122
+ if (isGapSignal(rawScore)) {
123
+ collectGap(node, config.use, rawScore, gaps, steps);
124
+ break;
125
+ }
126
+ } catch (error) {
127
+ const runError = {
128
+ nodeId: node.id,
129
+ entryKey: config.use,
130
+ message: error instanceof Error ? error.message : String(error),
131
+ };
132
+ errors.push(runError);
133
+ steps.push({ type: 'error', node, entryKey: config.use, error: runError });
134
+ break;
135
+ }
136
+
137
+ const result = normalizeScoreResult(rawScore, entry.threshold);
138
+ const role = expectedRole(node, parsed.graph.start) === 'scorer' && node.kind === 'decision' ? 'decision' : 'outcome';
139
+ /** @type {ScoreStep} */
140
+ const scoreStep = {
141
+ type: 'score',
142
+ node,
143
+ entryKey: config.use,
144
+ role,
145
+ args: config.args,
146
+ result,
147
+ };
148
+
149
+ if (node.kind === 'decision') {
150
+ const edge = selectDecisionEdge(outgoing.get(node.id) ?? [], result.pass);
151
+ if (edge?.label) scoreStep.branch = edge.label;
152
+ steps.push(scoreStep);
153
+ scores.push(scoreStep);
154
+ current = edge?.to ?? '';
155
+ } else {
156
+ steps.push(scoreStep);
157
+ scores.push(scoreStep);
158
+ current = (outgoing.get(node.id) ?? [])[0]?.to ?? '';
159
+ }
160
+ }
161
+ }
162
+
163
+ return {
164
+ ok: diagnostics.every((diagnostic) => diagnostic.severity !== 'error')
165
+ && gaps.length === 0
166
+ && errors.length === 0
167
+ && scores.filter((score) => score.role === 'outcome').every((score) => score.result.pass),
168
+ meta: parsed.meta,
169
+ context,
170
+ steps,
171
+ scores,
172
+ gaps,
173
+ errors,
174
+ diagnostics,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Parse and execute a journey directly against a reusable catalog.
180
+ *
181
+ * @param {string} text Markdown journey text.
182
+ * @param {Catalog} catalog Reusable catalog.
183
+ * @param {RunOptions} [options] Run options.
184
+ * @returns {Promise<RunResult>}
185
+ */
186
+ export function runText(text, catalog, options = {}) {
187
+ return run(parse(text), catalog, options);
188
+ }
189
+
190
+ /**
191
+ * Format diagnostics, gaps, runtime errors, and failed outcome scores for CLI
192
+ * output and generated node:test assertions.
193
+ *
194
+ * @param {RunResult} result Run result.
195
+ * @returns {string}
196
+ */
197
+ export function formatRunFailure(result) {
198
+ /** @type {string[]} */
199
+ const lines = [];
200
+
201
+ for (const diagnostic of result.diagnostics) {
202
+ if (diagnostic.severity === 'error') {
203
+ lines.push(`${diagnostic.code}${diagnostic.node ? ` [${diagnostic.node}]` : ''}: ${diagnostic.message}`);
204
+ }
205
+ }
206
+ for (const gap of result.gaps) {
207
+ lines.push(`gap ${gap.kind} [${gap.nodeId}]: ${gap.reason}`);
208
+ }
209
+ for (const error of result.errors) {
210
+ lines.push(`error [${error.nodeId}]: ${error.message}`);
211
+ }
212
+ for (const score of result.scores) {
213
+ if (score.role === 'outcome' && !score.result.pass) {
214
+ lines.push(`failed scorer [${score.node.id}]: ${score.result.message ?? 'score did not pass'}`);
215
+ }
216
+ }
217
+
218
+ return lines.length > 0 ? lines.join('\n') : 'journey did not pass';
219
+ }
220
+
221
+ /**
222
+ * @param {ParseResult} parsed
223
+ * @param {Diagnostic[]} diagnostics
224
+ * @returns {RunResult}
225
+ */
226
+ function emptyResult(parsed, diagnostics) {
227
+ return {
228
+ ok: false,
229
+ meta: parsed.meta,
230
+ context: {},
231
+ steps: [],
232
+ scores: [],
233
+ gaps: [],
234
+ errors: [],
235
+ diagnostics,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * @param {Diagnostic[]} diagnostics
241
+ * @returns {boolean}
242
+ */
243
+ function hasErrors(diagnostics) {
244
+ return diagnostics.some((diagnostic) => diagnostic.severity === 'error');
245
+ }
246
+
247
+ /**
248
+ * @param {Node} node
249
+ * @param {string} entryKey
250
+ * @param {GapSignal} signal
251
+ * @param {Gap[]} gaps
252
+ * @param {ExecutedStep[]} steps
253
+ */
254
+ function collectGap(node, entryKey, signal, gaps, steps) {
255
+ const gap = { nodeId: node.id, entryKey, kind: signal.kind, reason: signal.reason };
256
+ gaps.push(gap);
257
+ steps.push({ type: 'gap', node, entryKey, gap });
258
+ }
@@ -0,0 +1,142 @@
1
+ // @ts-check
2
+
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ /**
7
+ * @typedef {object} InitOptions
8
+ * @property {boolean} [force] Overwrite starter files when they already exist.
9
+ */
10
+
11
+ /**
12
+ * Create a project-local Melusine starter.
13
+ *
14
+ * @param {string} cwd Target project directory.
15
+ * @param {InitOptions} [options] Init options.
16
+ * @returns {Promise<string[]>} Files written or updated.
17
+ */
18
+ export async function initProject(cwd, options = {}) {
19
+ const catalogPath = join(cwd, 'melusine.catalog.js');
20
+ const journeyPath = join(cwd, 'journeys', 'example.journey.md');
21
+ const packagePath = join(cwd, 'package.json');
22
+ const packageJson = await readPackageJson(packagePath);
23
+ const force = options.force === true;
24
+ /** @type {string[]} */
25
+ const written = [];
26
+
27
+ if (!force) {
28
+ await assertMissing(catalogPath);
29
+ await assertMissing(journeyPath);
30
+ }
31
+
32
+ await writeNewFile(catalogPath, STARTER_CATALOG);
33
+ written.push(catalogPath);
34
+ await writeNewFile(journeyPath, STARTER_JOURNEY);
35
+ written.push(journeyPath);
36
+
37
+ if (packageJson) {
38
+ /** @type {Record<string, string>} */
39
+ const scripts = typeof packageJson.scripts === 'object'
40
+ && packageJson.scripts !== null
41
+ && !Array.isArray(packageJson.scripts)
42
+ ? /** @type {Record<string, string>} */ (packageJson.scripts)
43
+ : {};
44
+ scripts['melusine:validate'] = 'melusine validate journeys/example.journey.md --catalog melusine.catalog.js';
45
+ scripts['melusine:test'] = 'melusine test journeys/example.journey.md --catalog melusine.catalog.js';
46
+ packageJson.scripts = scripts;
47
+ await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8');
48
+ written.push(packagePath);
49
+ }
50
+
51
+ return written;
52
+ }
53
+
54
+ /**
55
+ * @param {string} path
56
+ * @returns {Promise<Record<string, unknown> | undefined>}
57
+ */
58
+ async function readPackageJson(path) {
59
+ try {
60
+ const pkg = JSON.parse(await readFile(path, 'utf8'));
61
+ if (typeof pkg !== 'object' || pkg === null || Array.isArray(pkg)) {
62
+ throw new Error('package.json must contain an object');
63
+ }
64
+ return pkg;
65
+ } catch (error) {
66
+ if (error && typeof error === 'object' && Reflect.get(error, 'code') === 'ENOENT') {
67
+ return undefined;
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * @param {string} path
75
+ */
76
+ async function assertMissing(path) {
77
+ try {
78
+ await readFile(path, 'utf8');
79
+ throw new Error(`refusing to overwrite existing file: ${path}`);
80
+ } catch (error) {
81
+ if (!(error && typeof error === 'object' && Reflect.get(error, 'code') === 'ENOENT')) {
82
+ throw error;
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param {string} path
89
+ * @param {string} text
90
+ */
91
+ async function writeNewFile(path, text) {
92
+ await mkdir(dirname(path), { recursive: true });
93
+ await writeFile(path, text, 'utf8');
94
+ }
95
+
96
+ const STARTER_CATALOG = `// @ts-check
97
+
98
+ import { scorer, task } from 'melusine';
99
+
100
+ export default {
101
+ createSubject: task(({ args }) => {
102
+ return { name: args[0], completed: false };
103
+ }, { as: 'subject', requiredArgs: 1 }),
104
+
105
+ completeSubject: task(({ context }) => {
106
+ const subject = context.subject;
107
+ if (!subject || typeof subject !== 'object') {
108
+ throw new Error('subject missing from context');
109
+ }
110
+ subject.completed = true;
111
+ return subject;
112
+ }),
113
+
114
+ subjectCompleted: scorer(({ context }) => {
115
+ return {
116
+ pass: context.subject?.completed === true,
117
+ actual: context.subject?.completed,
118
+ expected: true,
119
+ message: 'subject should be completed',
120
+ };
121
+ }),
122
+ };
123
+ `;
124
+
125
+ const STARTER_JOURNEY = `---
126
+ journey: melusine-starter
127
+ nodes:
128
+ createSubject:
129
+ args: ["starter"]
130
+ as: subject
131
+ ---
132
+
133
+ # Melusine starter journey
134
+
135
+ \`\`\`mermaid
136
+ graph TD
137
+ start(["Start"]) --> createSubject
138
+ createSubject["Create subject"] --> completeSubject
139
+ completeSubject["Complete subject"] --> subjectCompleted
140
+ subjectCompleted(["Subject completed"])
141
+ \`\`\`
142
+ `;