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