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,636 @@
1
+ /**
2
+ * One join, one rule: which compiled substep does a docflow binding belong to?
3
+ *
4
+ * The dashboard used to answer this six separate times with the same
5
+ * expression — `binding.line >= substep.sourceRange.startLine && binding.line
6
+ * <= substep.sourceRange.endLine`. That compares two DIFFERENT constructs by
7
+ * containment and is wrong on every chained play: static analysis sets a
8
+ * dataset stage's `sourceRange` to `ctx.dataset(key, rows)` alone (the head of
9
+ * the chain), while the members that belong to it annotate `.withColumn(...)`
10
+ * calls further down. On `find-ctos` the stage spans lines 54–55 and its four
11
+ * members bind at 57, 79, 84 and 89 — the join returned nothing, so loop
12
+ * members got no coverage chip and no provider logo.
13
+ *
14
+ * The docflow language already carries a structural key, and lint already
15
+ * enforces it, so the join does not need a coordinate:
16
+ *
17
+ * - A **subgraph member**'s `out:` names per-row COLUMNS of the dataset it
18
+ * annotates. `docflow_loop_incomplete` validates those names against the
19
+ * dataset's real computed columns.
20
+ * - Any **other** binding's `out:` names the ASSIGNED VARIABLE of the annotated
21
+ * statement. `docflow_output_not_found` is a hard `plays check` error when it
22
+ * does not.
23
+ *
24
+ * So: symbol first. A binding whose `out:` root names a column a dataset
25
+ * computes belongs to that dataset, wherever either one sits in the file. That
26
+ * key survives reformatting, an inserted comment, and a change in how the AST
27
+ * walk shapes ranges — the failures that produced this bug.
28
+ *
29
+ * Position remains the fallback for exactly the case where the language gives
30
+ * no symbol to join on: an assignment-named node (`out:"searchResult"` on a
31
+ * `ctx.tools.execute` statement, `out:"companies"` on the dataset statement
32
+ * itself), where the compiler's own name for the substep (`company_search`,
33
+ * `cto_targets`) is by design NOT the author's variable. This mirrors ADR 0016
34
+ * rule 1, which resolves a binding by symbol and keeps position as the fallback
35
+ * when the symbol abstains.
36
+ *
37
+ * The fallback compares like with like, which the old join did not: a
38
+ * substep's extent is its own range UNIONED with every nested step's range —
39
+ * the whole construct the substep owns, not its head call. Among containing
40
+ * candidates the smallest extent wins, so a dataset nested inside another
41
+ * dataset's per-row body resolves to the inner one.
42
+ *
43
+ * Failure is a state, not a default. When no substep owns a binding the
44
+ * resolution says so (`reason`), and callers render nothing rather than
45
+ * borrowing a neighbour's numbers or logo. The loud gate lives upstream where
46
+ * it belongs: `plays check` already fails a binding whose `out:` names neither
47
+ * a real column nor the assigned variable.
48
+ */
49
+ import type { PlayDocflow, PlayDocflowBinding } from './docflow';
50
+ import type {
51
+ PlayStaticSourceRange,
52
+ PlayStaticSubstep,
53
+ } from './static-pipeline';
54
+
55
+ export type DocflowDatasetSubstep = Extract<
56
+ PlayStaticSubstep,
57
+ { type: 'dataset' }
58
+ >;
59
+ export type DocflowOwnerSubstep = Extract<
60
+ PlayStaticSubstep,
61
+ { type: 'dataset' | 'csv' }
62
+ >;
63
+ export type DocflowToolSubstep = Extract<PlayStaticSubstep, { type: 'tool' }>;
64
+ export type DocflowPlayCallSubstep = Extract<
65
+ PlayStaticSubstep,
66
+ { type: 'play_call' }
67
+ >;
68
+ export type DocflowStepSuiteSubstep = Extract<
69
+ PlayStaticSubstep,
70
+ { type: 'step_suite' }
71
+ >;
72
+
73
+ /**
74
+ * Every substep in a pipeline tree, parents before children. Top-level substeps
75
+ * are not enough for child plays: measured on the shipped prebuilts, the only
76
+ * real `ctx.runPlay` call site (`people-search-to-email`) is a `play_call`
77
+ * nested inside a dataset's per-row body, so a top-level-only scan finds none.
78
+ */
79
+ export function flattenSubsteps(
80
+ substeps: readonly PlayStaticSubstep[],
81
+ ): PlayStaticSubstep[] {
82
+ const flat: PlayStaticSubstep[] = [];
83
+ const visit = (candidate: PlayStaticSubstep) => {
84
+ flat.push(candidate);
85
+ const nested = (candidate as { steps?: PlayStaticSubstep[] }).steps;
86
+ if (Array.isArray(nested)) for (const step of nested) visit(step);
87
+ };
88
+ for (const substep of substeps) visit(substep);
89
+ return flat;
90
+ }
91
+
92
+ /** How a binding reached its substep — see the module header for the rules. */
93
+ export type DocflowJoinVia = 'symbol' | 'extent';
94
+
95
+ export type DocflowJoinFailure =
96
+ /** The binding names no joinable symbol and no substep extent contains it. */
97
+ | 'no-owner'
98
+ /** Two or more substeps claim the symbol and none contains the line. */
99
+ | 'ambiguous';
100
+
101
+ export type DocflowBindingJoin<TSubstep> =
102
+ | { substep: TSubstep; via: DocflowJoinVia; reason?: undefined }
103
+ | { substep: null; via?: undefined; reason: DocflowJoinFailure };
104
+
105
+ export type DocflowLineExtent = { startLine: number; endLine: number };
106
+
107
+ /**
108
+ * The line span of the whole construct a substep owns: its own range unioned
109
+ * with every nested step's range. A `ctx.dataset(...)` chain's own range covers
110
+ * only the head call, so a dataset's per-row body is only visible once the
111
+ * `.withColumn(...)` steps are folded in.
112
+ */
113
+ export function substepSourceExtent(
114
+ substep: PlayStaticSubstep,
115
+ ): DocflowLineExtent | null {
116
+ const ranges: PlayStaticSourceRange[] = [];
117
+ const collect = (candidate: PlayStaticSubstep) => {
118
+ if (candidate.sourceRange) ranges.push(candidate.sourceRange);
119
+ const nested = (candidate as { steps?: PlayStaticSubstep[] }).steps;
120
+ if (Array.isArray(nested)) for (const step of nested) collect(step);
121
+ };
122
+ collect(substep);
123
+ if (ranges.length === 0) return null;
124
+ return {
125
+ startLine: Math.min(...ranges.map((range) => range.startLine)),
126
+ endLine: Math.max(...ranges.map((range) => range.endLine)),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The distinct first path segments of a binding's `out:` contract, dropping
132
+ * `$output` (a return value names no substep symbol).
133
+ */
134
+ export function docflowOutputRoots(binding: PlayDocflowBinding): string[] {
135
+ return [
136
+ ...new Set(
137
+ (binding.outputs ?? [])
138
+ .map((path) => path.split('.')[0] ?? '')
139
+ .filter((root) => root.length > 0 && root !== '$output'),
140
+ ),
141
+ ];
142
+ }
143
+
144
+ /**
145
+ * The names of the per-row columns a dataset computes — the exact set a loop
146
+ * member's `out:` is validated against by `docflow_loop_incomplete`. Seed/input
147
+ * columns are excluded on purpose: a member claims work the dataset DOES, not a
148
+ * column it merely carries.
149
+ */
150
+ export function datasetComputedColumnNames(
151
+ substep: DocflowDatasetSubstep,
152
+ ): Set<string> {
153
+ const names = new Set<string>();
154
+ const add = (name: string | undefined | null) => {
155
+ if (name && name !== 'row_number') names.add(name);
156
+ };
157
+ for (const step of substep.steps ?? []) {
158
+ if ('field' in step) add(step.field);
159
+ }
160
+ for (const column of substep.columns ?? []) {
161
+ if (column.source !== 'datasetColumn') continue;
162
+ add(column.sqlName ?? column.id);
163
+ add(column.id);
164
+ for (const producer of column.producers ?? []) {
165
+ add(producer.field.split('.')[0]);
166
+ }
167
+ }
168
+ for (const column of substep.sheetContract?.columns ?? []) {
169
+ if (column.source !== 'datasetColumn') continue;
170
+ add(column.field ?? column.id);
171
+ add(column.sqlName);
172
+ }
173
+ return names;
174
+ }
175
+
176
+ /**
177
+ * The extent a positional fallback measures a binding against. Defaults to the
178
+ * whole construct a substep owns ({@link substepSourceExtent}); a caller whose
179
+ * construct is a single CALL passes {@link substepOwnSourceExtent} instead.
180
+ */
181
+ export type DocflowExtentResolver = (
182
+ substep: PlayStaticSubstep,
183
+ ) => DocflowLineExtent | null;
184
+
185
+ /**
186
+ * A substep's OWN call range, ignoring nested steps. The right extent for a
187
+ * construct whose annotation sits directly above one call — `ctx.runSteps(...)`
188
+ * — where the union would instead span every leg the builder declares, and for
189
+ * an imported builder those legs live in ANOTHER FILE, so the union is not even
190
+ * a coherent interval.
191
+ */
192
+ export function substepOwnSourceExtent(
193
+ substep: PlayStaticSubstep,
194
+ ): DocflowLineExtent | null {
195
+ const range = substep.sourceRange;
196
+ return range ? { startLine: range.startLine, endLine: range.endLine } : null;
197
+ }
198
+
199
+ function extentContainsLine(
200
+ substep: PlayStaticSubstep,
201
+ line: number,
202
+ extentOf: DocflowExtentResolver,
203
+ ): DocflowLineExtent | null {
204
+ const extent = extentOf(substep);
205
+ if (!extent) return null;
206
+ return line >= extent.startLine && line <= extent.endLine ? extent : null;
207
+ }
208
+
209
+ /** Smallest containing extent wins, so a nested construct beats its parent. */
210
+ function smallestContaining<TSubstep extends PlayStaticSubstep>(
211
+ candidates: readonly TSubstep[],
212
+ line: number,
213
+ extentOf: DocflowExtentResolver = substepSourceExtent,
214
+ ): TSubstep | null {
215
+ let best: TSubstep | null = null;
216
+ let bestSpan = Number.POSITIVE_INFINITY;
217
+ for (const candidate of candidates) {
218
+ const extent = extentContainsLine(candidate, line, extentOf);
219
+ if (!extent) continue;
220
+ const span = extent.endLine - extent.startLine;
221
+ if (span < bestSpan) {
222
+ best = candidate;
223
+ bestSpan = span;
224
+ }
225
+ }
226
+ return best;
227
+ }
228
+
229
+ /**
230
+ * One call site, identified by what it IS. A pipeline exposes the same construct
231
+ * through more than one tree — the stages tree and the flattened compiled
232
+ * substeps each carry their own copy of a nested `play_call` — so a naive match
233
+ * count sees N copies of one statement and calls it ambiguous. Ambiguity has to
234
+ * mean "two DIFFERENT statements compute this symbol", which is the only case a
235
+ * guess could get wrong.
236
+ */
237
+ function substepIdentity(substep: PlayStaticSubstep): string {
238
+ const range = substep.sourceRange;
239
+ const position = range
240
+ ? `${range.sourcePath ?? ''}:${range.startLine}:${range.startColumn ?? ''}-${range.endLine}:${range.endColumn ?? ''}`
241
+ : 'no-range';
242
+ const name =
243
+ (substep as { field?: string }).field ??
244
+ (substep as { alias?: string }).alias ??
245
+ '';
246
+ return `${substep.type}::${name}::${position}`;
247
+ }
248
+
249
+ function joinBySymbolThenExtent<TSubstep extends PlayStaticSubstep>(
250
+ binding: PlayDocflowBinding,
251
+ candidates: readonly TSubstep[],
252
+ symbolsOf: (substep: TSubstep) => Set<string>,
253
+ extentOf: DocflowExtentResolver = substepSourceExtent,
254
+ ): DocflowBindingJoin<TSubstep> {
255
+ const roots = docflowOutputRoots(binding);
256
+ if (roots.length > 0) {
257
+ const byIdentity = new Map<string, TSubstep>();
258
+ for (const candidate of candidates) {
259
+ const symbols = symbolsOf(candidate);
260
+ if (!roots.some((root) => symbols.has(root))) continue;
261
+ const identity = substepIdentity(candidate);
262
+ if (!byIdentity.has(identity)) byIdentity.set(identity, candidate);
263
+ }
264
+ const matches = [...byIdentity.values()];
265
+ if (matches.length === 1) return { substep: matches[0]!, via: 'symbol' };
266
+ if (matches.length > 1) {
267
+ // Two constructs compute the same column name. The one whose body the
268
+ // annotation actually sits in decides; otherwise this is drift, and a
269
+ // guess would attribute a member's rows to the wrong table.
270
+ const narrowed = smallestContaining(matches, binding.line, extentOf);
271
+ if (narrowed) return { substep: narrowed, via: 'symbol' };
272
+ return { substep: null, reason: 'ambiguous' };
273
+ }
274
+ }
275
+ const positional = smallestContaining(candidates, binding.line, extentOf);
276
+ if (positional) return { substep: positional, via: 'extent' };
277
+ return { substep: null, reason: 'no-owner' };
278
+ }
279
+
280
+ /**
281
+ * The dataset (or CSV) substep a docflow binding belongs to. Datasets carry
282
+ * computed-column symbols; a CSV substep has rows but no per-row column work,
283
+ * so it can only be reached by extent.
284
+ */
285
+ export function resolveDocflowOwnerSubstep(
286
+ binding: PlayDocflowBinding,
287
+ substeps: readonly PlayStaticSubstep[],
288
+ ): DocflowBindingJoin<DocflowOwnerSubstep> {
289
+ const owners = substeps.filter(
290
+ (substep): substep is DocflowOwnerSubstep =>
291
+ substep.type === 'dataset' || substep.type === 'csv',
292
+ );
293
+ return joinBySymbolThenExtent(binding, owners, (substep) =>
294
+ substep.type === 'dataset'
295
+ ? datasetComputedColumnNames(substep)
296
+ : new Set<string>(),
297
+ );
298
+ }
299
+
300
+ /** Same join, narrowed to dataset substeps (CSV has no per-row columns). */
301
+ export function resolveDocflowOwnerDataset(
302
+ binding: PlayDocflowBinding,
303
+ substeps: readonly PlayStaticSubstep[],
304
+ ): DocflowBindingJoin<DocflowDatasetSubstep> {
305
+ const resolution = resolveDocflowOwnerSubstep(binding, substeps);
306
+ if (resolution.substep?.type === 'dataset') {
307
+ return { substep: resolution.substep, via: resolution.via! };
308
+ }
309
+ return {
310
+ substep: null,
311
+ reason: resolution.substep ? 'no-owner' : resolution.reason,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * The top-level tool substep a docflow binding names. A tool substep's `field`
317
+ * is the column it produces, so a member's `out:"col"` joins by symbol; an
318
+ * assignment-named action node (`out:"searchResult"`) falls back to extent.
319
+ */
320
+ export function resolveDocflowBoundTool(
321
+ binding: PlayDocflowBinding,
322
+ substeps: readonly PlayStaticSubstep[],
323
+ ): DocflowBindingJoin<DocflowToolSubstep> {
324
+ const tools = substeps.filter(
325
+ (substep): substep is DocflowToolSubstep => substep.type === 'tool',
326
+ );
327
+ return joinBySymbolThenExtent(
328
+ binding,
329
+ tools,
330
+ (substep) => new Set([substep.field]),
331
+ );
332
+ }
333
+
334
+ /**
335
+ * The `ctx.runPlay` call a `type:"play"` docflow node names. Same join as every
336
+ * other binding — symbol first, extent as the fallback — over the FLATTENED
337
+ * substep tree, because a child call inside a `.withColumn(...)` body is nested
338
+ * under its dataset. A `play_call`'s `field` is the column or variable the child
339
+ * result lands in, which is exactly what the node's `out:` names.
340
+ */
341
+ export function resolveDocflowBoundPlayCall(
342
+ binding: PlayDocflowBinding,
343
+ substeps: readonly PlayStaticSubstep[],
344
+ ): DocflowBindingJoin<DocflowPlayCallSubstep> {
345
+ const playCalls = flattenSubsteps(substeps).filter(
346
+ (substep): substep is DocflowPlayCallSubstep =>
347
+ substep.type === 'play_call',
348
+ );
349
+ return joinBySymbolThenExtent(
350
+ binding,
351
+ playCalls,
352
+ (substep) => new Set(substep.field.split('.')),
353
+ );
354
+ }
355
+
356
+ // ── waterfalls: the step suite and its legs ─────────────────────────────────
357
+ //
358
+ // `steps().step('hunter_email', …).step('leadmagic_email', …).return(…)` compiles
359
+ // to ONE `step_suite` substep whose children are the legs. Six shipped prebuilts
360
+ // run one of these as their whole scalar body, 4–16 legs deep, and until this
361
+ // join existed an authored node over the waterfall resolved to nothing: the
362
+ // suite is not a `tool`, so `resolveDocflowBoundTool` skipped it and the node
363
+ // rendered with no provider, no legs, and no order — strictly less than the
364
+ // undiagrammed `StepSuiteCard` beside it.
365
+ //
366
+ // A leg is NOT one substep. The extractor emits one substep per tool CALL inside
367
+ // the leg's resolver, so a leg that finds a phone and then validates it emits
368
+ // two, both carrying the leg's own `field`. `contact-to-phone-waterfall` compiles
369
+ // 21 substeps for 11 legs that way. The leg is the `field`; its substeps are how
370
+ // it works.
371
+
372
+ /** The dotted tail of a leg's field relative to its suite (`steps.a` -> `a`). */
373
+ function legNameFromField(suiteField: string, legField: string): string {
374
+ return legField.startsWith(`${suiteField}.`)
375
+ ? legField.slice(suiteField.length + 1)
376
+ : legField;
377
+ }
378
+
379
+ /**
380
+ * One leg of a waterfall, as the source declares it. Ordered, deduplicated by
381
+ * field, and carrying only decidable facts about the text — never a claim about
382
+ * whether this run reached it.
383
+ */
384
+ export type DocflowStepSuiteLeg = {
385
+ /** The leg's durable step name — the string the author passed `.step(…)`. */
386
+ name: string;
387
+ /** The suite-qualified field, i.e. the compiled substep key. */
388
+ field: string;
389
+ /** The first provider call the leg makes, when it makes one. */
390
+ toolId: string | null;
391
+ /** Every provider call the leg makes, in order. */
392
+ toolIds: string[];
393
+ /** The leg is `runIf`-guarded, so whether it runs depends on earlier legs. */
394
+ conditional: boolean;
395
+ /** Every substep of the leg is statically off (`runIf: () => false`). */
396
+ disabled: boolean;
397
+ /** The leg is itself a nested `steps()` program. */
398
+ nested: boolean;
399
+ };
400
+
401
+ /**
402
+ * The legs of a suite, in declaration order. Deduplicated by field so a
403
+ * find-then-validate leg reads as one attempt; `conditional` is true when ANY of
404
+ * a leg's substeps is guarded, `disabled` only when EVERY one is off.
405
+ */
406
+ export function stepSuiteLegs(
407
+ suite: DocflowStepSuiteSubstep,
408
+ ): DocflowStepSuiteLeg[] {
409
+ const byField = new Map<string, DocflowStepSuiteLeg>();
410
+ for (const child of suite.steps ?? []) {
411
+ const field = (child as { field?: string }).field ?? '';
412
+ if (!field) continue;
413
+ const toolIds = flattenSubsteps([child])
414
+ .filter((step): step is DocflowToolSubstep => step.type === 'tool')
415
+ .map((step) => step.toolId);
416
+ const existing = byField.get(field);
417
+ if (existing) {
418
+ existing.toolIds.push(...toolIds);
419
+ existing.toolId ??= toolIds[0] ?? null;
420
+ existing.conditional ||= child.conditional === true;
421
+ existing.disabled &&= child.disabled === true;
422
+ existing.nested ||= child.type === 'step_suite';
423
+ continue;
424
+ }
425
+ byField.set(field, {
426
+ name: legNameFromField(suite.field, field),
427
+ field,
428
+ toolId: toolIds[0] ?? null,
429
+ toolIds: [...toolIds],
430
+ conditional: child.conditional === true,
431
+ disabled: child.disabled === true,
432
+ nested: child.type === 'step_suite',
433
+ });
434
+ }
435
+ return [...byField.values()];
436
+ }
437
+
438
+ /**
439
+ * The `step_suite` a docflow node names — the whole waterfall as one step.
440
+ *
441
+ * Symbol first, like every other join. Inside a dataset the suite's `field` IS
442
+ * the column it fills (`.withColumn('email_result', personToEmailSteps())`
443
+ * compiles to `step_suite` field `email_result`), so `out:"email_result"` binds
444
+ * by name. A scalar `ctx.runSteps(personalEmailSteps(), input)` has no such
445
+ * name — the extractor calls it `steps` because the argument is a call, not a
446
+ * named program — so `out:"result"` falls through to position.
447
+ *
448
+ * That fallback measures the suite's OWN call range, never the union with its
449
+ * legs: an imported builder's legs live in another file, and unioning line
450
+ * numbers across files produces an interval that means nothing. The annotation
451
+ * sits directly above the `ctx.runSteps(...)` call, which is exactly what the
452
+ * own range covers.
453
+ */
454
+ export function resolveDocflowBoundStepSuite(
455
+ binding: PlayDocflowBinding,
456
+ substeps: readonly PlayStaticSubstep[],
457
+ ): DocflowBindingJoin<DocflowStepSuiteSubstep> {
458
+ const suites = flattenSubsteps(substeps).filter(
459
+ (substep): substep is DocflowStepSuiteSubstep =>
460
+ substep.type === 'step_suite',
461
+ );
462
+ return joinBySymbolThenExtent(
463
+ binding,
464
+ suites,
465
+ (substep) => new Set([substep.field, substep.field.split('.').pop()!]),
466
+ substepOwnSourceExtent,
467
+ );
468
+ }
469
+
470
+ /** One leg, with the suite it belongs to. */
471
+ export type DocflowBoundStepLeg = {
472
+ suite: DocflowStepSuiteSubstep;
473
+ leg: DocflowStepSuiteLeg;
474
+ };
475
+
476
+ /**
477
+ * The waterfall LEG a docflow node names, for a member drawn inside a waterfall
478
+ * region. Symbol only — a leg name is always available (the author wrote it as a
479
+ * string literal in `.step('<name>', …)`), so there is nothing position could
480
+ * add, and the legs of an imported builder have no line coordinate this file
481
+ * could compare against anyway.
482
+ *
483
+ * Ambiguity is real and reported: two suites in one play may both declare a leg
484
+ * called `validate`, and picking one would attribute a provider to the wrong
485
+ * cascade.
486
+ */
487
+ export function resolveDocflowBoundStepLeg(
488
+ binding: PlayDocflowBinding,
489
+ substeps: readonly PlayStaticSubstep[],
490
+ ): DocflowBindingJoin<DocflowBoundStepLeg> {
491
+ const roots = docflowOutputRoots(binding);
492
+ if (roots.length === 0) return { substep: null, reason: 'no-owner' };
493
+ const seen = new Set<string>();
494
+ const matches: DocflowBoundStepLeg[] = [];
495
+ for (const suite of flattenSubsteps(substeps)) {
496
+ if (suite.type !== 'step_suite') continue;
497
+ for (const leg of stepSuiteLegs(suite)) {
498
+ if (!roots.includes(leg.name) && !roots.includes(leg.field)) continue;
499
+ // The same suite reaches this walk through both the stages tree and the
500
+ // flattened compiled substeps, so identity is the leg's field, not the
501
+ // object.
502
+ if (seen.has(leg.field)) continue;
503
+ seen.add(leg.field);
504
+ matches.push({ suite, leg });
505
+ }
506
+ }
507
+ if (matches.length === 1) return { substep: matches[0]!, via: 'symbol' };
508
+ return {
509
+ substep: null,
510
+ reason: matches.length > 1 ? 'ambiguous' : 'no-owner',
511
+ };
512
+ }
513
+
514
+ // ── the waterfall region ────────────────────────────────────────────────────
515
+
516
+ export type DocflowWaterfallRegionMember = {
517
+ nodeId: string;
518
+ leg: DocflowStepSuiteLeg;
519
+ /** 1-based position of the leg in the cascade the suite declares. */
520
+ position: number;
521
+ };
522
+
523
+ /**
524
+ * A `subgraph` whose members are the legs of one cascade.
525
+ *
526
+ * Ownership needs no edge and no coordinate: a member's `out:` names a leg, a leg
527
+ * belongs to exactly one suite, so the members THEMSELVES say which cascade the
528
+ * region is. That is strictly better than the dataset loop region, which has to
529
+ * fall back to "which dataset does an edge touch" and then disambiguate by line.
530
+ */
531
+ export type DocflowWaterfallRegion = {
532
+ subgraphId: string;
533
+ suite: DocflowStepSuiteSubstep;
534
+ /** Drawn legs, in the order the SUITE declares them, not the order drawn. */
535
+ members: DocflowWaterfallRegionMember[];
536
+ /**
537
+ * Members that carry an `out:` and still name no leg of this suite. A claim
538
+ * the region cannot back — surfaced, never rendered as if fine.
539
+ */
540
+ foreignMemberIds: string[];
541
+ };
542
+
543
+ export type DocflowWaterfallRegionIndex = {
544
+ regions: DocflowWaterfallRegion[];
545
+ /**
546
+ * Subgraphs whose members name legs of MORE than one cascade. One region
547
+ * cannot be two waterfalls, and picking one would attribute providers to the
548
+ * wrong cascade.
549
+ */
550
+ splitRegions: Array<{ subgraphId: string; suiteFields: string[] }>;
551
+ };
552
+
553
+ /**
554
+ * Every waterfall region in a diagram. A subgraph with no leg-bound member is
555
+ * not one (it is a dataset loop, or pure presentation) and is absent from both
556
+ * lists, so this is safe to run over any diagram.
557
+ */
558
+ export function resolveDocflowWaterfallRegions(
559
+ docflow: PlayDocflow | null | undefined,
560
+ substeps: readonly PlayStaticSubstep[],
561
+ ): DocflowWaterfallRegionIndex {
562
+ const regions: DocflowWaterfallRegion[] = [];
563
+ const splitRegions: Array<{ subgraphId: string; suiteFields: string[] }> = [];
564
+ if (!docflow?.subgraphs?.length) return { regions, splitRegions };
565
+ const bindingByNodeId = new Map(
566
+ docflow.bindings.map((binding) => [binding.nodeId, binding]),
567
+ );
568
+ for (const subgraph of docflow.subgraphs) {
569
+ const resolved: Array<{
570
+ nodeId: string;
571
+ suite: DocflowStepSuiteSubstep;
572
+ leg: DocflowStepSuiteLeg;
573
+ }> = [];
574
+ const foreignMemberIds: string[] = [];
575
+ for (const memberId of subgraph.memberIds) {
576
+ const binding = bindingByNodeId.get(memberId);
577
+ // An unbound member, or one that names nothing, is presentation — a note
578
+ // drawn inside the region. Only a member making a claim can be wrong.
579
+ if (!binding || docflowOutputRoots(binding).length === 0) continue;
580
+ const join = resolveDocflowBoundStepLeg(binding, substeps);
581
+ if (join.substep) {
582
+ resolved.push({ nodeId: memberId, ...join.substep });
583
+ } else {
584
+ foreignMemberIds.push(memberId);
585
+ }
586
+ }
587
+ if (resolved.length === 0) continue;
588
+ const suiteFields = [
589
+ ...new Set(resolved.map((entry) => entry.suite.field)),
590
+ ];
591
+ if (suiteFields.length > 1) {
592
+ splitRegions.push({ subgraphId: subgraph.id, suiteFields });
593
+ continue;
594
+ }
595
+ const suite = resolved[0]!.suite;
596
+ const order = stepSuiteLegs(suite).map((leg) => leg.field);
597
+ regions.push({
598
+ subgraphId: subgraph.id,
599
+ suite,
600
+ members: resolved
601
+ .map((entry) => ({
602
+ nodeId: entry.nodeId,
603
+ leg: entry.leg,
604
+ position: order.indexOf(entry.leg.field) + 1,
605
+ }))
606
+ .sort((left, right) => left.position - right.position),
607
+ // Only a member that claimed a leg of THIS suite and missed is foreign.
608
+ // A member naming a leg of another suite already made the region split.
609
+ foreignMemberIds,
610
+ });
611
+ }
612
+ return { regions, splitRegions };
613
+ }
614
+
615
+ export type DocflowOwnerIndex = {
616
+ /** Owning dataset/CSV substep per bound docflow node id. */
617
+ ownerByNodeId: Map<string, DocflowOwnerSubstep>;
618
+ /** Bindings no substep owns, with why — a state to surface, not to default. */
619
+ unresolved: Array<{ nodeId: string; reason: DocflowJoinFailure }>;
620
+ };
621
+
622
+ /** Resolves every binding in a docflow once, for callers that need the map. */
623
+ export function resolveDocflowOwnerIndex(
624
+ docflow: PlayDocflow | null | undefined,
625
+ substeps: readonly PlayStaticSubstep[],
626
+ ): DocflowOwnerIndex {
627
+ const ownerByNodeId = new Map<string, DocflowOwnerSubstep>();
628
+ const unresolved: Array<{ nodeId: string; reason: DocflowJoinFailure }> = [];
629
+ for (const binding of docflow?.bindings ?? []) {
630
+ const resolution = resolveDocflowOwnerSubstep(binding, substeps);
631
+ if (resolution.substep)
632
+ ownerByNodeId.set(binding.nodeId, resolution.substep);
633
+ else unresolved.push({ nodeId: binding.nodeId, reason: resolution.reason });
634
+ }
635
+ return { ownerByNodeId, unresolved };
636
+ }