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,598 @@
1
+ /**
2
+ * Docflow binding resolution (ADR 0016 rule 1), esbuild-free.
3
+ *
4
+ * `plays check` runs inside the app's launch path (`start-run` ->
5
+ * `preflight-validation`), which must never reach esbuild
6
+ * (`check:absurd-launch-artifacts`). The play bundler imports these same
7
+ * resolvers so instrumentation and lint agree on one statement per binding;
8
+ * only the bundler layers its esbuild-fallback parse on top.
9
+ */
10
+ import {
11
+ astArray,
12
+ isAstNode,
13
+ parsePlaySourceForAnalysis,
14
+ type AstNode,
15
+ } from './ts-ast';
16
+ import {
17
+ parsePlayDocflow,
18
+ type ParsePlayDocflowOptions,
19
+ type PlayDocflowBinding,
20
+ } from './docflow';
21
+ import { isDefinePlayCall } from './play-exports';
22
+
23
+ export { astArray, isAstNode, TypeScriptParser, type AstNode } from './ts-ast';
24
+
25
+ /**
26
+ * Acorn-only parse for binding resolution. Source the acorn TS plugin cannot
27
+ * parse abstains (returns null) — the bundler's esbuild-fallback parse still
28
+ * covers instrumentation, and the TS diagnostics own the syntax error.
29
+ */
30
+ const parseDocflowSourceAst = parsePlaySourceForAnalysis;
31
+
32
+ export function sourceLineStarts(sourceCode: string): number[] {
33
+ const starts = [0];
34
+ for (let index = 0; index < sourceCode.length; index += 1) {
35
+ if (sourceCode[index] === '\n') starts.push(index + 1);
36
+ }
37
+ return starts;
38
+ }
39
+
40
+ export function astNodeBounds(
41
+ node: AstNode,
42
+ ): { start: number; end: number } | null {
43
+ return typeof node.start === 'number' && typeof node.end === 'number'
44
+ ? { start: node.start, end: node.end }
45
+ : null;
46
+ }
47
+
48
+ export function findDocflowBoundStatement(
49
+ ast: AstNode,
50
+ line: number,
51
+ lineStarts: readonly number[],
52
+ ): AstNode | null {
53
+ const lineStart = lineStarts[line - 1];
54
+ const lineEnd = lineStarts[line] ?? Number.POSITIVE_INFINITY;
55
+ if (lineStart === undefined) return null;
56
+ let match: AstNode | null = null;
57
+ const pending: AstNode[] = [ast];
58
+ const statementTypes = new Set([
59
+ 'ExpressionStatement',
60
+ 'IfStatement',
61
+ 'ReturnStatement',
62
+ 'ThrowStatement',
63
+ 'VariableDeclaration',
64
+ ]);
65
+ while (pending.length > 0) {
66
+ const node = pending.pop()!;
67
+ const bounds = astNodeBounds(node);
68
+ // A binding can sit mid-statement (e.g. a chained `.withColumn(...)`
69
+ // line), so any statement whose span overlaps the line is a candidate;
70
+ // the smallest span wins, which prefers statements starting on the line.
71
+ if (
72
+ bounds &&
73
+ bounds.start < lineEnd &&
74
+ bounds.end > lineStart &&
75
+ statementTypes.has(node.type)
76
+ ) {
77
+ if (
78
+ !match ||
79
+ bounds.end - bounds.start <
80
+ (astNodeBounds(match)?.end ?? 0) - (astNodeBounds(match)?.start ?? 0)
81
+ ) {
82
+ match = node;
83
+ }
84
+ }
85
+ for (const child of Object.values(node)) {
86
+ if (Array.isArray(child)) pending.push(...child.filter(isAstNode));
87
+ else if (isAstNode(child)) pending.push(child);
88
+ }
89
+ }
90
+ return match;
91
+ }
92
+
93
+ /**
94
+ * The statement types a docflow binding may attach to. Symbol resolution walks
95
+ * up from a matched `withColumn(...)` call or variable declarator to the
96
+ * enclosing member of this set, so a mid-chain match resolves to the same
97
+ * outermost statement positional resolution would produce.
98
+ */
99
+ const DOCFLOW_STATEMENT_TYPES = new Set([
100
+ 'ExpressionStatement',
101
+ 'IfStatement',
102
+ 'ReturnStatement',
103
+ 'ThrowStatement',
104
+ 'VariableDeclaration',
105
+ ]);
106
+
107
+ /** The first path segment of a contract output, e.g. `foo.bar` -> `foo`. */
108
+ export function docflowOutputRoot(binding: PlayDocflowBinding): string | null {
109
+ const first = binding.outputs?.[0];
110
+ if (!first) return null;
111
+ const root = first.split('.')[0]!;
112
+ if (!root || root === '$output') return null;
113
+ return root;
114
+ }
115
+
116
+ /**
117
+ * Walks every node once, recording each node's parent, so a matched inner node
118
+ * (a `withColumn` call, a variable declarator) can climb to the enclosing
119
+ * bindable statement. Acorn nodes carry no parent pointer, so we build one.
120
+ */
121
+ export function buildDocflowParentIndex(ast: AstNode): Map<AstNode, AstNode> {
122
+ const parents = new Map<AstNode, AstNode>();
123
+ const pending: AstNode[] = [ast];
124
+ while (pending.length > 0) {
125
+ const node = pending.pop()!;
126
+ for (const child of Object.values(node)) {
127
+ const children = Array.isArray(child)
128
+ ? child.filter(isAstNode)
129
+ : isAstNode(child)
130
+ ? [child]
131
+ : [];
132
+ for (const nested of children) {
133
+ parents.set(nested, node);
134
+ pending.push(nested);
135
+ }
136
+ }
137
+ }
138
+ return parents;
139
+ }
140
+
141
+ /** Climbs `parents` from `node` to the nearest bindable statement, if any. */
142
+ export function enclosingDocflowStatement(
143
+ node: AstNode,
144
+ parents: ReadonlyMap<AstNode, AstNode>,
145
+ ): AstNode | null {
146
+ let current: AstNode | null = node;
147
+ while (current) {
148
+ if (DOCFLOW_STATEMENT_TYPES.has(current.type)) return current;
149
+ current = parents.get(current) ?? null;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Is `node` a `withColumn('<columnName>', …)` call — callee property named
156
+ * `withColumn` with a first string-literal argument equal to `columnName`?
157
+ */
158
+ function isWithColumnCallForName(node: AstNode, columnName: string): boolean {
159
+ if (node.type !== 'CallExpression') return false;
160
+ const callee = isAstNode(node.callee) ? node.callee : null;
161
+ if (
162
+ !callee ||
163
+ callee.type !== 'MemberExpression' ||
164
+ callee.computed ||
165
+ !isAstNode(callee.property) ||
166
+ callee.property.type !== 'Identifier' ||
167
+ callee.property.name !== 'withColumn'
168
+ ) {
169
+ return false;
170
+ }
171
+ const firstArg = astArray(node.arguments)[0];
172
+ return Boolean(
173
+ firstArg &&
174
+ firstArg.type === 'Literal' &&
175
+ typeof firstArg.value === 'string' &&
176
+ firstArg.value === columnName,
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Is `node` a `.step('<name>', …)` call?
182
+ *
183
+ * Both `steps().step('hunter_email', …)` (a waterfall leg) and
184
+ * `ctx.step('score', …)` (a durable scalar step) match, deliberately: the docflow
185
+ * language cares that the call DECLARES a producer named `<name>`, and both do.
186
+ * The compiled pipeline distinguishes them; the resolver does not have to.
187
+ *
188
+ * Structurally identical to `withColumn('<name>', …)`, which is exactly why a
189
+ * leg was unbindable until now — `resolveDocflowBindingSymbol` knew that shape
190
+ * only under the name `withColumn`.
191
+ */
192
+ export function isDocflowStepCallForName(
193
+ node: AstNode,
194
+ stepName: string,
195
+ ): boolean {
196
+ if (node.type !== 'CallExpression') return false;
197
+ const callee = isAstNode(node.callee) ? node.callee : null;
198
+ if (
199
+ !callee ||
200
+ callee.type !== 'MemberExpression' ||
201
+ callee.computed ||
202
+ !isAstNode(callee.property) ||
203
+ callee.property.type !== 'Identifier' ||
204
+ callee.property.name !== 'step'
205
+ ) {
206
+ return false;
207
+ }
208
+ const firstArg = astArray(node.arguments)[0];
209
+ return Boolean(
210
+ firstArg &&
211
+ firstArg.type === 'Literal' &&
212
+ typeof firstArg.value === 'string' &&
213
+ firstArg.value === stepName,
214
+ );
215
+ }
216
+
217
+ /**
218
+ * Would observing `expression` time a `steps()` BUILDER rather than the leg the
219
+ * binding names?
220
+ *
221
+ * True when a `.step('<root>', …)` call for the binding's own symbol sits INSIDE
222
+ * the expression but is not the expression's own outermost call. That is exactly
223
+ * the waterfall-leg shape: a leg's enclosing statement is
224
+ * `return steps().step('a', …).step('b', …).return(…)`, one expression shared by
225
+ * every leg, which runs once and synchronously to build a program object.
226
+ * Wrapping it reports the builder's construction — settled instantly, with the
227
+ * program as its output — under each leg's node id.
228
+ *
229
+ * The "not the outermost call" clause is what keeps a legitimate
230
+ * `const score = await ctx.step('score', …)` observable: there the step call IS
231
+ * the observed expression, so observing it observes the step.
232
+ */
233
+ export function docflowExpressionWrapsStepBuilder(
234
+ expression: AstNode,
235
+ stepName: string,
236
+ ): boolean {
237
+ const unwrapped =
238
+ expression.type === 'AwaitExpression' && isAstNode(expression.argument)
239
+ ? expression.argument
240
+ : expression;
241
+ if (isDocflowStepCallForName(unwrapped, stepName)) return false;
242
+ const pending: AstNode[] = [unwrapped];
243
+ while (pending.length > 0) {
244
+ const node = pending.pop()!;
245
+ if (node !== unwrapped && isDocflowStepCallForName(node, stepName)) {
246
+ return true;
247
+ }
248
+ for (const child of Object.values(node)) {
249
+ if (Array.isArray(child)) pending.push(...child.filter(isAstNode));
250
+ else if (isAstNode(child)) pending.push(child);
251
+ }
252
+ }
253
+ return false;
254
+ }
255
+
256
+ function getIdentifierName(node: unknown): string | null {
257
+ return isAstNode(node) && node.type === 'Identifier'
258
+ ? typeof node.name === 'string'
259
+ ? node.name
260
+ : null
261
+ : null;
262
+ }
263
+
264
+ /**
265
+ * The innermost `definePlay(…)` call whose span contains `line`, or null when
266
+ * the line sits outside every play (a module-level helper, or a file whose
267
+ * plays this parser cannot see). Position is the right resolver here and only
268
+ * here: which play a source line belongs to is a fact about the text, not a
269
+ * claim about execution.
270
+ */
271
+ function enclosingDefinePlayCall(
272
+ ast: AstNode,
273
+ line: number,
274
+ lineStarts: readonly number[],
275
+ ): AstNode | null {
276
+ const lineStart = lineStarts[line - 1];
277
+ const lineEnd = lineStarts[line] ?? Number.POSITIVE_INFINITY;
278
+ if (lineStart === undefined) return null;
279
+ let match: AstNode | null = null;
280
+ const pending: AstNode[] = [ast];
281
+ while (pending.length > 0) {
282
+ const node = pending.pop()!;
283
+ const bounds = astNodeBounds(node);
284
+ if (
285
+ bounds &&
286
+ bounds.start < lineEnd &&
287
+ bounds.end > lineStart &&
288
+ isDefinePlayCall(node)
289
+ ) {
290
+ const matchBounds = match ? astNodeBounds(match) : null;
291
+ if (
292
+ !match ||
293
+ !matchBounds ||
294
+ bounds.end - bounds.start < matchBounds.end - matchBounds.start
295
+ ) {
296
+ match = node;
297
+ }
298
+ }
299
+ for (const child of Object.values(node)) {
300
+ if (Array.isArray(child)) pending.push(...child.filter(isAstNode));
301
+ else if (isAstNode(child)) pending.push(child);
302
+ }
303
+ }
304
+ return match;
305
+ }
306
+
307
+ /** Does `node` declare a variable named `name` (VariableDeclarator id)? */
308
+ function isVariableDeclarationForName(node: AstNode, name: string): boolean {
309
+ if (node.type !== 'VariableDeclaration') return false;
310
+ return astArray(node.declarations).some(
311
+ (declarator) => getIdentifierName(declarator.id) === name,
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Which shape produced a symbol match. Reported so a caller can tell a DECLARED
317
+ * PRODUCER (`withColumn('x', …)` / `.step('x', …)`) from an ordinary `const x`;
318
+ * the instrumentation skip tests the observed EXPRESSION instead, because a leg
319
+ * built in a module-level helper resolves positionally and never reaches here.
320
+ */
321
+ export type DocflowSymbolKind = 'declaredColumn' | 'declaredStep' | 'variable';
322
+
323
+ export type DocflowSymbolResolution =
324
+ | { kind: 'resolved'; statement: AstNode; symbolKind: DocflowSymbolKind }
325
+ | { kind: 'ambiguous'; candidates: AstNode[] }
326
+ | { kind: 'abstain' };
327
+
328
+ /**
329
+ * ADR 0016 rule 1: resolve a docflow binding to its statement by the symbol its
330
+ * `out:` contract names, not by line proximity.
331
+ *
332
+ * - If the output root names a DECLARED PRODUCER — `withColumn('<root>', …)` or
333
+ * `.step('<root>', …)` — the candidates are the statements containing such
334
+ * calls. Both shapes are the same tier: each is a call whose first string
335
+ * literal names the value it produces, and neither is more specific than the
336
+ * other. A file where a column and a step share a name therefore yields TWO
337
+ * candidates and falls to the selection rule below — it is never silently
338
+ * decided by which shape the resolver happened to look for first.
339
+ * - Else if the output root names a declared variable, the candidates are those
340
+ * declarations. A declared producer still beats a same-named variable: the
341
+ * producer call is the thing the compiler turns into a step, the variable is
342
+ * ordinary binding.
343
+ * - Else (no outputs / `$output` / decisions / returns), symbol resolution
344
+ * abstains and position remains the resolver.
345
+ *
346
+ * Selection among candidates: the one whose span contains the annotated line
347
+ * wins; failing that, a single candidate in the whole body wins; multiple
348
+ * candidates with none containing the line is ambiguous (a loud drift error,
349
+ * never a guess).
350
+ */
351
+ export function resolveDocflowBindingSymbol(
352
+ ast: AstNode,
353
+ binding: PlayDocflowBinding,
354
+ lineStarts: readonly number[],
355
+ parents?: ReadonlyMap<AstNode, AstNode>,
356
+ ): DocflowSymbolResolution {
357
+ const root = docflowOutputRoot(binding);
358
+ if (!root) return { kind: 'abstain' };
359
+ const parentIndex = parents ?? buildDocflowParentIndex(ast);
360
+ // A file can hold more than one play, and two plays routinely compute a
361
+ // column or variable of the same name (`score`, `email`, `rows`). Searching
362
+ // the whole file would let a symbol in the OTHER play capture this
363
+ // annotation — resolved to a statement that never runs in this play, or
364
+ // reported as drift when the annotation was right all along. Search the
365
+ // enclosing `definePlay(…)` instead, which is exactly the play whose diagram
366
+ // this node belongs to.
367
+ const scope = enclosingDefinePlayCall(ast, binding.line, lineStarts) ?? ast;
368
+ const inScope = (node: AstNode) => {
369
+ if (scope === ast) return true;
370
+ const bounds = astNodeBounds(node);
371
+ const scopeBounds = astNodeBounds(scope);
372
+ return Boolean(
373
+ bounds &&
374
+ scopeBounds &&
375
+ bounds.start >= scopeBounds.start &&
376
+ bounds.end <= scopeBounds.end,
377
+ );
378
+ };
379
+
380
+ const matchesColumn = (node: AstNode) =>
381
+ isWithColumnCallForName(node, root) && inScope(node);
382
+ const matchesStep = (node: AstNode) =>
383
+ isDocflowStepCallForName(node, root) && inScope(node);
384
+ const matchesVariable = (node: AstNode) =>
385
+ isVariableDeclarationForName(node, root) && inScope(node);
386
+
387
+ const collectStatements = (
388
+ predicate: (node: AstNode) => boolean,
389
+ ): Map<AstNode, DocflowSymbolKind> => {
390
+ const statements = new Map<AstNode, DocflowSymbolKind>();
391
+ const pending: AstNode[] = [scope];
392
+ while (pending.length > 0) {
393
+ const node = pending.pop()!;
394
+ if (predicate(node)) {
395
+ const statement = enclosingDocflowStatement(node, parentIndex);
396
+ const kind: DocflowSymbolKind = matchesColumn(node)
397
+ ? 'declaredColumn'
398
+ : matchesStep(node)
399
+ ? 'declaredStep'
400
+ : 'variable';
401
+ // One statement can hold both shapes — a `withColumn('x', …)` whose body
402
+ // builds a `steps().step('x', …)` program. The column is what the
403
+ // compiler turns into the statement's step, so it wins regardless of
404
+ // which node the walk reached first; without this the classification
405
+ // would depend on traversal order.
406
+ if (
407
+ statement &&
408
+ (!statements.has(statement) || kind === 'declaredColumn')
409
+ ) {
410
+ statements.set(statement, kind);
411
+ }
412
+ }
413
+ for (const child of Object.values(node)) {
414
+ if (Array.isArray(child)) pending.push(...child.filter(isAstNode));
415
+ else if (isAstNode(child)) pending.push(child);
416
+ }
417
+ }
418
+ return statements;
419
+ };
420
+
421
+ // Declared producers — `withColumn('R', …)` and `.step('R', …)` — are ONE
422
+ // tier. Both are calls naming the value they produce, so neither can claim
423
+ // priority over the other without guessing; a genuine collision falls through
424
+ // to the containment rule and then errors. Both beat a same-named variable.
425
+ let candidates = collectStatements(
426
+ (node) => matchesColumn(node) || matchesStep(node),
427
+ );
428
+ if (candidates.size === 0) candidates = collectStatements(matchesVariable);
429
+ if (candidates.size === 0) return { kind: 'abstain' };
430
+
431
+ const resolved = (statement: AstNode): DocflowSymbolResolution => ({
432
+ kind: 'resolved',
433
+ statement,
434
+ symbolKind: candidates.get(statement) ?? 'variable',
435
+ });
436
+
437
+ const lineStart = lineStarts[binding.line - 1];
438
+ const lineEnd = lineStarts[binding.line] ?? Number.POSITIVE_INFINITY;
439
+ if (lineStart !== undefined) {
440
+ const containing = [...candidates.keys()].filter((statement) => {
441
+ const bounds = astNodeBounds(statement);
442
+ return bounds && bounds.start < lineEnd && bounds.end > lineStart;
443
+ });
444
+ if (containing.length === 1) return resolved(containing[0]!);
445
+ if (containing.length > 1) {
446
+ return { kind: 'ambiguous', candidates: containing };
447
+ }
448
+ }
449
+
450
+ if (candidates.size === 1) {
451
+ return resolved([...candidates.keys()][0]!);
452
+ }
453
+ return { kind: 'ambiguous', candidates: [...candidates.keys()] };
454
+ }
455
+
456
+ export type DocflowBindingDriftDetail = {
457
+ binding: PlayDocflowBinding;
458
+ /** The output root the symbol resolver keyed on (never `$output`). */
459
+ symbol: string;
460
+ /** 1-based line where the annotated statement resolves positionally. */
461
+ annotatedLine: number | null;
462
+ /** 1-based line where the named symbol actually lives, when known. */
463
+ symbolLine: number | null;
464
+ /** Ambiguous when two chains compute the same column and none contains the line. */
465
+ ambiguous: boolean;
466
+ };
467
+
468
+ export type DocflowBindingResolution = {
469
+ binding: PlayDocflowBinding;
470
+ /** The statement instrumentation should wrap; symbol wins when it resolves. */
471
+ statement: AstNode | null;
472
+ drift: DocflowBindingDriftDetail | null;
473
+ /** Which shape the symbol matched, when it matched one; null when position won. */
474
+ symbolKind: DocflowSymbolKind | null;
475
+ };
476
+
477
+ function statementLine(
478
+ statement: AstNode | null,
479
+ lineStarts: readonly number[],
480
+ ): number | null {
481
+ const bounds = statement ? astNodeBounds(statement) : null;
482
+ if (!bounds) return null;
483
+ // The 1-based line is the count of line starts at or before the offset.
484
+ let line = 1;
485
+ for (let index = 0; index < lineStarts.length; index += 1) {
486
+ if (lineStarts[index]! > bounds.start) break;
487
+ line = index + 1;
488
+ }
489
+ return line;
490
+ }
491
+
492
+ /**
493
+ * Resolves one docflow binding for BOTH instrumentation and lint, so the two
494
+ * agree (ADR 0016 rule 1). Symbol resolution wins when it resolves
495
+ * unambiguously; position is the fallback. Drift is reported when symbol and
496
+ * position both resolve to different statements, or the symbol match is
497
+ * ambiguous.
498
+ */
499
+ export function resolveDocflowBinding(
500
+ ast: AstNode,
501
+ binding: PlayDocflowBinding,
502
+ lineStarts: readonly number[],
503
+ parents?: ReadonlyMap<AstNode, AstNode>,
504
+ ): DocflowBindingResolution {
505
+ const positional = findDocflowBoundStatement(ast, binding.line, lineStarts);
506
+ const symbol = resolveDocflowBindingSymbol(ast, binding, lineStarts, parents);
507
+ const root = docflowOutputRoot(binding);
508
+
509
+ if (symbol.kind === 'ambiguous') {
510
+ return {
511
+ binding,
512
+ statement: positional,
513
+ symbolKind: null,
514
+ drift: {
515
+ binding,
516
+ symbol: root ?? '',
517
+ annotatedLine: statementLine(positional, lineStarts),
518
+ symbolLine: statementLine(symbol.candidates[0] ?? null, lineStarts),
519
+ ambiguous: true,
520
+ },
521
+ };
522
+ }
523
+
524
+ if (symbol.kind === 'resolved') {
525
+ const drift =
526
+ positional && positional !== symbol.statement
527
+ ? {
528
+ binding,
529
+ symbol: root ?? '',
530
+ annotatedLine: statementLine(positional, lineStarts),
531
+ symbolLine: statementLine(symbol.statement, lineStarts),
532
+ ambiguous: false,
533
+ }
534
+ : null;
535
+ // Symbol statement is the instrumentation target; it makes lint and the
536
+ // observation wrapper agree even when the annotation drifted onto a
537
+ // neighbouring line of the same chain.
538
+ return {
539
+ binding,
540
+ statement: symbol.statement,
541
+ symbolKind: symbol.symbolKind,
542
+ drift,
543
+ };
544
+ }
545
+
546
+ return { binding, statement: positional, symbolKind: null, drift: null };
547
+ }
548
+
549
+ /**
550
+ * Resolves every docflow binding in `sourceCode` and returns the ones that
551
+ * drift — where the annotated line and the named symbol disagree, or the symbol
552
+ * match is ambiguous. `plays check` turns these into `docflow_binding_drift`
553
+ * errors. Returns an empty array for undiagrammed plays and unparseable source
554
+ * (other validators own those failures).
555
+ */
556
+ export function collectDocflowBindingDrift(
557
+ sourceCode: string,
558
+ options: ParsePlayDocflowOptions = {},
559
+ ): DocflowBindingDriftDetail[] {
560
+ const parsed = parsePlayDocflow(sourceCode, options);
561
+ if (!parsed.docflow || parsed.errors.length > 0) return [];
562
+ const ast = parseDocflowSourceAst(sourceCode);
563
+ if (!ast) return [];
564
+ const lineStarts = sourceLineStarts(sourceCode);
565
+ const parents = buildDocflowParentIndex(ast);
566
+ const drift: DocflowBindingDriftDetail[] = [];
567
+ for (const binding of parsed.docflow.bindings) {
568
+ const resolution = resolveDocflowBinding(ast, binding, lineStarts, parents);
569
+ if (resolution.drift) drift.push(resolution.drift);
570
+ }
571
+ return drift;
572
+ }
573
+
574
+ /**
575
+ * Maps each docflow binding node id to the 1-based line of the statement it
576
+ * resolves to (symbol first, position as fallback). `plays check` loop lint
577
+ * uses this to attribute a loop member to the dataset chain that runs it by the
578
+ * column its `out:` names, not by the line the annotation drifted onto.
579
+ * Bindings that abstain (no `out:` symbol) map to their positional line.
580
+ */
581
+ export function resolveDocflowBindingLines(
582
+ sourceCode: string,
583
+ options: ParsePlayDocflowOptions = {},
584
+ ): Map<string, number> {
585
+ const lines = new Map<string, number>();
586
+ const parsed = parsePlayDocflow(sourceCode, options);
587
+ if (!parsed.docflow || parsed.errors.length > 0) return lines;
588
+ const ast = parseDocflowSourceAst(sourceCode);
589
+ if (!ast) return lines;
590
+ const lineStarts = sourceLineStarts(sourceCode);
591
+ const parents = buildDocflowParentIndex(ast);
592
+ for (const binding of parsed.docflow.bindings) {
593
+ const resolution = resolveDocflowBinding(ast, binding, lineStarts, parents);
594
+ const resolvedLine = statementLine(resolution.statement, lineStarts);
595
+ lines.set(binding.nodeId, resolvedLine ?? binding.line);
596
+ }
597
+ return lines;
598
+ }