deepline 0.2.53 → 0.2.55

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 (44) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +17 -1
  2. package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/sdk/src/types.ts +43 -1
  5. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +30 -1
  6. package/dist/bundling-sources/shared_libs/play-runtime/cell-provenance.ts +231 -0
  7. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1094 -128
  8. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +178 -9
  9. package/dist/bundling-sources/shared_libs/play-runtime/docflow-node-io.ts +634 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/docflow-observation.ts +64 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/dynamic-worker-version.ts +1 -1
  12. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +18 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/live-state-contract.ts +33 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/log-provenance.ts +251 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/play-node-scope.ts +160 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +6 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +27 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +43 -5
  19. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +12 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/local-process.ts +26 -4
  21. package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +6 -1
  22. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +83 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/worker-api-types.ts +3 -0
  24. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +49 -1
  25. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +375 -29
  26. package/dist/bundling-sources/shared_libs/plays/docflow-binding-owner.ts +636 -0
  27. package/dist/bundling-sources/shared_libs/plays/docflow-binding.ts +598 -0
  28. package/dist/bundling-sources/shared_libs/plays/docflow.ts +1645 -0
  29. package/dist/bundling-sources/shared_libs/plays/play-exports.ts +202 -0
  30. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +16 -1
  31. package/dist/bundling-sources/shared_libs/plays/ts-ast.ts +48 -0
  32. package/dist/cli/index.js +994 -312
  33. package/dist/cli/index.mjs +994 -312
  34. package/dist/{compiler-manifest-Cj3--4ZJ.d.mts → compiler-manifest-Bl8kmLx9.d.mts} +118 -0
  35. package/dist/{compiler-manifest-Cj3--4ZJ.d.ts → compiler-manifest-Bl8kmLx9.d.ts} +118 -0
  36. package/dist/index.d.mts +47 -2
  37. package/dist/index.d.ts +47 -2
  38. package/dist/index.js +419 -59
  39. package/dist/index.mjs +419 -59
  40. package/dist/install-integrity.json +12 -2
  41. package/dist/plays/bundle-play-file.d.mts +2 -2
  42. package/dist/plays/bundle-play-file.d.ts +2 -2
  43. package/dist/plays/bundle-play-file.mjs +1361 -45
  44. package/package.json +1 -1
