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/cli.js ADDED
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { formatRunFailure, generateTest, parse, runText, validateCatalog } from './index.js';
8
+ import { initProject } from './scaffold.js';
9
+
10
+ /**
11
+ * @typedef {import('./types.js').Catalog} Catalog
12
+ * @typedef {import('./types.js').Diagnostic} Diagnostic
13
+ * @typedef {import('./types.js').Gap} Gap
14
+ * @typedef {import('./types.js').RunResult} RunResult
15
+ */
16
+
17
+ const HELP = `melusine - test Mermaid journey charts with reusable catalogs
18
+
19
+ Usage:
20
+ melusine validate <journey.md> [--catalog <catalog.js>] [--export <name>] [--json]
21
+ melusine test <journey.md> --catalog <catalog.js> [--export <name>] [--json]
22
+ melusine compile <journey.md> --catalog <catalog.js> [--export <name>] --out <file>
23
+ melusine init [--force]
24
+
25
+ Commands:
26
+ validate Parse and validate a chart. With --catalog, also validate catalog references.
27
+ test Execute a journey directly with a catalog.
28
+ compile Write a node:test wrapper that calls the same direct runner.
29
+ init Create a project-local starter catalog, journey, and package scripts.
30
+
31
+ Options:
32
+ -c, --catalog <file> ESM module exporting a catalog.
33
+ --export <name> Named catalog export to load.
34
+ -o, --out <file> Output file for compile.
35
+ --force Overwrite starter files during init.
36
+ --json Print machine-readable output.
37
+ -h, --help Show this help.
38
+
39
+ Catalog loading:
40
+ The CLI uses --export when provided. Otherwise it loads default, then catalog.
41
+
42
+ Examples:
43
+ melusine init
44
+ melusine validate journeys/example.journey.md --catalog melusine.catalog.js
45
+ melusine test journeys/example.journey.md --catalog melusine.catalog.js
46
+ melusine compile journeys/example.journey.md --catalog melusine.catalog.js --out journeys/example.test.mjs
47
+ `;
48
+
49
+ /**
50
+ * CLI entrypoint.
51
+ *
52
+ * @param {string[]} argv Raw process arguments.
53
+ * @param {{ cwd?: string, stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream }} [io]
54
+ * @returns {Promise<number>} Process exit code.
55
+ */
56
+ export async function main(argv = process.argv.slice(2), io = {}) {
57
+ const cwd = io.cwd ?? process.cwd();
58
+ const stdout = io.stdout ?? process.stdout;
59
+ const stderr = io.stderr ?? process.stderr;
60
+
61
+ try {
62
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
63
+ stdout.write(HELP);
64
+ return 0;
65
+ }
66
+
67
+ const command = argv[0];
68
+ const args = argv.slice(1);
69
+
70
+ if (command === 'validate') return validateCommand(args, { cwd, stdout, stderr });
71
+ if (command === 'test') return testCommand(args, { cwd, stdout, stderr });
72
+ if (command === 'compile') return compileCommand(args, { cwd, stdout, stderr });
73
+ if (command === 'init') return initCommand(args, { cwd, stdout });
74
+
75
+ stderr.write(`unknown command '${command}'\n\n${HELP}`);
76
+ return 1;
77
+ } catch (error) {
78
+ stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
79
+ return 1;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @param {string[]} argv
85
+ * @param {{ cwd: string, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream }} io
86
+ * @returns {Promise<number>}
87
+ */
88
+ async function validateCommand(argv, io) {
89
+ const parsedArgs = parseArgs(argv);
90
+ const journeyPath = parsedArgs.positionals[0];
91
+ if (!journeyPath) throw new Error('validate requires <journey.md>');
92
+
93
+ const absoluteJourneyPath = resolve(io.cwd, journeyPath);
94
+ const text = await readFile(absoluteJourneyPath, 'utf8');
95
+ const parsed = parse(text);
96
+ let diagnostics = parsed.diagnostics;
97
+
98
+ if (!hasErrors(diagnostics) && parsedArgs.flags.catalog) {
99
+ const catalog = await loadCatalog(resolve(io.cwd, parsedArgs.flags.catalog), parsedArgs.flags.exportName);
100
+ diagnostics = diagnostics.concat(validateCatalog(parsed.graph, parsed.bindings, catalog));
101
+ }
102
+
103
+ if (parsedArgs.flags.json) {
104
+ io.stdout.write(`${JSON.stringify({
105
+ ok: !hasErrors(diagnostics),
106
+ diagnostics,
107
+ nodeCount: parsed.graph.nodes.size,
108
+ edgeCount: parsed.graph.edges.length,
109
+ start: parsed.graph.start,
110
+ }, null, 2)}\n`);
111
+ } else {
112
+ reportDiagnostics(diagnostics, io.stderr);
113
+ if (!hasErrors(diagnostics)) io.stderr.write(`valid: ${journeyPath}\n`);
114
+ }
115
+
116
+ return hasErrors(diagnostics) ? 1 : 0;
117
+ }
118
+
119
+ /**
120
+ * @param {string[]} argv
121
+ * @param {{ cwd: string, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream }} io
122
+ * @returns {Promise<number>}
123
+ */
124
+ async function testCommand(argv, io) {
125
+ const parsedArgs = parseArgs(argv);
126
+ const journeyPath = parsedArgs.positionals[0];
127
+ if (!journeyPath) throw new Error('test requires <journey.md>');
128
+ if (!parsedArgs.flags.catalog) throw new Error('test requires --catalog <catalog.js>');
129
+
130
+ const text = await readFile(resolve(io.cwd, journeyPath), 'utf8');
131
+ const catalog = await loadCatalog(resolve(io.cwd, parsedArgs.flags.catalog), parsedArgs.flags.exportName);
132
+ const result = await runText(text, catalog);
133
+
134
+ if (parsedArgs.flags.json) {
135
+ io.stdout.write(`${JSON.stringify(toJsonRunResult(result), null, 2)}\n`);
136
+ } else if (result.ok) {
137
+ io.stderr.write(`passed: ${journeyPath}\n`);
138
+ } else {
139
+ reportRunResult(result, io.stderr);
140
+ }
141
+
142
+ return result.ok ? 0 : 1;
143
+ }
144
+
145
+ /**
146
+ * @param {string[]} argv
147
+ * @param {{ cwd: string, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream }} io
148
+ * @returns {Promise<number>}
149
+ */
150
+ async function compileCommand(argv, io) {
151
+ const parsedArgs = parseArgs(argv);
152
+ const journeyPath = parsedArgs.positionals[0];
153
+ if (!journeyPath) throw new Error('compile requires <journey.md>');
154
+ if (!parsedArgs.flags.catalog) throw new Error('compile requires --catalog <catalog.js>');
155
+ if (!parsedArgs.flags.out) throw new Error('compile requires --out <file>');
156
+
157
+ const absoluteJourneyPath = resolve(io.cwd, journeyPath);
158
+ const absoluteCatalogPath = resolve(io.cwd, parsedArgs.flags.catalog);
159
+ const absoluteOutPath = resolve(io.cwd, parsedArgs.flags.out);
160
+ const text = await readFile(absoluteJourneyPath, 'utf8');
161
+ const parsed = parse(text);
162
+
163
+ let diagnostics = parsed.diagnostics;
164
+ if (!hasErrors(diagnostics)) {
165
+ const catalog = await loadCatalog(absoluteCatalogPath, parsedArgs.flags.exportName);
166
+ diagnostics = diagnostics.concat(validateCatalog(parsed.graph, parsed.bindings, catalog));
167
+ }
168
+ reportDiagnostics(diagnostics, io.stderr);
169
+ if (hasErrors(diagnostics)) return 1;
170
+
171
+ const testName = typeof parsed.meta.journey === 'string' ? `journey: ${parsed.meta.journey}` : undefined;
172
+ const code = generateTest({
173
+ journeyPath: absoluteJourneyPath,
174
+ catalogPath: absoluteCatalogPath,
175
+ outPath: absoluteOutPath,
176
+ exportName: parsedArgs.flags.exportName,
177
+ testName,
178
+ });
179
+
180
+ await mkdir(dirname(absoluteOutPath), { recursive: true });
181
+ await writeFile(absoluteOutPath, code, 'utf8');
182
+ io.stderr.write(`compiled: ${absoluteOutPath}\n`);
183
+ return 0;
184
+ }
185
+
186
+ /**
187
+ * @param {string[]} argv
188
+ * @param {{ cwd: string, stdout: NodeJS.WritableStream }} io
189
+ * @returns {Promise<number>}
190
+ */
191
+ async function initCommand(argv, io) {
192
+ const parsedArgs = parseArgs(argv);
193
+ const written = await initProject(io.cwd, { force: parsedArgs.flags.force });
194
+ for (const path of written) {
195
+ io.stdout.write(`created: ${path}\n`);
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ /**
201
+ * @typedef {object} ParsedArgs
202
+ * @property {string[]} positionals
203
+ * @property {{ catalog?: string, exportName?: string, out?: string, force: boolean, json: boolean }} flags
204
+ */
205
+
206
+ /**
207
+ * @param {string[]} argv
208
+ * @returns {ParsedArgs}
209
+ */
210
+ function parseArgs(argv) {
211
+ /** @type {string[]} */
212
+ const positionals = [];
213
+ /** @type {ParsedArgs['flags']} */
214
+ const flags = { force: false, json: false };
215
+
216
+ for (let i = 0; i < argv.length; i += 1) {
217
+ const arg = argv[i];
218
+ if (arg === '-c' || arg === '--catalog') {
219
+ flags.catalog = requireValue(argv, i, arg);
220
+ i += 1;
221
+ } else if (arg === '-o' || arg === '--out') {
222
+ flags.out = requireValue(argv, i, arg);
223
+ i += 1;
224
+ } else if (arg === '--export' || arg === '--catalog-export') {
225
+ flags.exportName = requireValue(argv, i, arg);
226
+ i += 1;
227
+ } else if (arg === '--json') {
228
+ flags.json = true;
229
+ } else if (arg === '--force') {
230
+ flags.force = true;
231
+ } else if (arg.startsWith('-')) {
232
+ throw new Error(`unknown option '${arg}'`);
233
+ } else {
234
+ positionals.push(arg);
235
+ }
236
+ }
237
+
238
+ return { positionals, flags };
239
+ }
240
+
241
+ /**
242
+ * @param {string[]} argv
243
+ * @param {number} index
244
+ * @param {string} flag
245
+ * @returns {string}
246
+ */
247
+ function requireValue(argv, index, flag) {
248
+ const value = argv[index + 1];
249
+ if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value`);
250
+ return value;
251
+ }
252
+
253
+ /**
254
+ * @param {string} catalogPath
255
+ * @param {string | undefined} exportName
256
+ * @returns {Promise<Catalog>}
257
+ */
258
+ async function loadCatalog(catalogPath, exportName) {
259
+ const module = await import(pathToFileURL(catalogPath).href);
260
+ const candidate = exportName ? module[exportName] : (module.default ?? module.catalog);
261
+
262
+ if (!candidate) {
263
+ const suffix = exportName ? `export '${exportName}'` : 'a default export or named export `catalog`';
264
+ throw new Error(`catalog module must provide ${suffix}`);
265
+ }
266
+
267
+ return candidate;
268
+ }
269
+
270
+ /**
271
+ * @param {Diagnostic[]} diagnostics
272
+ * @param {NodeJS.WritableStream} stream
273
+ */
274
+ function reportDiagnostics(diagnostics, stream) {
275
+ for (const diagnostic of diagnostics) {
276
+ const node = diagnostic.node ? ` [${diagnostic.node}]` : '';
277
+ stream.write(`${diagnostic.severity} ${diagnostic.code}${node}: ${diagnostic.message}\n`);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * @param {RunResult} result
283
+ * @param {NodeJS.WritableStream} stream
284
+ */
285
+ function reportRunResult(result, stream) {
286
+ reportDiagnostics(result.diagnostics, stream);
287
+ for (const gap of result.gaps) {
288
+ stream.write(`gap ${gap.kind} [${gap.nodeId}]: ${gap.reason}\n`);
289
+ }
290
+ for (const error of result.errors) {
291
+ stream.write(`error [${error.nodeId}]: ${error.message}\n`);
292
+ }
293
+ for (const score of result.scores) {
294
+ if (score.role === 'outcome' && !score.result.pass) {
295
+ stream.write(`failed scorer [${score.node.id}]: ${score.result.message ?? 'score did not pass'}\n`);
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * @param {Diagnostic[]} diagnostics
302
+ * @returns {boolean}
303
+ */
304
+ function hasErrors(diagnostics) {
305
+ return diagnostics.some((diagnostic) => diagnostic.severity === 'error');
306
+ }
307
+
308
+ /**
309
+ * @param {RunResult} result
310
+ * @returns {object}
311
+ */
312
+ function toJsonRunResult(result) {
313
+ return {
314
+ ok: result.ok,
315
+ diagnostics: result.diagnostics,
316
+ gaps: result.gaps,
317
+ errors: result.errors,
318
+ scores: result.scores.map((score) => ({
319
+ nodeId: score.node.id,
320
+ entryKey: score.entryKey,
321
+ role: score.role,
322
+ branch: score.branch,
323
+ result: score.result,
324
+ })),
325
+ failure: result.ok ? undefined : formatRunFailure(result),
326
+ };
327
+ }
328
+
329
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
330
+ process.exitCode = await main();
331
+ }
package/src/compile.js ADDED
@@ -0,0 +1,3 @@
1
+ // @ts-check
2
+
3
+ export { run as compile, runText as compileText } from './run.js';
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+
3
+ import { basename, dirname, relative, sep } from 'node:path';
4
+
5
+ /**
6
+ * @typedef {import('./types.js').GenerateOptions} GenerateOptions
7
+ */
8
+
9
+ /**
10
+ * Generate a small node:test wrapper that calls Melusine's direct runner.
11
+ *
12
+ * @param {GenerateOptions} options Generation options.
13
+ * @returns {string}
14
+ */
15
+ export function generateTest(options) {
16
+ const packageName = options.packageName ?? 'melusine';
17
+ const outDir = options.outPath ? dirname(options.outPath) : process.cwd();
18
+ const journeySpecifier = toImportSpecifier(relative(outDir, options.journeyPath));
19
+ const catalogSpecifier = toImportSpecifier(relative(outDir, options.catalogPath));
20
+ const testName = options.testName ?? `journey: ${basename(options.journeyPath)}`;
21
+ const catalogExpression = options.exportName
22
+ ? `catalogModule[${JSON.stringify(options.exportName)}]`
23
+ : '(catalogModule.default ?? catalogModule.catalog)';
24
+
25
+ return [
26
+ `import test from 'node:test';`,
27
+ `import assert from 'node:assert/strict';`,
28
+ `import { readFile } from 'node:fs/promises';`,
29
+ `import { formatRunFailure, runText } from ${JSON.stringify(packageName)};`,
30
+ `import * as catalogModule from ${JSON.stringify(catalogSpecifier)};`,
31
+ ``,
32
+ `const catalog = ${catalogExpression};`,
33
+ `const journeyUrl = new URL(${JSON.stringify(journeySpecifier)}, import.meta.url);`,
34
+ ``,
35
+ `test(${JSON.stringify(testName)}, async () => {`,
36
+ ` assert.ok(catalog && typeof catalog === 'object', 'catalog module did not export a catalog object');`,
37
+ ` const text = await readFile(journeyUrl, 'utf8');`,
38
+ ` const result = await runText(text, catalog);`,
39
+ ` assert.equal(result.ok, true, formatRunFailure(result));`,
40
+ `});`,
41
+ ``,
42
+ ].join('\n');
43
+ }
44
+
45
+ /**
46
+ * @param {string} value
47
+ * @returns {string}
48
+ */
49
+ function toImportSpecifier(value) {
50
+ const normalized = value.split(sep).join('/');
51
+ return normalized.startsWith('.') ? normalized : `./${normalized}`;
52
+ }
package/src/index.js ADDED
@@ -0,0 +1,28 @@
1
+ // @ts-check
2
+
3
+ export { parse } from './parse.js';
4
+ export { task, scorer, todo, hole, normalizeScoreResult, validateCatalog } from './catalog.js';
5
+ export { run, runText, formatRunFailure } from './run.js';
6
+ export { compile, compileText } from './compile.js';
7
+ export { generateTest } from './generate.js';
8
+ export * from './types.js';
9
+
10
+ /**
11
+ * @typedef {import('./types.js').NodeKind} NodeKind
12
+ * @typedef {import('./types.js').Node} Node
13
+ * @typedef {import('./types.js').Edge} Edge
14
+ * @typedef {import('./types.js').JourneyGraph} JourneyGraph
15
+ * @typedef {import('./types.js').Binding} Binding
16
+ * @typedef {import('./types.js').Diagnostic} Diagnostic
17
+ * @typedef {import('./types.js').ParseResult} ParseResult
18
+ * @typedef {import('./types.js').NodeConfig} NodeConfig
19
+ * @typedef {import('./types.js').CatalogCall} CatalogCall
20
+ * @typedef {import('./types.js').TaskEntry} TaskEntry
21
+ * @typedef {import('./types.js').ScorerEntry} ScorerEntry
22
+ * @typedef {import('./types.js').CatalogEntry} CatalogEntry
23
+ * @typedef {import('./types.js').Catalog} Catalog
24
+ * @typedef {import('./types.js').ScoreResult} ScoreResult
25
+ * @typedef {import('./types.js').Gap} Gap
26
+ * @typedef {import('./types.js').RunResult} RunResult
27
+ * @typedef {import('./types.js').RunOptions} RunOptions
28
+ */
package/src/parse.js ADDED
@@ -0,0 +1,263 @@
1
+ // @ts-check
2
+
3
+ import YAML from 'yaml';
4
+ import { validate } from './validate.js';
5
+
6
+ /**
7
+ * @typedef {import('./types.js').Binding} Binding
8
+ * @typedef {import('./types.js').Diagnostic} Diagnostic
9
+ * @typedef {import('./types.js').Edge} Edge
10
+ * @typedef {import('./types.js').JourneyGraph} JourneyGraph
11
+ * @typedef {import('./types.js').Node} Node
12
+ * @typedef {import('./types.js').NodeKind} NodeKind
13
+ * @typedef {import('./types.js').ParseResult} ParseResult
14
+ */
15
+
16
+ const ID = '[A-Za-z][A-Za-z0-9_-]*';
17
+ const PROCESS_RE = new RegExp(`^(${ID})\\s*\\[\\s*(?:"([^"\`]*)"|([^\`\\]]*?))\\s*\\]$`, 'u');
18
+ const DECISION_RE = new RegExp(`^(${ID})\\s*\\{\\s*(?:"([^"\`]*)"|([^\`}]*?))\\s*\\}$`, 'u');
19
+ const TERMINAL_RE = new RegExp(`^(${ID})\\s*\\(\\s*\\[\\s*(?:"([^"\`]*)"|([^\`\\]]*?))\\s*\\]\\s*\\)$`, 'u');
20
+ const BARE_RE = new RegExp(`^${ID}$`, 'u');
21
+
22
+ /**
23
+ * Parse Markdown containing optional YAML frontmatter and the first Mermaid
24
+ * flowchart fence into the journey IR.
25
+ *
26
+ * Author mistakes are reported as diagnostics. This function only throws for
27
+ * ordinary JavaScript programmer errors outside the supported input contract.
28
+ *
29
+ * @param {string} text Markdown journey text.
30
+ * @returns {ParseResult}
31
+ */
32
+ export function parse(text) {
33
+ /** @type {Diagnostic[]} */
34
+ const diagnostics = [];
35
+ const split = splitFrontmatter(text);
36
+ const { bindings, meta } = parseFrontmatter(split.frontmatter, diagnostics);
37
+ const mermaid = extractMermaid(split.body);
38
+
39
+ if (!mermaid) {
40
+ return {
41
+ graph: { nodes: new Map(), edges: [], start: '' },
42
+ bindings,
43
+ meta,
44
+ diagnostics: diagnostics.concat({
45
+ severity: 'error',
46
+ code: 'JOURNEY_NO_MERMAID',
47
+ message: 'no mermaid code block found',
48
+ }),
49
+ };
50
+ }
51
+
52
+ const parsed = parseMermaid(mermaid, diagnostics);
53
+ const validationDiagnostics = validate(parsed.graph);
54
+ return {
55
+ graph: parsed.graph,
56
+ bindings,
57
+ meta,
58
+ diagnostics: diagnostics.concat(validationDiagnostics),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * @param {string} text
64
+ * @returns {{ frontmatter: string | undefined, body: string }}
65
+ */
66
+ function splitFrontmatter(text) {
67
+ if (!text.startsWith('---\n') && !text.startsWith('---\r\n')) {
68
+ return { frontmatter: undefined, body: text };
69
+ }
70
+
71
+ const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/u.exec(text);
72
+ if (!match) {
73
+ return { frontmatter: undefined, body: text };
74
+ }
75
+
76
+ return {
77
+ frontmatter: match[1],
78
+ body: text.slice(match[0].length),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * @param {string | undefined} frontmatter
84
+ * @param {Diagnostic[]} diagnostics
85
+ * @returns {{ bindings: Map<string, Binding>, meta: Record<string, unknown> }}
86
+ */
87
+ function parseFrontmatter(frontmatter, diagnostics) {
88
+ /** @type {Map<string, Binding>} */
89
+ const bindings = new Map();
90
+ /** @type {Record<string, unknown>} */
91
+ const meta = {};
92
+
93
+ if (frontmatter === undefined) {
94
+ return { bindings, meta };
95
+ }
96
+
97
+ try {
98
+ const document = YAML.parse(frontmatter) ?? {};
99
+ if (!isRecord(document)) {
100
+ return { bindings, meta };
101
+ }
102
+
103
+ for (const [key, value] of Object.entries(document)) {
104
+ if (key === 'nodes') {
105
+ if (isRecord(value)) {
106
+ for (const [nodeId, binding] of Object.entries(value)) {
107
+ bindings.set(nodeId, binding);
108
+ }
109
+ }
110
+ } else {
111
+ meta[key] = value;
112
+ }
113
+ }
114
+ } catch (error) {
115
+ diagnostics.push({
116
+ severity: 'error',
117
+ code: 'JOURNEY_FRONTMATTER_INVALID',
118
+ message: `frontmatter could not be parsed as YAML: ${error instanceof Error ? error.message : String(error)}`,
119
+ });
120
+ }
121
+
122
+ return { bindings, meta };
123
+ }
124
+
125
+ /**
126
+ * @param {string} body
127
+ * @returns {string | undefined}
128
+ */
129
+ function extractMermaid(body) {
130
+ const match = /```mermaid[ \t]*\r?\n([\s\S]*?)\r?\n```/iu.exec(body);
131
+ return match?.[1];
132
+ }
133
+
134
+ /**
135
+ * @param {string} mermaid
136
+ * @param {Diagnostic[]} diagnostics
137
+ * @returns {{ graph: JourneyGraph }}
138
+ */
139
+ function parseMermaid(mermaid, diagnostics) {
140
+ /** @type {Map<string, Node>} */
141
+ const nodes = new Map();
142
+ /** @type {Edge[]} */
143
+ const edges = [];
144
+ /** @type {Set<string>} */
145
+ const referencedBare = new Set();
146
+
147
+ for (const rawLine of mermaid.split(/\r?\n/u)) {
148
+ const line = rawLine.trim().replace(/;$/u, '');
149
+ if (!line || line.startsWith('%%')) continue;
150
+ if (/^(?:graph|flowchart)\s+(?:TD|LR)$/iu.test(line)) continue;
151
+
152
+ if (hasUnsupportedEdge(line)) {
153
+ diagnostics.push({
154
+ severity: 'warning',
155
+ code: 'JOURNEY_UNSUPPORTED_EDGE',
156
+ message: `unsupported edge syntax ignored: ${line}`,
157
+ });
158
+ continue;
159
+ }
160
+
161
+ const edge = parseEdge(line);
162
+ if (edge) {
163
+ if (edge.from.node) nodes.set(edge.from.node.id, edge.from.node);
164
+ else referencedBare.add(edge.from.id);
165
+
166
+ if (edge.to.node) nodes.set(edge.to.node.id, edge.to.node);
167
+ else referencedBare.add(edge.to.id);
168
+
169
+ edges.push({ from: edge.from.id, to: edge.to.id, ...(edge.label ? { label: edge.label } : {}) });
170
+ continue;
171
+ }
172
+
173
+ const term = parseTerm(line);
174
+ if (term?.node) {
175
+ nodes.set(term.node.id, term.node);
176
+ }
177
+ }
178
+
179
+ for (const id of referencedBare) {
180
+ if (!nodes.has(id)) {
181
+ nodes.set(id, { id, kind: 'process', label: id });
182
+ diagnostics.push({
183
+ severity: 'info',
184
+ code: 'JOURNEY_BARE_NODE',
185
+ message: `node '${id}' was referenced without a shape and defaulted to process`,
186
+ node: id,
187
+ });
188
+ }
189
+ }
190
+
191
+ return {
192
+ graph: { nodes, edges, start: '' },
193
+ };
194
+ }
195
+
196
+ /**
197
+ * @param {string} line
198
+ * @returns {boolean}
199
+ */
200
+ function hasUnsupportedEdge(line) {
201
+ return line.includes('-.->') || line.includes('==>') || (line.includes('---') && !line.includes('-->'));
202
+ }
203
+
204
+ /**
205
+ * @typedef {{ id: string, node?: Node }} ParsedTerm
206
+ * @typedef {{ from: ParsedTerm, to: ParsedTerm, label?: string }} ParsedEdge
207
+ */
208
+
209
+ /**
210
+ * @param {string} line
211
+ * @returns {ParsedEdge | undefined}
212
+ */
213
+ function parseEdge(line) {
214
+ const match = /^(.*?)\s*-->\s*(?:\|\s*([^|]+?)\s*\|\s*)?(.*?)$/u.exec(line);
215
+ if (!match) return undefined;
216
+
217
+ const from = parseTerm(match[1].trim());
218
+ const to = parseTerm(match[3].trim());
219
+ if (!from || !to) return undefined;
220
+
221
+ const label = match[2]?.trim();
222
+ return { from, to, ...(label ? { label } : {}) };
223
+ }
224
+
225
+ /**
226
+ * Parse either a shaped node term or a bare node id.
227
+ *
228
+ * @param {string} text
229
+ * @returns {ParsedTerm | undefined}
230
+ */
231
+ function parseTerm(text) {
232
+ const terminal = TERMINAL_RE.exec(text);
233
+ if (terminal) return termFromMatch(terminal, 'terminal');
234
+
235
+ const process = PROCESS_RE.exec(text);
236
+ if (process) return termFromMatch(process, 'process');
237
+
238
+ const decision = DECISION_RE.exec(text);
239
+ if (decision) return termFromMatch(decision, 'decision');
240
+
241
+ if (BARE_RE.test(text)) return { id: text };
242
+
243
+ return undefined;
244
+ }
245
+
246
+ /**
247
+ * @param {RegExpExecArray} match
248
+ * @param {NodeKind} kind
249
+ * @returns {ParsedTerm}
250
+ */
251
+ function termFromMatch(match, kind) {
252
+ const id = match[1];
253
+ const label = match[2] ?? match[3]?.trim() ?? id;
254
+ return { id, node: { id, kind, label } };
255
+ }
256
+
257
+ /**
258
+ * @param {unknown} value
259
+ * @returns {value is Record<string, unknown>}
260
+ */
261
+ function isRecord(value) {
262
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
263
+ }