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/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
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -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
|
+
`;
|