@@ -0,0 +1,1645 @@
1
+ import {
2
+ canonicalPlayExportName,
3
+ isDefinePlayCall,
4
+ listPlayFileExports,
5
+ playExportNamesForMessage,
6
+ PLAY_DEFAULT_EXPORT,
7
+ } from './play-exports';
8
+ import {
9
+ astArray,
10
+ isAstNode,
11
+ parsePlaySourceForAnalysis,
12
+ type AstNode,
13
+ } from './ts-ast';
14
+
15
+ export type PlayDocflowNodeKind =
16
+ | 'action'
17
+ | 'decision'
18
+ | 'dataset'
19
+ | 'play'
20
+ | 'conceptual';
21
+
22
+ /**
23
+ * The valid `type:"…"` values a `// @mermaid-node` annotation may carry, in the
24
+ * order the author should think about them (the concrete work kinds first, the
25
+ * presentation-only kind last). The single source of truth for both the runtime
26
+ * validator ({@link nodeKind}) and the "unsupported type" diagnostic, so the
27
+ * error message can never enumerate a set that drifts from what is accepted.
28
+ */
29
+ export const PLAY_DOCFLOW_NODE_KINDS = [
30
+ 'action',
31
+ 'decision',
32
+ 'dataset',
33
+ 'play',
34
+ 'conceptual',
35
+ ] as const satisfies readonly PlayDocflowNodeKind[];
36
+
37
+ export type PlayDocflowNode = {
38
+ id: string;
39
+ label: string;
40
+ kind: PlayDocflowNodeKind;
41
+ };
42
+
43
+ /**
44
+ * Which arm of a conditional a drawn decision edge IS.
45
+ *
46
+ * The runtime's own two-valued vocabulary for a `runIf` (ADR 0019): the cell
47
+ * record says `branch: 'run' | 'else'`, and this is the same token on the
48
+ * diagram's side of the join. Deliberately NOT the arm's label — a label is the
49
+ * author's prose ("fit 65 or better", "nicht gefunden") and says nothing about
50
+ * polarity in any language.
51
+ */
52
+ export type PlayDocflowArm = 'run' | 'else';
53
+
54
+ export const PLAY_DOCFLOW_ARMS = [
55
+ 'run',
56
+ 'else',
57
+ ] as const satisfies readonly PlayDocflowArm[];
58
+
59
+ export type PlayDocflowEdge = {
60
+ from: string;
61
+ to: string;
62
+ label?: string;
63
+ /**
64
+ * The conditional arm this edge is, when the author recorded it.
65
+ *
66
+ * ABSENT — never `null` — when unrecorded, and that is load-bearing rather
67
+ * than stylistic. `docflow` is whole-object serialized into
68
+ * `playStaticPipelineContractHash` (`src/lib/plays/artifact-storage.ts`),
69
+ * which is part of the immutable artifact storage key, and the canonicalizer
70
+ * there drops `undefined` but HASHES `null`. Emitting `arm: null` on an
71
+ * unannotated edge would change the contract hash of every diagrammed play
72
+ * ever published and force a republish. Omission is what keeps this additive.
73
+ */
74
+ arm?: PlayDocflowArm;
75
+ };
76
+
77
+ /**
78
+ * A Mermaid `subgraph … end` region. When an edge connects it to a dataset
79
+ * node, it models that dataset's per-row loop; its members represent the
80
+ * per-row column work. See `docs/play-syntax-spec.md`. `memberIds` records the
81
+ * innermost subgraph for nested regions.
82
+ */
83
+ export type PlayDocflowSubgraph = {
84
+ id: string;
85
+ label: string;
86
+ memberIds: string[];
87
+ };
88
+
89
+ export type PlayDocflowBinding = {
90
+ nodeId: string;
91
+ line: number;
92
+ label?: string;
93
+ kind?: PlayDocflowNodeKind;
94
+ /** Symbolic values read by this business node. Never an arbitrary JS expression. */
95
+ inputs?: string[];
96
+ /** Symbolic values produced or changed by this business node. */
97
+ outputs?: string[];
98
+ /** Whether the contract was authored, safely inferred, or still needs help. */
99
+ ioConfidence?: 'explicit' | 'inferred' | 'ambiguous';
100
+ /**
101
+ * `arm:"run"` / `arm:"else"` — this node is that arm of the decision above it.
102
+ *
103
+ * Recorded on the annotation because the annotation is the only place the two
104
+ * halves of the join meet: a `@mermaid-node` binds a DIAGRAM id to the SOURCE
105
+ * statement directly beneath it, so the author writing it is the one person
106
+ * who knows both which drawn arm this is and which side of the `runIf` the
107
+ * code under it implements. Projected onto the incoming decision edge by
108
+ * {@link attachBindingsToBlocks}; the edge is what readers resolve against.
109
+ */
110
+ arm?: PlayDocflowArm;
111
+ };
112
+
113
+ export type PlayDocflow = {
114
+ direction: 'LR' | 'RL' | 'TB' | 'TD' | 'BT';
115
+ nodes: PlayDocflowNode[];
116
+ edges: PlayDocflowEdge[];
117
+ bindings: PlayDocflowBinding[];
118
+ /** Authoring syntax used by the source file. Absent on older persisted graphs. */
119
+ syntax?: 'docflow' | 'mermaid';
120
+ /** Normalized Mermaid text, ready to pass directly to a Mermaid renderer. */
121
+ mermaidSource?: string;
122
+ /**
123
+ * Mermaid `subgraph` loop regions. Optional so older persisted graphs stay
124
+ * valid. Only populated for the Mermaid syntax; legacy `docflow` has none.
125
+ */
126
+ subgraphs?: PlayDocflowSubgraph[];
127
+ /** Mermaid directives accepted by the parser but not applied by React Flow. */
128
+ ignoredDirectives?: string[];
129
+ };
130
+
131
+ export type PlayDocflowParseResult = {
132
+ docflow: PlayDocflow | null;
133
+ errors: string[];
134
+ };
135
+
136
+ export type ParsePlayDocflowOptions = {
137
+ /**
138
+ * Which exported play's diagram to return. Defaults to the file's default
139
+ * export. Aliases resolve: a file ending `export default scalar` answers to
140
+ * both `scalar` and `default`.
141
+ */
142
+ exportName?: string | null;
143
+ };
144
+
145
+ /** One `@mermaid` block, already bound to the export it describes. */
146
+ export type PlayDocflowBlock = {
147
+ /** Canonical export name — `default` for an unnamed block. */
148
+ exportName: string;
149
+ docflow: PlayDocflow;
150
+ };
151
+
152
+ export type PlayDocflowFileParseResult = {
153
+ blocks: PlayDocflowBlock[];
154
+ /**
155
+ * Every `// @mermaid-node` binding in the file, across all blocks. Runtime
156
+ * instrumentation wraps all of them: two exports' statements are disjoint, so
157
+ * instrumenting both is correct for whichever one actually runs, and it means
158
+ * the bundler never has to know which export it is building.
159
+ */
160
+ bindings: PlayDocflowBinding[];
161
+ errors: string[];
162
+ };
163
+
164
+ export type PlayDocflowLintIssue = {
165
+ code:
166
+ | 'docflow_branch_labels_required'
167
+ | 'docflow_branch_requires_decision'
168
+ | 'docflow_direction_not_top_down'
169
+ | 'docflow_layout_complexity'
170
+ | 'docflow_topology_invalid'
171
+ | 'docflow_io_ambiguous'
172
+ | 'docflow_input_not_found'
173
+ | 'docflow_output_not_found'
174
+ | 'docflow_label_counts_rows'
175
+ | 'docflow_directive_ignored';
176
+ severity: 'error' | 'warning';
177
+ message: string;
178
+ path?: string;
179
+ hint?: string;
180
+ };
181
+
182
+ export class PlayDocflowCompileError extends Error {
183
+ readonly diagnostics: string[];
184
+
185
+ constructor(diagnostics: string[]) {
186
+ super(diagnostics.join(' '));
187
+ this.name = 'PlayDocflowCompileError';
188
+ this.diagnostics = diagnostics;
189
+ }
190
+ }
191
+
192
+ const DOCFLOW_MAX_NODES = 12;
193
+ const DOCFLOW_MAX_BRANCHES = 3;
194
+ const DOCFLOW_MAX_LABEL_LENGTH = 48;
195
+
196
+ // A node label names the thing; the runtime counts it. A label that bakes a
197
+ // magnitude in ("8k seed rows", "10,000 rows", "500 leads") goes stale the first
198
+ // time the input changes, and then the canvas shows the authored number beside
199
+ // the live one — the same quantity in two voices. Two shapes are detectable
200
+ // without guessing at prose:
201
+ // 1. a scale-suffixed magnitude — `8k`, `20K`, `1.5M`
202
+ // 2. a number attached to a counted noun — `10,000 rows`, `500 leads`
203
+ // Deliberately NOT matched: bare numerals inside names ("SOC 2 signals",
204
+ // "Series B", "V2 pipeline"), where the digit is part of the thing's name
205
+ // rather than a count of it.
206
+ const LABEL_SCALE_MAGNITUDE = /\b\d[\d,.]*\s*[kKmM]\b/;
207
+ const LABEL_COUNTED_NOUN =
208
+ /\b\d[\d,]*\s*(rows?|leads?|records?|contacts?|companies|accounts?|items?|results?|seeds?|emails?|domains?|profiles?)\b/i;
209
+
210
+ /** The magnitude fragment a node label bakes in, or null when the label is
211
+ * count-free. Exported so the lint's message can quote the exact fragment and
212
+ * so tests pin the detector independently of the lint plumbing. */
213
+ export function docflowLabelCountFragment(label: string): string | null {
214
+ return (
215
+ LABEL_SCALE_MAGNITUDE.exec(label)?.[0]?.trim() ??
216
+ LABEL_COUNTED_NOUN.exec(label)?.[0]?.trim() ??
217
+ null
218
+ );
219
+ }
220
+
221
+ const FLOW_START = /^\s*(?:flowchart|graph)\s+(LR|RL|TB|TD|BT)\s*$/i;
222
+ const PUT = /^\s*\/\/\s*put\s+(.+)$/;
223
+ const MERMAID_NODE = /^\s*\/\/\s*@mermaid-node\s+([A-Za-z][\w-]*)(?:\s+(.*))?$/;
224
+ const ATTRIBUTE = /([A-Za-z][\w-]*)\s*:\s*"([^"\n]*)"/y;
225
+ const MERMAID_NODE_ATTRIBUTES = ['label', 'type', 'in', 'out', 'arm'] as const;
226
+ const LEGACY_DOCFLOW_ATTRIBUTES = ['id', ...MERMAID_NODE_ATTRIBUTES] as const;
227
+ const MERMAID_NODE_ID = /^[A-Za-z][\w-]*/;
228
+ /**
229
+ * Mermaid's node shapes, longest opener FIRST.
230
+ *
231
+ * The order is the contract, not a formatting choice: the parser takes the
232
+ * first opener that matches at the cursor, so every multi-character opener has
233
+ * to precede the single-character one it starts with. `([` was missing from
234
+ * this list while `[(` was present, so `proxy(["Fall back to a growth proxy"])`
235
+ * — mermaid's stadium shape, and a shape this file's own SHAPE_KINDS docstring
236
+ * already names — matched the bare `(`, took its label as everything up to the
237
+ * next `)`, and rendered the node with its own source syntax as the label:
238
+ *
239
+ * ["Fall back to a growth proxy"]
240
+ *
241
+ * brackets and quotes included, on the canvas, in a shipped prebuilt. The fix
242
+ * belongs here rather than in a renderer that strips brackets: a label may
243
+ * legitimately contain one, and a canvas that launders bad parses cannot tell
244
+ * anyone the parse was bad.
245
+ */
246
+ const MERMAID_NODE_SHAPES = [
247
+ ['[[', ']]'],
248
+ ['[(', ')]'],
249
+ ['([', '])'],
250
+ ['{{', '}}'],
251
+ ['((', '))'],
252
+ ['[/', '/]'],
253
+ ['[\\', '\\]'],
254
+ ['[/', '\\]'],
255
+ ['[\\', '/]'],
256
+ ['[', ']'],
257
+ ['{', '}'],
258
+ ['(', ')'],
259
+ ['>', ']'],
260
+ ] as const;
261
+ // `subgraph <id>`, `subgraph <id>["Label"]`, or `subgraph <id>[Label]`. The
262
+ // label bracket is optional; when present the quotes are stripped by the caller.
263
+ const SUBGRAPH_OPEN = /^\s*subgraph\s+([A-Za-z][\w-]*)\s*(?:\[(.*)\])?\s*$/i;
264
+ const SUBGRAPH_END = /^\s*end\s*$/i;
265
+ const DOCFLOW_EDGE_CONNECTOR = /^\s*-->(?:\|([^|]+)\|)?/;
266
+ const MERMAID_EDGE_CONNECTOR = /^\s*(?:-->|-\.->|==>|---)(?:\|([^|]+)\|)?/;
267
+ const DOCFLOW_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
268
+ const IDENTIFIER = /[A-Za-z_$][\w$]*/g;
269
+ const JS_WORDS = new Set([
270
+ 'async',
271
+ 'await',
272
+ 'const',
273
+ 'else',
274
+ 'false',
275
+ 'if',
276
+ 'let',
277
+ 'new',
278
+ 'null',
279
+ 'return',
280
+ 'true',
281
+ 'undefined',
282
+ 'var',
283
+ ]);
284
+
285
+ function parseAttributes(
286
+ source: string,
287
+ input: {
288
+ line: number;
289
+ annotation: '@mermaid-node' | 'put';
290
+ errors: string[];
291
+ },
292
+ ): Record<string, string> | null {
293
+ const attributes: Record<string, string> = {};
294
+ const validAttributes =
295
+ input.annotation === '@mermaid-node'
296
+ ? MERMAID_NODE_ATTRIBUTES
297
+ : LEGACY_DOCFLOW_ATTRIBUTES;
298
+ let cursor = 0;
299
+ while (cursor < source.length) {
300
+ cursor += /^\s*/.exec(source.slice(cursor))?.[0].length ?? 0;
301
+ if (cursor >= source.length) break;
302
+ ATTRIBUTE.lastIndex = cursor;
303
+ const match = ATTRIBUTE.exec(source);
304
+ if (!match) {
305
+ const fragment =
306
+ source.slice(cursor).split(/\s+/)[0] ?? source.slice(cursor);
307
+ input.errors.push(
308
+ `Docflow annotation on line ${input.line} has malformed attribute ${JSON.stringify(fragment)}. Use key:"value" pairs. Valid attributes: ${validAttributes.map((name) => `"${name}"`).join(', ')}.`,
309
+ );
310
+ return null;
311
+ }
312
+ const name = match[1]!;
313
+ if (!(validAttributes as readonly string[]).includes(name)) {
314
+ input.errors.push(
315
+ `Docflow annotation on line ${input.line} has unknown attribute "${name}". Valid attributes: ${validAttributes.map((option) => `"${option}"`).join(', ')}.`,
316
+ );
317
+ return null;
318
+ }
319
+ if (Object.prototype.hasOwnProperty.call(attributes, name)) {
320
+ input.errors.push(
321
+ `Docflow annotation on line ${input.line} repeats attribute "${name}". Write each attribute once.`,
322
+ );
323
+ return null;
324
+ }
325
+ attributes[name] = match[2]!;
326
+ cursor = ATTRIBUTE.lastIndex;
327
+ }
328
+ return attributes;
329
+ }
330
+
331
+ function nodeKind(
332
+ value: string | undefined,
333
+ ): PlayDocflowNodeKind | null | undefined {
334
+ if (!value) return undefined;
335
+ return (PLAY_DOCFLOW_NODE_KINDS as readonly string[]).includes(value)
336
+ ? (value as PlayDocflowNodeKind)
337
+ : null;
338
+ }
339
+
340
+ /**
341
+ * `undefined` when unwritten, `null` when written as something that is not an
342
+ * arm. The two are different answers and the caller must not collapse them: an
343
+ * unwritten attribute is a play that says nothing, a misspelled one is a play
344
+ * whose author tried to say something and must be told it did not land.
345
+ */
346
+ function nodeArm(value: string | undefined): PlayDocflowArm | null | undefined {
347
+ if (!value) return undefined;
348
+ return (PLAY_DOCFLOW_ARMS as readonly string[]).includes(value)
349
+ ? (value as PlayDocflowArm)
350
+ : null;
351
+ }
352
+
353
+ /**
354
+ * Shapes that carry their kind in the drawing, so an author who never writes a
355
+ * `type:"…"` attribute still gets the right node. Mermaid's own vocabulary
356
+ * decides these: `{…}` is the decision rhombus everywhere, and `[[…]]` is the
357
+ * subroutine box — the shape whose entire meaning in flowchart notation is "a
358
+ * call into a process defined elsewhere", which is exactly what `ctx.runPlay`
359
+ * is. Every other shape stays `action` unless the annotation names a kind,
360
+ * because `[(…)]` (dataset) and `([…])` (conceptual) each have a binding
361
+ * contract the drawing alone cannot establish.
362
+ */
363
+ const SHAPE_KINDS: Record<string, PlayDocflowNodeKind> = {
364
+ '{': 'decision',
365
+ '[[': 'play',
366
+ };
367
+
368
+ type ParsedEdgeNode = {
369
+ id: string;
370
+ label: string;
371
+ kind: PlayDocflowNodeKind;
372
+ };
373
+
374
+ type ParsedMermaidNode = ParsedEdgeNode & {
375
+ length: number;
376
+ shaped: boolean;
377
+ /**
378
+ * The shape never closed — `foo["…` with no `"]`.
379
+ *
380
+ * Carried rather than returned as `null`, because `null` here used to mean
381
+ * "no node starts at this offset", and an unterminated shape was folded into
382
+ * the same answer: the declaration vanished, the id survived as a bare edge
383
+ * endpoint, and the node reached the canvas labelled with its own id. A parse
384
+ * that could not finish must say so, not shrug.
385
+ */
386
+ unterminated?: { open: string; close: string };
387
+ };
388
+
389
+ /**
390
+ * Openers a parsed label must never still begin with.
391
+ *
392
+ * A label that survives parsing with its own delimiters attached
393
+ * (`["Fall back to a growth proxy"]`) is a parse that fell through to raw text
394
+ * — the shape was not recognised, so the "label" is really source. That shipped
395
+ * to the canvas once; it is an error now.
396
+ */
397
+ const MERMAID_LABEL_LOOKS_LIKE_SOURCE = /^(?:\[|\{|\(|>)/;
398
+
399
+ function parseMermaidNodeAtStart(source: string): ParsedMermaidNode | null {
400
+ const leadingWhitespace = /^\s*/.exec(source)?.[0].length ?? 0;
401
+ const idMatch = MERMAID_NODE_ID.exec(source.slice(leadingWhitespace));
402
+ if (!idMatch) return null;
403
+ const id = idMatch[0];
404
+ let cursor = leadingWhitespace + id.length;
405
+ cursor += /^\s*/.exec(source.slice(cursor))?.[0].length ?? 0;
406
+ const shape = MERMAID_NODE_SHAPES.find(([open]) =>
407
+ source.startsWith(open, cursor),
408
+ );
409
+ if (!shape) {
410
+ return { id, label: id, kind: 'action', length: cursor, shaped: false };
411
+ }
412
+ const [open, close] = shape;
413
+ const labelStart = cursor + open.length;
414
+ // Mermaid's quoted labels may contain the same punctuation that closes the
415
+ // surrounding shape: `step["Validate [required] fields"]` is ordinary
416
+ // Mermaid, not a nested node. `indexOf(close)` used to stop at the bracket in
417
+ // `required]`, accept the declaration, and silently ship the truncated label
418
+ // `"Validate [required` to the React Flow canvas. When a label opens with a
419
+ // quote, only a shape closer after its matching (unescaped) quote may end it.
420
+ const openingQuote = source[labelStart];
421
+ let labelEnd = -1;
422
+ if (openingQuote === '"' || openingQuote === "'") {
423
+ for (let index = labelStart + 1; index < source.length; index += 1) {
424
+ if (source[index] === '\\') {
425
+ index += 1;
426
+ continue;
427
+ }
428
+ if (source[index] !== openingQuote) continue;
429
+ const whitespace = /^\s*/.exec(source.slice(index + 1))?.[0].length ?? 0;
430
+ const candidate = index + 1 + whitespace;
431
+ if (source.startsWith(close, candidate)) {
432
+ labelEnd = candidate;
433
+ break;
434
+ }
435
+ }
436
+ } else {
437
+ labelEnd = source.indexOf(close, labelStart);
438
+ }
439
+ if (labelEnd < 0) {
440
+ return {
441
+ id,
442
+ label: id,
443
+ kind: 'action',
444
+ length: cursor + open.length,
445
+ shaped: false,
446
+ unterminated: { open, close },
447
+ };
448
+ }
449
+ const rawLabel = source.slice(labelStart, labelEnd).trim();
450
+ const label =
451
+ rawLabel.length >= 2 &&
452
+ ((rawLabel.startsWith('"') && rawLabel.endsWith('"')) ||
453
+ (rawLabel.startsWith("'") && rawLabel.endsWith("'")))
454
+ ? rawLabel.slice(1, -1)
455
+ : rawLabel;
456
+ return {
457
+ id,
458
+ label: label || id,
459
+ kind: SHAPE_KINDS[open] ?? 'action',
460
+ length: labelEnd + close.length,
461
+ shaped: true,
462
+ };
463
+ }
464
+
465
+ function parseShapedMermaidNodes(fragment: string): ParsedMermaidNode[] {
466
+ const nodes: ParsedMermaidNode[] = [];
467
+ let cursor = 0;
468
+ while (cursor < fragment.length) {
469
+ const identifier = /[A-Za-z][\w-]*/.exec(fragment.slice(cursor));
470
+ if (!identifier) break;
471
+ const start = cursor + (identifier.index ?? 0);
472
+ const parsed = parseMermaidNodeAtStart(fragment.slice(start));
473
+ if (parsed?.shaped || parsed?.unterminated) nodes.push(parsed);
474
+ cursor = start + Math.max(parsed?.length ?? identifier[0].length, 1);
475
+ }
476
+ return nodes;
477
+ }
478
+
479
+ function parseEdgeChain(
480
+ line: string,
481
+ syntax: 'docflow' | 'mermaid',
482
+ ): { nodes: ParsedEdgeNode[]; labels: Array<string | undefined> } | null {
483
+ let remaining = line;
484
+ const first = parseMermaidNodeAtStart(remaining);
485
+ if (!first) return null;
486
+ const nodes: ParsedEdgeNode[] = [first];
487
+ const labels: Array<string | undefined> = [];
488
+ remaining = remaining.slice(first.length);
489
+ while (remaining.trim()) {
490
+ const connector = (
491
+ syntax === 'mermaid' ? MERMAID_EDGE_CONNECTOR : DOCFLOW_EDGE_CONNECTOR
492
+ ).exec(remaining);
493
+ if (!connector) return null;
494
+ remaining = remaining.slice(connector[0].length);
495
+ const next = parseMermaidNodeAtStart(remaining);
496
+ if (!next) return null;
497
+ labels.push(connector[1]?.trim() || undefined);
498
+ nodes.push(next);
499
+ remaining = remaining.slice(next.length);
500
+ }
501
+ return nodes.length > 1 ? { nodes, labels } : null;
502
+ }
503
+
504
+ function parseContractPaths(
505
+ value: string | undefined,
506
+ attribute: 'in' | 'out',
507
+ nodeId: string,
508
+ line: number,
509
+ errors: string[],
510
+ ): string[] | undefined {
511
+ if (value === undefined) return undefined;
512
+ const paths = value
513
+ .split(',')
514
+ .map((path) => path.trim())
515
+ .filter(Boolean);
516
+ if (paths.some((path) => !DOCFLOW_PATH.test(path))) {
517
+ errors.push(
518
+ `Docflow annotation "${nodeId}" on line ${line} has an invalid ${attribute}:"…" contract. Use comma-separated identifiers or property paths only.`,
519
+ );
520
+ return undefined;
521
+ }
522
+ return [...new Set(paths)];
523
+ }
524
+
525
+ function inferBindingIo(
526
+ statement: string,
527
+ ): Pick<PlayDocflowBinding, 'inputs' | 'outputs' | 'ioConfidence'> {
528
+ const returned = /^return\s+(.+?);?\s*$/.exec(statement.trim());
529
+ if (returned) {
530
+ const inputs = new Set<string>();
531
+ const expression = returned[1]!.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, '');
532
+ IDENTIFIER.lastIndex = 0;
533
+ for (const match of expression.matchAll(IDENTIFIER)) {
534
+ const identifier = match[0]!;
535
+ const index = match.index ?? 0;
536
+ if (JS_WORDS.has(identifier) || expression[index - 1] === '.') continue;
537
+ inputs.add(identifier);
538
+ }
539
+ return {
540
+ inputs: [...inputs],
541
+ ioConfidence: 'inferred',
542
+ };
543
+ }
544
+ const assignment =
545
+ /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;]+);?\s*$/.exec(
546
+ statement.trim(),
547
+ );
548
+ if (!assignment || assignment[2]!.includes('await')) {
549
+ return { ioConfidence: 'ambiguous' };
550
+ }
551
+ const output = assignment[1]!;
552
+ const expression = assignment[2]!;
553
+ const inputs = new Set<string>();
554
+ IDENTIFIER.lastIndex = 0;
555
+ for (const match of expression.matchAll(IDENTIFIER)) {
556
+ const identifier = match[0]!;
557
+ const index = match.index ?? 0;
558
+ const previous = expression[index - 1] ?? '';
559
+ const next = expression[index + identifier.length] ?? '';
560
+ // Function names, object-property keys, and language words are not values
561
+ // flowing into this node. This deliberately keeps inference narrow.
562
+ if (
563
+ JS_WORDS.has(identifier) ||
564
+ identifier === output ||
565
+ previous === '.' ||
566
+ next === ':' ||
567
+ /^\s*\(/.test(expression.slice(index + identifier.length))
568
+ ) {
569
+ continue;
570
+ }
571
+ inputs.add(identifier);
572
+ }
573
+ return {
574
+ inputs: [...inputs],
575
+ outputs: [output],
576
+ ioConfidence: 'inferred',
577
+ };
578
+ }
579
+
580
+ function rootPath(path: string): string {
581
+ return path.split('.')[0]!;
582
+ }
583
+
584
+ function levenshtein(left: string, right: string): number {
585
+ const row = Array.from({ length: right.length + 1 }, (_, index) => index);
586
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
587
+ let previous = row[0]!;
588
+ row[0] = leftIndex;
589
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
590
+ const current = row[rightIndex]!;
591
+ row[rightIndex] = Math.min(
592
+ row[rightIndex - 1]! + 1,
593
+ row[rightIndex]! + 1,
594
+ previous + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
595
+ );
596
+ previous = current;
597
+ }
598
+ }
599
+ return row[right.length]!;
600
+ }
601
+
602
+ const MERMAID_BLOCK = /\/\*\*\s*@mermaid(?:\s|\r?\n)([\s\S]*?)\*\//g;
603
+ const LEGACY_BLOCK = /\/\*\*\s*@docflow(?:\s|\r?\n)([\s\S]*?)\*\//;
604
+ const BLOCK_EXPORT_HEADER = /^[A-Za-z_$][\w$]*$/;
605
+
606
+ /** Strips the JSDoc `*` gutter and trims, leaving the authored block text. */
607
+ function cleanBlockBody(raw: string): string {
608
+ return raw
609
+ .split(/\r?\n/)
610
+ .map((line) => (/^\s*\*/.test(line) ? line.replace(/^\s*\* ?/, '') : line))
611
+ .join('\n')
612
+ .trim();
613
+ }
614
+
615
+ /**
616
+ * Splits `/** @mermaid batch` into the export it names and the diagram itself.
617
+ *
618
+ * The name is the first token after the tag, the same place `// @mermaid-node
619
+ * <id>` puts its target, so one rule covers both halves of the grammar. It is
620
+ * only a name when it stands alone on its line: `@mermaid flowchart TD` has a
621
+ * remainder, so it stays what it always was — the first line of the diagram.
622
+ * `flowchart`/`graph` alone are the diagram too; nobody exports a play by
623
+ * those names, and reading them as one would turn a working file into an
624
+ * "unknown export" error.
625
+ */
626
+ function splitBlockExportHeader(body: string): {
627
+ exportName: string | null;
628
+ diagram: string;
629
+ } {
630
+ const newlineIndex = body.indexOf('\n');
631
+ const firstLine = (
632
+ newlineIndex < 0 ? body : body.slice(0, newlineIndex)
633
+ ).trim();
634
+ if (
635
+ !BLOCK_EXPORT_HEADER.test(firstLine) ||
636
+ /^(?:flowchart|graph)$/i.test(firstLine)
637
+ ) {
638
+ return { exportName: null, diagram: body };
639
+ }
640
+ return {
641
+ exportName: firstLine,
642
+ diagram: newlineIndex < 0 ? '' : body.slice(newlineIndex + 1).trim(),
643
+ };
644
+ }
645
+
646
+ type ParsedDocflowBlockGraph = {
647
+ direction: PlayDocflow['direction'];
648
+ nodes: Map<string, PlayDocflowNode>;
649
+ edges: PlayDocflowEdge[];
650
+ subgraphs: Map<string, PlayDocflowSubgraph>;
651
+ ignoredDirectives: string[];
652
+ };
653
+
654
+ type ParsedDocflowBlock = ParsedDocflowBlockGraph & {
655
+ /** The export name as authored, before canonicalization. */
656
+ authoredExportName: string | null;
657
+ /** Canonical export name, or null when the authored name resolved to none. */
658
+ exportName: string | null;
659
+ syntax: NonNullable<PlayDocflow['syntax']>;
660
+ mermaidSource: string;
661
+ bindings: PlayDocflowBinding[];
662
+ };
663
+
664
+ /** How a block is named in a diagnostic. */
665
+ function blockLabel(block: {
666
+ authoredExportName: string | null;
667
+ syntax: string;
668
+ }): string {
669
+ const tag = block.syntax === 'mermaid' ? '@mermaid' : '@docflow';
670
+ return block.authoredExportName ? `${tag} ${block.authoredExportName}` : tag;
671
+ }
672
+
673
+ /**
674
+ * Parses one block's flowchart into nodes, edges and subgraph regions. Returns
675
+ * null when the block does not open with a direction, which is the one error
676
+ * that makes the rest of the block unreadable.
677
+ */
678
+ function parseDocflowBlockGraph(
679
+ mermaidSource: string,
680
+ syntax: NonNullable<PlayDocflow['syntax']>,
681
+ errors: string[],
682
+ ): ParsedDocflowBlockGraph | null {
683
+ const docLines = mermaidSource
684
+ .split(/\r?\n/)
685
+ .filter((line) => line.trim() && !line.trim().startsWith('%%'));
686
+ const direction = FLOW_START.exec(docLines[0] ?? '')?.[1]?.toUpperCase() as
687
+ | PlayDocflow['direction']
688
+ | undefined;
689
+ if (!direction) {
690
+ errors.push(
691
+ 'Docflow must start with `flowchart` or `graph` and direction `LR`, `RL`, `TB`, `TD`, or `BT`.',
692
+ );
693
+ return null;
694
+ }
695
+
696
+ const nodes = new Map<string, PlayDocflowNode>();
697
+ const edges: PlayDocflowEdge[] = [];
698
+ // Subgraph ids are region containers, not regular nodes. Track them so node
699
+ // materialization can skip an id that names a subgraph, and so an edge
700
+ // endpoint naming a subgraph stays a legal reference. Pre-scan the ids first:
701
+ // an edge on an earlier line may reference a subgraph declared later.
702
+ const subgraphs = new Map<string, PlayDocflowSubgraph>();
703
+ const ignoredDirectives: string[] = [];
704
+ const subgraphStack: PlayDocflowSubgraph[] = [];
705
+ const subgraphIds = new Set<string>();
706
+ if (syntax === 'mermaid') {
707
+ for (const line of docLines.slice(1)) {
708
+ const opened = SUBGRAPH_OPEN.exec(line);
709
+ if (opened) subgraphIds.add(opened[1]!);
710
+ }
711
+ }
712
+ // Ids that were DECLARED with a shape somewhere, as opposed to merely named
713
+ // as an edge endpoint. A node nothing ever declares reaches the canvas
714
+ // labelled with its own id, which is never what an author meant.
715
+ const declaredIds = new Set<string>();
716
+ const addNodes = (fragment: string, line: string): boolean => {
717
+ let sawDeclaration = false;
718
+ for (const node of parseShapedMermaidNodes(fragment)) {
719
+ const { id, label, kind } = node;
720
+ if (node.unterminated) {
721
+ sawDeclaration = true;
722
+ errors.push(
723
+ `Docflow node "${id}" opens with \`${node.unterminated.open}\` but never closes with \`${node.unterminated.close}\`: ${line.trim()}`,
724
+ );
725
+ continue;
726
+ }
727
+ sawDeclaration = true;
728
+ // A shaped node carrying a subgraph's id is a real collision — a region
729
+ // and a node cannot share an id. A bare edge endpoint (unshaped) naming a
730
+ // subgraph is a legal reference and never reaches here.
731
+ if (subgraphIds.has(id)) {
732
+ errors.push(
733
+ `Docflow subgraph "${id}" collides with a node of the same id.`,
734
+ );
735
+ continue;
736
+ }
737
+ // The label kept its own delimiters, so the shape was not recognised and
738
+ // this "label" is really source text. Loud, because the alternative is a
739
+ // canvas card reading `["Fall back to a growth proxy"]`.
740
+ if (MERMAID_LABEL_LOOKS_LIKE_SOURCE.test(label)) {
741
+ errors.push(
742
+ `Docflow node "${id}" has an unreadable label \`${label}\` — the shape around it is not one this parser knows, so its own syntax became the label. Supported shapes: ${MERMAID_NODE_SHAPES.map(([open, close]) => `${open}…${close}`).join(', ')}. Line: ${line.trim()}`,
743
+ );
744
+ continue;
745
+ }
746
+ declaredIds.add(id);
747
+ const existing = nodes.get(id);
748
+ // An id already materialized as a bare edge endpoint carries its own id as
749
+ // a placeholder label; the real declaration upgrades it rather than
750
+ // colliding with it.
751
+ if (existing && existing.label === existing.id && label !== id) {
752
+ existing.label = label;
753
+ existing.kind = kind;
754
+ } else if (existing && existing.label !== label) {
755
+ errors.push(`Docflow node "${id}" has conflicting labels.`);
756
+ } else if (!existing) {
757
+ nodes.set(id, { id, label, kind });
758
+ // Innermost subgraph claims membership of nodes declared inside it.
759
+ subgraphStack[subgraphStack.length - 1]?.memberIds.push(id);
760
+ }
761
+ }
762
+ return sawDeclaration;
763
+ };
764
+ for (const line of docLines.slice(1)) {
765
+ if (syntax === 'mermaid') {
766
+ const opened = SUBGRAPH_OPEN.exec(line);
767
+ if (opened) {
768
+ const id = opened[1]!;
769
+ const rawLabel = opened[2]?.trim() ?? '';
770
+ const label =
771
+ rawLabel.length >= 2 &&
772
+ ((rawLabel.startsWith('"') && rawLabel.endsWith('"')) ||
773
+ (rawLabel.startsWith("'") && rawLabel.endsWith("'")))
774
+ ? rawLabel.slice(1, -1)
775
+ : rawLabel;
776
+ const subgraph: PlayDocflowSubgraph = {
777
+ id,
778
+ label: label || id,
779
+ memberIds: [],
780
+ };
781
+ subgraphs.set(id, subgraph);
782
+ subgraphStack.push(subgraph);
783
+ continue;
784
+ }
785
+ if (SUBGRAPH_END.test(line)) {
786
+ if (subgraphStack.length === 0) {
787
+ errors.push(
788
+ 'Docflow has an `end` with no open `subgraph` above it. Every `end` closes exactly one `subgraph`.',
789
+ );
790
+ }
791
+ subgraphStack.pop();
792
+ continue;
793
+ }
794
+ }
795
+ let declaredOnThisLine = false;
796
+ const isDirective =
797
+ /^\s*(?:direction|classDef|class|style|linkStyle|click)\b/i.test(line);
798
+ if (syntax === 'mermaid' && isDirective) {
799
+ ignoredDirectives.push(line.trim());
800
+ }
801
+ if (syntax === 'mermaid' && !isDirective) {
802
+ declaredOnThisLine = addNodes(line, line);
803
+ }
804
+ const chain = parseEdgeChain(line, syntax);
805
+ if (!chain) {
806
+ if (syntax === 'docflow') {
807
+ errors.push(`Unsupported docflow line: ${line.trim()}`);
808
+ } else if (!isDirective && !declaredOnThisLine) {
809
+ // Mermaid the parser could not consume at all: no edge, no node
810
+ // declaration, not a directive it knowingly ignores. It used to be
811
+ // skipped in silence, so a typo'd arrow or a stray token simply removed
812
+ // part of the diagram and nothing said so.
813
+ errors.push(
814
+ `Docflow line is not an edge, a node declaration, or a directive this parser understands: ${line.trim()}`,
815
+ );
816
+ }
817
+ continue;
818
+ }
819
+ if (syntax === 'docflow') addNodes(line, line);
820
+ for (const node of chain.nodes) {
821
+ // An edge endpoint may name a subgraph id; keep the edge but do not
822
+ // materialize a regular node for the region.
823
+ if (subgraphIds.has(node.id)) continue;
824
+ const existing = nodes.get(node.id);
825
+ if (!existing) {
826
+ nodes.set(node.id, node);
827
+ subgraphStack[subgraphStack.length - 1]?.memberIds.push(node.id);
828
+ } else if (existing.label === existing.id && node.label !== node.id) {
829
+ existing.label = node.label;
830
+ existing.kind = node.kind;
831
+ }
832
+ }
833
+ for (let index = 0; index < chain.nodes.length - 1; index += 1) {
834
+ edges.push({
835
+ from: chain.nodes[index]!.id,
836
+ to: chain.nodes[index + 1]!.id,
837
+ ...(chain.labels[index] ? { label: chain.labels[index] } : {}),
838
+ });
839
+ }
840
+ }
841
+
842
+ if (subgraphStack.length > 0) {
843
+ errors.push(
844
+ `Docflow subgraph${subgraphStack.length === 1 ? '' : 's'} ${subgraphStack
845
+ .map((subgraph) => `"${subgraph.id}"`)
846
+ .join(
847
+ ', ',
848
+ )} ${subgraphStack.length === 1 ? 'is' : 'are'} never closed with \`end\`. Everything below an unclosed subgraph is silently drawn inside it.`,
849
+ );
850
+ }
851
+
852
+ // An id that only ever appeared as an edge endpoint has no label of its own,
853
+ // so the canvas would print its id. Subgraph ids are exempt: naming a region
854
+ // in an edge is the documented way to wire one up.
855
+ for (const node of nodes.values()) {
856
+ if (declaredIds.has(node.id) || subgraphIds.has(node.id)) continue;
857
+ errors.push(
858
+ `Docflow node "${node.id}" is referenced by an edge but never declared with a shape and label, so the canvas would show its id. Declare it once, e.g. \`${node.id}["What this step does"]\`.`,
859
+ );
860
+ }
861
+
862
+ return { direction, nodes, edges, subgraphs, ignoredDirectives };
863
+ }
864
+
865
+ function materializeDocflow(block: ParsedDocflowBlock): PlayDocflow {
866
+ return {
867
+ direction: block.direction,
868
+ nodes: [...block.nodes.values()],
869
+ edges: block.edges,
870
+ bindings: block.bindings,
871
+ syntax: block.syntax,
872
+ mermaidSource: block.mermaidSource,
873
+ ...(block.subgraphs.size
874
+ ? { subgraphs: [...block.subgraphs.values()] }
875
+ : {}),
876
+ ...(block.ignoredDirectives.length
877
+ ? { ignoredDirectives: block.ignoredDirectives }
878
+ : {}),
879
+ };
880
+ }
881
+
882
+ /**
883
+ * Parses EVERY `@mermaid` block in a file and binds each to the export it
884
+ * describes. One file can carry one play or several; a block names its export
885
+ * in its header and an unnamed block means the default export, so every diagram
886
+ * written before per-export blocks existed keeps meaning exactly what it meant.
887
+ */
888
+ export function parsePlayDocflowFile(
889
+ sourceCode: string,
890
+ ): PlayDocflowFileParseResult {
891
+ const errors: string[] = [];
892
+ const bindings = parseDocflowBindings(sourceCode, errors);
893
+
894
+ const rawBlocks: Array<{
895
+ syntax: NonNullable<PlayDocflow['syntax']>;
896
+ body: string;
897
+ }> = [];
898
+ MERMAID_BLOCK.lastIndex = 0;
899
+ for (const match of sourceCode.matchAll(MERMAID_BLOCK)) {
900
+ rawBlocks.push({ syntax: 'mermaid', body: cleanBlockBody(match[1]!) });
901
+ }
902
+ if (rawBlocks.length === 0) {
903
+ const legacy = LEGACY_BLOCK.exec(sourceCode);
904
+ if (legacy) {
905
+ rawBlocks.push({ syntax: 'docflow', body: cleanBlockBody(legacy[1]!) });
906
+ }
907
+ }
908
+ if (rawBlocks.length === 0) return { blocks: [], bindings, errors };
909
+
910
+ const parsedBlocks: ParsedDocflowBlock[] = [];
911
+ for (const raw of rawBlocks) {
912
+ const { exportName, diagram } = splitBlockExportHeader(raw.body);
913
+ const graph = parseDocflowBlockGraph(diagram, raw.syntax, errors);
914
+ if (!graph) continue;
915
+ parsedBlocks.push({
916
+ ...graph,
917
+ authoredExportName: exportName,
918
+ exportName: null,
919
+ syntax: raw.syntax,
920
+ mermaidSource: diagram,
921
+ bindings: [],
922
+ });
923
+ }
924
+
925
+ resolveBlockExports(sourceCode, parsedBlocks, errors);
926
+ attachBindingsToBlocks(sourceCode, parsedBlocks, bindings, errors);
927
+
928
+ return {
929
+ blocks: parsedBlocks
930
+ .filter((block) => block.exportName !== null)
931
+ .map((block) => ({
932
+ exportName: block.exportName!,
933
+ docflow: materializeDocflow(block),
934
+ })),
935
+ bindings,
936
+ errors,
937
+ };
938
+ }
939
+
940
+ /**
941
+ * Resolves each block's `@mermaid <export>` header to a canonical export name,
942
+ * defaulting an unnamed block to the default export. A header naming an export
943
+ * the file does not define is an error listing what it does define — never a
944
+ * quietly ignored diagram.
945
+ */
946
+ function resolveBlockExports(
947
+ sourceCode: string,
948
+ blocks: ParsedDocflowBlock[],
949
+ errors: string[],
950
+ ): void {
951
+ // The export list costs an AST parse, so only pay for it when a header could
952
+ // change the answer. An undiagrammed play, and a play with the one unnamed
953
+ // block every existing diagram uses, never reach this.
954
+ const fileExports = blocks.some((block) => block.authoredExportName !== null)
955
+ ? listPlayFileExports(sourceCode)
956
+ : null;
957
+ const claimed = new Map<string, ParsedDocflowBlock>();
958
+ for (const block of blocks) {
959
+ if (block.authoredExportName === null) {
960
+ block.exportName = PLAY_DEFAULT_EXPORT;
961
+ } else if (fileExports === null) {
962
+ // Source acorn could not parse: the TypeScript diagnostics own that
963
+ // failure, so take the header at face value rather than inventing a
964
+ // second, more confusing error on top of it.
965
+ block.exportName = block.authoredExportName;
966
+ } else {
967
+ const canonical = canonicalPlayExportName(
968
+ block.authoredExportName,
969
+ fileExports,
970
+ );
971
+ if (!canonical) {
972
+ const available = playExportNamesForMessage(fileExports);
973
+ errors.push(
974
+ `Docflow block \`${blockLabel(block)}\` names an export this file does not define. ` +
975
+ (available.length
976
+ ? `Exported plays: ${available.map((name) => `"${name}"`).join(', ')}.`
977
+ : 'This file exports no play.'),
978
+ );
979
+ continue;
980
+ }
981
+ block.exportName = canonical;
982
+ }
983
+ const existing = claimed.get(block.exportName);
984
+ if (existing) {
985
+ errors.push(
986
+ `Docflow blocks \`${blockLabel(existing)}\` and \`${blockLabel(block)}\` both describe export "${block.exportName}". One diagram per export.`,
987
+ );
988
+ block.exportName = null;
989
+ continue;
990
+ }
991
+ claimed.set(block.exportName, block);
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Routes each `// @mermaid-node` annotation to the block that declares its node
997
+ * id. An id declared by no block, or by two, is an error rather than a guess:
998
+ * annotations are file-scoped text and only the diagrams say which play a node
999
+ * belongs to.
1000
+ */
1001
+ type PlayExportSourceRange = {
1002
+ exportName: string;
1003
+ start: number;
1004
+ end: number;
1005
+ };
1006
+
1007
+ function unwrapExportExpression(node: AstNode | null): AstNode | null {
1008
+ let current = node;
1009
+ while (
1010
+ current &&
1011
+ (current.type === 'TSAsExpression' ||
1012
+ current.type === 'TSSatisfiesExpression' ||
1013
+ current.type === 'TSTypeAssertion' ||
1014
+ current.type === 'TSNonNullExpression' ||
1015
+ current.type === 'ParenthesizedExpression')
1016
+ ) {
1017
+ current = isAstNode(current.expression) ? current.expression : null;
1018
+ }
1019
+ return current;
1020
+ }
1021
+
1022
+ /** Source extent of each exported definePlay call, keyed by canonical export. */
1023
+ function playExportSourceRanges(sourceCode: string): PlayExportSourceRange[] {
1024
+ const ast = parsePlaySourceForAnalysis(sourceCode);
1025
+ const fileExports = listPlayFileExports(sourceCode);
1026
+ if (!ast || !fileExports) return [];
1027
+
1028
+ const declarations = new Map<string, AstNode | null>();
1029
+ const namedExports = new Map<string, string>();
1030
+ let defaultExpression: AstNode | null = null;
1031
+ for (const statement of astArray(ast.body)) {
1032
+ const recordDeclarations = (declaration: AstNode, exported: boolean) => {
1033
+ for (const declarator of astArray(declaration.declarations)) {
1034
+ const id = isAstNode(declarator.id) ? declarator.id : null;
1035
+ const name =
1036
+ id?.type === 'Identifier' && typeof id.name === 'string'
1037
+ ? id.name
1038
+ : null;
1039
+ if (!name) continue;
1040
+ declarations.set(
1041
+ name,
1042
+ isAstNode(declarator.init) ? declarator.init : null,
1043
+ );
1044
+ if (exported) namedExports.set(name, name);
1045
+ }
1046
+ };
1047
+ if (statement.type === 'VariableDeclaration') {
1048
+ recordDeclarations(statement, false);
1049
+ } else if (statement.type === 'ExportDefaultDeclaration') {
1050
+ defaultExpression = isAstNode(statement.declaration)
1051
+ ? statement.declaration
1052
+ : null;
1053
+ } else if (statement.type === 'TSExportAssignment') {
1054
+ defaultExpression = isAstNode(statement.expression)
1055
+ ? statement.expression
1056
+ : null;
1057
+ } else if (statement.type === 'ExportNamedDeclaration') {
1058
+ if (
1059
+ isAstNode(statement.declaration) &&
1060
+ statement.declaration.type === 'VariableDeclaration'
1061
+ ) {
1062
+ recordDeclarations(statement.declaration, true);
1063
+ }
1064
+ for (const specifier of astArray(statement.specifiers)) {
1065
+ const local = isAstNode(specifier.local) ? specifier.local : null;
1066
+ const exported = isAstNode(specifier.exported)
1067
+ ? specifier.exported
1068
+ : null;
1069
+ if (
1070
+ local?.type === 'Identifier' &&
1071
+ typeof local.name === 'string' &&
1072
+ exported?.type === 'Identifier' &&
1073
+ typeof exported.name === 'string'
1074
+ ) {
1075
+ namedExports.set(exported.name, local.name);
1076
+ }
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ const resolveCall = (
1082
+ expression: AstNode | null,
1083
+ seen = new Set<string>(),
1084
+ ): AstNode | null => {
1085
+ const unwrapped = unwrapExportExpression(expression);
1086
+ if (!unwrapped) return null;
1087
+ if (isDefinePlayCall(unwrapped)) return unwrapped;
1088
+ if (
1089
+ unwrapped.type !== 'Identifier' ||
1090
+ typeof unwrapped.name !== 'string' ||
1091
+ seen.has(unwrapped.name)
1092
+ ) {
1093
+ return null;
1094
+ }
1095
+ seen.add(unwrapped.name);
1096
+ return resolveCall(declarations.get(unwrapped.name) ?? null, seen);
1097
+ };
1098
+
1099
+ const defaultCall = resolveCall(defaultExpression);
1100
+ const ranges: PlayExportSourceRange[] = [];
1101
+ for (const fileExport of fileExports) {
1102
+ const call =
1103
+ fileExport.name === PLAY_DEFAULT_EXPORT
1104
+ ? defaultCall
1105
+ : resolveCall(
1106
+ declarations.get(namedExports.get(fileExport.name) ?? '') ?? null,
1107
+ );
1108
+ if (
1109
+ call &&
1110
+ typeof call.start === 'number' &&
1111
+ typeof call.end === 'number'
1112
+ ) {
1113
+ ranges.push({
1114
+ exportName: fileExport.name,
1115
+ start: call.start,
1116
+ end: call.end,
1117
+ });
1118
+ }
1119
+ }
1120
+ return ranges;
1121
+ }
1122
+
1123
+ /**
1124
+ * Moves each `arm:"…"` from the annotation that declared it onto the drawn edge
1125
+ * it is about, and refuses every shape where that edge is not unambiguous.
1126
+ *
1127
+ * The annotation is node-scoped because that is where the author is standing;
1128
+ * the FACT is edge-scoped, because "which arm is this" is a question about the
1129
+ * line from the diamond, not about the box it lands in. A box two decisions both
1130
+ * point at has two answers and gets none.
1131
+ *
1132
+ * Runs after node kinds are applied, so `type:"decision"` written on the diamond
1133
+ * is already visible here. Errors are hard — `parsePlayDocflow` returns a null
1134
+ * docflow when any is pushed — because a MISPLACED arm claim is worse than no
1135
+ * arm claim: the reader is told the run took the arm it did not take, in a
1136
+ * surface whose entire job is to be believed.
1137
+ */
1138
+ function projectRecordedArms(
1139
+ block: ParsedDocflowBlock,
1140
+ errors: string[],
1141
+ ): void {
1142
+ const declaredBy = new Map<string, string>();
1143
+ for (const binding of block.bindings) {
1144
+ if (!binding.arm) continue;
1145
+ const incoming = block.edges.filter(
1146
+ (edge) =>
1147
+ edge.to === binding.nodeId &&
1148
+ block.nodes.get(edge.from)?.kind === 'decision',
1149
+ );
1150
+ if (incoming.length === 0) {
1151
+ errors.push(
1152
+ `Docflow annotation "${binding.nodeId}" declares arm:"${binding.arm}" but no decision points at it. Put arm:"…" on the node a decision's labelled edge leads to.`,
1153
+ );
1154
+ continue;
1155
+ }
1156
+ if (incoming.length > 1) {
1157
+ errors.push(
1158
+ `Docflow annotation "${binding.nodeId}" declares arm:"${binding.arm}" but ${incoming.length} decisions point at it, so the arm names no single edge. Give each decision its own arm node.`,
1159
+ );
1160
+ continue;
1161
+ }
1162
+ const edge = incoming[0]!;
1163
+ // One decision, one meaning per token. Two arms both claiming `run` is the
1164
+ // exact defect the recorded identity exists to make impossible, so it is
1165
+ // refused at the source rather than resolved by precedence downstream.
1166
+ const key = `${edge.from}${binding.arm}`;
1167
+ const already = declaredBy.get(key);
1168
+ if (already !== undefined) {
1169
+ errors.push(
1170
+ `Decision "${edge.from}" has two arms declaring arm:"${binding.arm}" ("${already}" and "${binding.nodeId}"). A conditional has one run arm and one else arm.`,
1171
+ );
1172
+ continue;
1173
+ }
1174
+ declaredBy.set(key, binding.nodeId);
1175
+ edge.arm = binding.arm;
1176
+ }
1177
+ }
1178
+
1179
+ function attachBindingsToBlocks(
1180
+ sourceCode: string,
1181
+ blocks: ParsedDocflowBlock[],
1182
+ bindings: readonly PlayDocflowBinding[],
1183
+ errors: string[],
1184
+ ): void {
1185
+ const lineStarts = sourceLineStartsForDocflow(sourceCode);
1186
+ const exportRanges = playExportSourceRanges(sourceCode);
1187
+ for (const binding of bindings) {
1188
+ let owners = blocks.filter((block) => block.nodes.has(binding.nodeId));
1189
+ if (owners.length === 0) {
1190
+ errors.push(
1191
+ `Docflow annotation "${binding.nodeId}" does not name a graph node.`,
1192
+ );
1193
+ continue;
1194
+ }
1195
+ if (owners.length > 1) {
1196
+ const lineStart = lineStarts[binding.line - 1];
1197
+ if (lineStart !== undefined) {
1198
+ const scoped = owners.filter((owner) =>
1199
+ exportRanges.some(
1200
+ (range) =>
1201
+ range.exportName === owner.exportName &&
1202
+ lineStart >= range.start &&
1203
+ lineStart < range.end,
1204
+ ),
1205
+ );
1206
+ if (scoped.length === 1) owners = scoped;
1207
+ }
1208
+ }
1209
+ if (owners.length > 1) {
1210
+ errors.push(
1211
+ `Docflow annotation "${binding.nodeId}" names a node in ${owners.map((owner) => `\`${blockLabel(owner)}\``).join(' and ')}, and its enclosing play could not identify one owner. Put the annotation inside the definePlay handler for the export it describes.`,
1212
+ );
1213
+ continue;
1214
+ }
1215
+ const owner = owners[0]!;
1216
+ if (
1217
+ owner.bindings.some((candidate) => candidate.nodeId === binding.nodeId)
1218
+ ) {
1219
+ errors.push(
1220
+ `Docflow node "${binding.nodeId}" has more than one code binding.`,
1221
+ );
1222
+ continue;
1223
+ }
1224
+ owner.bindings.push(binding);
1225
+ }
1226
+ for (const block of blocks) {
1227
+ for (const binding of block.bindings) {
1228
+ const node = block.nodes.get(binding.nodeId)!;
1229
+ if (binding.label) node.label = binding.label;
1230
+ if (binding.kind) node.kind = binding.kind;
1231
+ }
1232
+ projectRecordedArms(block, errors);
1233
+ if (block.syntax !== 'docflow') continue;
1234
+ const bound = new Set(block.bindings.map((binding) => binding.nodeId));
1235
+ for (const node of block.nodes.values()) {
1236
+ if (node.kind !== 'conceptual' && !bound.has(node.id)) {
1237
+ errors.push(
1238
+ `Docflow node "${node.id}" has no code binding; mark it type:"conceptual" or bind it.`,
1239
+ );
1240
+ }
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ function sourceLineStartsForDocflow(sourceCode: string): number[] {
1246
+ const starts = [0];
1247
+ for (let index = 0; index < sourceCode.length; index += 1) {
1248
+ if (sourceCode[index] === '\n') starts.push(index + 1);
1249
+ }
1250
+ return starts;
1251
+ }
1252
+
1253
+ /**
1254
+ * Parses the intentionally small authoring surface. Mermaid remains an export
1255
+ * target; this owns the stable IDs and bindings that make the diagram truthful.
1256
+ *
1257
+ * Returns the diagram for ONE export (the default unless asked otherwise), so a
1258
+ * caller analyzing `batch` never sees the block that describes `scalar`. Errors
1259
+ * stay file-wide: a broken block is broken whichever export you asked about.
1260
+ */
1261
+ export function parsePlayDocflow(
1262
+ sourceCode: string,
1263
+ options: ParsePlayDocflowOptions = {},
1264
+ ): PlayDocflowParseResult {
1265
+ const parsed = parsePlayDocflowFile(sourceCode);
1266
+ const requested = options.exportName?.trim() || PLAY_DEFAULT_EXPORT;
1267
+ const selected =
1268
+ parsed.blocks.find((block) => block.exportName === requested) ??
1269
+ // `scalar` and `default` name the same play in `export default scalar`, and
1270
+ // the registry addresses that play as `scalar`. Only worth an AST parse
1271
+ // when a block exists and the caller asked for something else.
1272
+ (requested !== PLAY_DEFAULT_EXPORT && parsed.blocks.length > 0
1273
+ ? parsed.blocks.find(
1274
+ (block) =>
1275
+ block.exportName ===
1276
+ canonicalPlayExportName(
1277
+ requested,
1278
+ listPlayFileExports(sourceCode) ?? [],
1279
+ ),
1280
+ )
1281
+ : undefined);
1282
+ return {
1283
+ docflow: parsed.errors.length ? null : (selected?.docflow ?? null),
1284
+ errors: parsed.errors,
1285
+ };
1286
+ }
1287
+
1288
+ /**
1289
+ * Collects every `// @mermaid-node` annotation in the file. Blocks route these
1290
+ * to themselves afterwards; the annotations themselves are file-scoped text.
1291
+ */
1292
+ function parseDocflowBindings(
1293
+ sourceCode: string,
1294
+ errors: string[],
1295
+ ): PlayDocflowBinding[] {
1296
+ const lines = sourceCode.split(/\r?\n/);
1297
+ const bindings: PlayDocflowBinding[] = [];
1298
+
1299
+ for (let index = 0; index < lines.length; index += 1) {
1300
+ PUT.lastIndex = 0;
1301
+ MERMAID_NODE.lastIndex = 0;
1302
+ const legacyMatch = PUT.exec(lines[index]!);
1303
+ const mermaidMatch = MERMAID_NODE.exec(lines[index]!);
1304
+ if (!legacyMatch && !mermaidMatch) continue;
1305
+ const attributes = parseAttributes(
1306
+ mermaidMatch ? (mermaidMatch[2] ?? '') : legacyMatch![1]!,
1307
+ {
1308
+ line: index + 1,
1309
+ annotation: mermaidMatch ? '@mermaid-node' : 'put',
1310
+ errors,
1311
+ },
1312
+ );
1313
+ if (!attributes) continue;
1314
+ const id = (mermaidMatch ? mermaidMatch[1] : attributes.id)?.trim();
1315
+ if (!id) {
1316
+ errors.push(`Docflow annotation on line ${index + 1} requires id:"…".`);
1317
+ continue;
1318
+ }
1319
+ const kind = nodeKind(attributes.type);
1320
+ if (attributes.type && !kind) {
1321
+ errors.push(
1322
+ `Docflow annotation "${id}" has unsupported type "${attributes.type}". Valid types: ${PLAY_DOCFLOW_NODE_KINDS.map((nodeType) => `"${nodeType}"`).join(', ')}.`,
1323
+ );
1324
+ continue;
1325
+ }
1326
+ const arm = nodeArm(attributes.arm);
1327
+ if (attributes.arm && !arm) {
1328
+ errors.push(
1329
+ `Docflow annotation "${id}" has unsupported arm "${attributes.arm}". Valid arms: ${PLAY_DOCFLOW_ARMS.map((token) => `"${token}"`).join(', ')}. The arm names which side of the conditional this node is, not what the edge is labelled.`,
1330
+ );
1331
+ continue;
1332
+ }
1333
+ let nextLine = index + 1;
1334
+ while (
1335
+ nextLine < lines.length &&
1336
+ (!lines[nextLine]!.trim() || lines[nextLine]!.trim().startsWith('//'))
1337
+ )
1338
+ nextLine += 1;
1339
+ if (nextLine === lines.length) {
1340
+ errors.push(
1341
+ `Docflow annotation "${id}" must be followed by executable code.`,
1342
+ );
1343
+ continue;
1344
+ }
1345
+ const inputs = parseContractPaths(
1346
+ attributes.in,
1347
+ 'in',
1348
+ id,
1349
+ index + 1,
1350
+ errors,
1351
+ );
1352
+ const outputs = parseContractPaths(
1353
+ attributes.out,
1354
+ 'out',
1355
+ id,
1356
+ index + 1,
1357
+ errors,
1358
+ );
1359
+ if (
1360
+ (attributes.in !== undefined && !inputs) ||
1361
+ (attributes.out !== undefined && !outputs)
1362
+ ) {
1363
+ continue;
1364
+ }
1365
+ const inferred =
1366
+ inputs || outputs
1367
+ ? {
1368
+ ...(inputs ? { inputs } : {}),
1369
+ ...(outputs ? { outputs } : {}),
1370
+ ioConfidence: 'explicit' as const,
1371
+ }
1372
+ : inferBindingIo(lines[nextLine]!);
1373
+ bindings.push({
1374
+ nodeId: id,
1375
+ line: nextLine + 1,
1376
+ ...(attributes.label ? { label: attributes.label } : {}),
1377
+ ...(kind ? { kind } : {}),
1378
+ // Spread, never `arm: arm ?? undefined` — see `PlayDocflowEdge.arm`. An
1379
+ // explicit `undefined` key survives `JSON.stringify` round-trips in some
1380
+ // callers and would reintroduce the hash drift this omission avoids.
1381
+ ...(arm ? { arm } : {}),
1382
+ ...inferred,
1383
+ });
1384
+ }
1385
+
1386
+ return bindings;
1387
+ }
1388
+
1389
+ /**
1390
+ * Enforces the small graph grammar the dashboard can render clearly. Syntax
1391
+ * errors are owned by {@link parsePlayDocflow}; these diagnostics are about
1392
+ * semantic topology and presentation complexity.
1393
+ */
1394
+ export function lintPlayDocflow(
1395
+ docflow: PlayDocflow,
1396
+ sourceCode?: string,
1397
+ ): PlayDocflowLintIssue[] {
1398
+ const issues: PlayDocflowLintIssue[] = [];
1399
+ // Mermaid subgraphs are region endpoints rather than ordinary nodes. Until
1400
+ // their nested membership is projected into this lightweight graph, applying
1401
+ // the one-root/reachability check to them reports every valid region member as
1402
+ // a second root. Plain Mermaid diagrams have no such ambiguity and should get
1403
+ // the same useful connected-topology validation as the legacy syntax.
1404
+ const enforceConnectedTopology =
1405
+ docflow.syntax !== 'mermaid' || (docflow.subgraphs?.length ?? 0) === 0;
1406
+ const byId = new Map(docflow.nodes.map((node) => [node.id, node]));
1407
+ const outgoing = new Map(
1408
+ docflow.nodes.map((node) => [node.id, [] as PlayDocflowEdge[]]),
1409
+ );
1410
+ const indegree = new Map(docflow.nodes.map((node) => [node.id, 0]));
1411
+ const sourceIdentifiers = new Set<string>();
1412
+ if (sourceCode) {
1413
+ const executableSource = sourceCode
1414
+ .replace(/\/\*[\s\S]*?\*\//g, '')
1415
+ .replace(/\/\/.*$/gm, '');
1416
+ IDENTIFIER.lastIndex = 0;
1417
+ for (const match of executableSource.matchAll(IDENTIFIER)) {
1418
+ sourceIdentifiers.add(match[0]!);
1419
+ }
1420
+ }
1421
+
1422
+ for (const edge of docflow.edges) {
1423
+ outgoing.get(edge.from)?.push(edge);
1424
+ indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
1425
+ }
1426
+
1427
+ if (docflow.direction !== 'TB' && docflow.direction !== 'TD') {
1428
+ issues.push({
1429
+ code: 'docflow_direction_not_top_down',
1430
+ severity: 'warning',
1431
+ message: `Docflow direction ${docflow.direction} is normalized to a top-down dashboard layout.`,
1432
+ path: 'docflow.direction',
1433
+ hint: 'Author the document as `flowchart TD` so source and rendered direction agree.',
1434
+ });
1435
+ }
1436
+
1437
+ if (docflow.ignoredDirectives?.length) {
1438
+ issues.push({
1439
+ code: 'docflow_directive_ignored',
1440
+ severity: 'warning',
1441
+ message: `Docflow contains Mermaid directives the dashboard does not apply: ${docflow.ignoredDirectives.join('; ')}.`,
1442
+ path: 'docflow.mermaidSource',
1443
+ hint: 'Remove these directives. The dashboard currently owns node, edge, and top-down layout styling.',
1444
+ });
1445
+ }
1446
+
1447
+ const roots = docflow.nodes.filter((node) => indegree.get(node.id) === 0);
1448
+ if (enforceConnectedTopology && roots.length !== 1) {
1449
+ issues.push({
1450
+ code: 'docflow_topology_invalid',
1451
+ severity: 'error',
1452
+ message: `Docflow must have one entry node; found ${roots.length}.`,
1453
+ path: 'docflow.edges',
1454
+ hint: 'Connect every authored node beneath one clear starting node.',
1455
+ });
1456
+ } else if (enforceConnectedTopology) {
1457
+ const reachable = new Set<string>();
1458
+ const pending = [roots[0]!.id];
1459
+ while (pending.length > 0) {
1460
+ const current = pending.pop()!;
1461
+ if (reachable.has(current)) continue;
1462
+ reachable.add(current);
1463
+ for (const edge of outgoing.get(current) ?? []) pending.push(edge.to);
1464
+ }
1465
+ const unreachable = docflow.nodes.filter((node) => !reachable.has(node.id));
1466
+ if (unreachable.length > 0) {
1467
+ issues.push({
1468
+ code: 'docflow_topology_invalid',
1469
+ severity: 'error',
1470
+ message: `Docflow contains unreachable nodes: ${unreachable.map((node) => node.id).join(', ')}.`,
1471
+ path: 'docflow.edges',
1472
+ hint: 'Connect or remove each orphaned presentation node.',
1473
+ });
1474
+ }
1475
+ }
1476
+
1477
+ for (const node of docflow.nodes) {
1478
+ const branches = outgoing.get(node.id) ?? [];
1479
+ if (branches.length > 1 && node.kind !== 'decision') {
1480
+ issues.push({
1481
+ code: 'docflow_branch_requires_decision',
1482
+ severity: 'error',
1483
+ message: `Docflow node "${node.id}" branches ${branches.length} ways but is not a decision.`,
1484
+ path: `docflow.nodes.${node.id}`,
1485
+ hint: 'Insert a decision node for the branch so the split is explicit in the UI.',
1486
+ });
1487
+ }
1488
+ if (node.kind === 'decision' && branches.length > 1) {
1489
+ const labels = branches.map((edge) => edge.label?.trim() ?? '');
1490
+ if (
1491
+ labels.some((label) => !label) ||
1492
+ new Set(labels).size !== labels.length
1493
+ ) {
1494
+ issues.push({
1495
+ code: 'docflow_branch_labels_required',
1496
+ severity: 'error',
1497
+ message: `Decision "${node.id}" must give every branch a unique label.`,
1498
+ path: `docflow.nodes.${node.id}`,
1499
+ hint: 'Use `decision -->|outcome| next` for every decision edge.',
1500
+ });
1501
+ }
1502
+ }
1503
+ if (branches.length > DOCFLOW_MAX_BRANCHES) {
1504
+ issues.push({
1505
+ code: 'docflow_layout_complexity',
1506
+ severity: 'error',
1507
+ message: `Docflow node "${node.id}" has ${branches.length} branches; the dashboard supports at most ${DOCFLOW_MAX_BRANCHES} readable branches from one node.`,
1508
+ path: `docflow.nodes.${node.id}`,
1509
+ hint: 'Split this choice into smaller named decisions.',
1510
+ });
1511
+ }
1512
+ if (node.label.length > DOCFLOW_MAX_LABEL_LENGTH) {
1513
+ issues.push({
1514
+ code: 'docflow_layout_complexity',
1515
+ severity: 'warning',
1516
+ message: `Docflow node "${node.id}" has a ${node.label.length}-character label; labels over ${DOCFLOW_MAX_LABEL_LENGTH} characters are truncated in the graph.`,
1517
+ path: `docflow.nodes.${node.id}.label`,
1518
+ hint: 'Move detail into code or a conceptual node and keep the card title short.',
1519
+ });
1520
+ }
1521
+ // A label names the thing; the runtime counts it. Warning, not error: this
1522
+ // reads prose, so a legitimate name that happens to carry a magnitude
1523
+ // should not be able to hard-fail `plays check`.
1524
+ const countFragment = docflowLabelCountFragment(node.label);
1525
+ if (countFragment) {
1526
+ issues.push({
1527
+ code: 'docflow_label_counts_rows',
1528
+ severity: 'warning',
1529
+ message: `Docflow node "${node.id}" labels a count ("${countFragment}") in "${node.label}"; row counts come from the run, so an authored number goes stale as soon as the input changes.`,
1530
+ path: `docflow.nodes.${node.id}.label`,
1531
+ hint: 'Name what the node IS ("Seed rows"), not how many it holds — the canvas already shows the live count beside the node.',
1532
+ });
1533
+ }
1534
+ }
1535
+
1536
+ // Loop members name the columns of the dataset they annotate, not the
1537
+ // statement's assigned variable. Their outputs are validated against the
1538
+ // dataset's real computed columns by the preflight loop-completeness check,
1539
+ // so the assigned-name agreement rule does not apply to them.
1540
+ const subgraphMemberIds = new Set(
1541
+ (docflow.subgraphs ?? []).flatMap((subgraph) => subgraph.memberIds),
1542
+ );
1543
+
1544
+ for (const binding of docflow.bindings) {
1545
+ const node = byId.get(binding.nodeId);
1546
+ if (!node) continue;
1547
+ if (binding.ioConfidence === 'ambiguous' && node.kind === 'action') {
1548
+ issues.push({
1549
+ code: 'docflow_io_ambiguous',
1550
+ severity: 'warning',
1551
+ message: `Could not determine what "${binding.nodeId}" reads or changes.`,
1552
+ path: `docflow.nodes.${binding.nodeId}`,
1553
+ hint: 'Add in:"…" and out:"…", or annotate a clearer assignment.',
1554
+ });
1555
+ }
1556
+ if (!sourceCode) continue;
1557
+ const boundLine = sourceCode.split(/\r?\n/)[binding.line - 1]?.trim() ?? '';
1558
+ const assignedName = /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/.exec(
1559
+ boundLine,
1560
+ )?.[1];
1561
+ for (const path of binding.inputs ?? []) {
1562
+ const root = rootPath(path);
1563
+ if (sourceIdentifiers.has(root)) continue;
1564
+ const suggestion = [...sourceIdentifiers]
1565
+ .filter((candidate) => candidate.length > 1)
1566
+ .sort(
1567
+ (left, right) => levenshtein(root, left) - levenshtein(root, right),
1568
+ )[0];
1569
+ issues.push({
1570
+ code: 'docflow_input_not_found',
1571
+ severity: 'error',
1572
+ message: `Input "${path}" is not in scope for docflow node "${binding.nodeId}".`,
1573
+ path: `docflow.nodes.${binding.nodeId}.in`,
1574
+ ...(suggestion && levenshtein(root, suggestion) <= 3
1575
+ ? { hint: `Did you mean "${suggestion}"?` }
1576
+ : {
1577
+ hint: 'Use an identifier or property path that exists in this play.',
1578
+ }),
1579
+ });
1580
+ }
1581
+ for (const path of binding.outputs ?? []) {
1582
+ const root = rootPath(path);
1583
+ if (root === '$output') continue;
1584
+ if (
1585
+ assignedName &&
1586
+ root !== assignedName &&
1587
+ !subgraphMemberIds.has(binding.nodeId)
1588
+ ) {
1589
+ issues.push({
1590
+ code: 'docflow_output_not_found',
1591
+ severity: 'error',
1592
+ message: `Output "${path}" does not match the assigned variable "${assignedName}" for docflow node "${binding.nodeId}" — out: names bind to the variable the annotated statement assigns, so write out:"${assignedName}" (the node id can stay "${binding.nodeId}").`,
1593
+ path: `docflow.nodes.${binding.nodeId}.out`,
1594
+ hint: `The node id and the out: name are independent: the id labels the diagram box, out: must be the assigned variable/column name.`,
1595
+ });
1596
+ continue;
1597
+ }
1598
+ if (sourceIdentifiers.has(root)) continue;
1599
+ issues.push({
1600
+ code: 'docflow_output_not_found',
1601
+ severity: 'error',
1602
+ message: `Output "${path}" is not declared by docflow node "${binding.nodeId}".`,
1603
+ path: `docflow.nodes.${binding.nodeId}.out`,
1604
+ hint: 'Use an identifier or property path that exists in this play.',
1605
+ });
1606
+ }
1607
+ }
1608
+
1609
+ if (docflow.nodes.length > DOCFLOW_MAX_NODES) {
1610
+ issues.push({
1611
+ code: 'docflow_layout_complexity',
1612
+ severity: 'warning',
1613
+ message: `Docflow has ${docflow.nodes.length} nodes; more than ${DOCFLOW_MAX_NODES} makes the default graph hard to scan.`,
1614
+ path: 'docflow.nodes',
1615
+ hint: 'Keep the primary business path here and move supporting explanation into concise conceptual nodes.',
1616
+ });
1617
+ }
1618
+
1619
+ const remainingIndegree = new Map(indegree);
1620
+ const pending = docflow.nodes
1621
+ .filter((node) => remainingIndegree.get(node.id) === 0)
1622
+ .map((node) => node.id);
1623
+ let visited = 0;
1624
+ while (pending.length > 0) {
1625
+ const current = pending.pop()!;
1626
+ visited += 1;
1627
+ for (const edge of outgoing.get(current) ?? []) {
1628
+ const nextIndegree = (remainingIndegree.get(edge.to) ?? 0) - 1;
1629
+ remainingIndegree.set(edge.to, nextIndegree);
1630
+ if (nextIndegree === 0) pending.push(edge.to);
1631
+ }
1632
+ }
1633
+ if (enforceConnectedTopology && visited !== byId.size) {
1634
+ issues.push({
1635
+ code: 'docflow_layout_complexity',
1636
+ severity: 'warning',
1637
+ message:
1638
+ 'Docflow contains a feedback loop, which renders as a secondary dashed path.',
1639
+ path: 'docflow.edges',
1640
+ hint: 'Label feedback edges clearly and keep the main forward path acyclic.',
1641
+ });
1642
+ }
1643
+
1644
+ return issues;
1645
+ }