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.
- package/dist/bundling-sources/sdk/src/client.ts +17 -1
- package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/types.ts +43 -1
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +30 -1
- package/dist/bundling-sources/shared_libs/play-runtime/cell-provenance.ts +231 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1094 -128
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +178 -9
- package/dist/bundling-sources/shared_libs/play-runtime/docflow-node-io.ts +634 -0
- package/dist/bundling-sources/shared_libs/play-runtime/docflow-observation.ts +64 -0
- package/dist/bundling-sources/shared_libs/play-runtime/dynamic-worker-version.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +18 -0
- package/dist/bundling-sources/shared_libs/play-runtime/live-state-contract.ts +33 -0
- package/dist/bundling-sources/shared_libs/play-runtime/log-provenance.ts +251 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-node-scope.ts +160 -0
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +6 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +27 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +43 -5
- package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +12 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/local-process.ts +26 -4
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +6 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +83 -0
- package/dist/bundling-sources/shared_libs/play-runtime/worker-api-types.ts +3 -0
- package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +49 -1
- package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +375 -29
- package/dist/bundling-sources/shared_libs/plays/docflow-binding-owner.ts +636 -0
- package/dist/bundling-sources/shared_libs/plays/docflow-binding.ts +598 -0
- package/dist/bundling-sources/shared_libs/plays/docflow.ts +1645 -0
- package/dist/bundling-sources/shared_libs/plays/play-exports.ts +202 -0
- package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +16 -1
- package/dist/bundling-sources/shared_libs/plays/ts-ast.ts +48 -0
- package/dist/cli/index.js +994 -312
- package/dist/cli/index.mjs +994 -312
- package/dist/{compiler-manifest-Cj3--4ZJ.d.mts → compiler-manifest-Bl8kmLx9.d.mts} +118 -0
- package/dist/{compiler-manifest-Cj3--4ZJ.d.ts → compiler-manifest-Bl8kmLx9.d.ts} +118 -0
- package/dist/index.d.mts +47 -2
- package/dist/index.d.ts +47 -2
- package/dist/index.js +419 -59
- package/dist/index.mjs +419 -59
- package/dist/install-integrity.json +12 -2
- package/dist/plays/bundle-play-file.d.mts +2 -2
- package/dist/plays/bundle-play-file.d.ts +2 -2
- package/dist/plays/bundle-play-file.mjs +1361 -45
- 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
|
+
}
|