/** * Docflow binding resolution (ADR 0016 rule 1), esbuild-free. * * `plays check` runs inside the app's launch path (`start-run` -> * `preflight-validation`), which must never reach esbuild * (`check:absurd-launch-artifacts`). The play bundler imports these same * resolvers so instrumentation and lint agree on one statement per binding; * only the bundler layers its esbuild-fallback parse on top. */ import { astArray, isAstNode, parsePlaySourceForAnalysis, type AstNode, } from './ts-ast'; import { parsePlayDocflow, type ParsePlayDocflowOptions, type PlayDocflowBinding, } from './docflow'; import { isDefinePlayCall } from './play-exports'; export { astArray, isAstNode, TypeScriptParser, type AstNode } from './ts-ast'; /** * Acorn-only parse for binding resolution. Source the acorn TS plugin cannot * parse abstains (returns null) — the bundler's esbuild-fallback parse still * covers instrumentation, and the TS diagnostics own the syntax error. */ const parseDocflowSourceAst = parsePlaySourceForAnalysis; export function sourceLineStarts(sourceCode: string): number[] { const starts = [0]; for (let index = 0; index < sourceCode.length; index += 1) { if (sourceCode[index] === '\n') starts.push(index + 1); } return starts; } export function astNodeBounds( node: AstNode, ): { start: number; end: number } | null { return typeof node.start === 'number' && typeof node.end === 'number' ? { start: node.start, end: node.end } : null; } export function findDocflowBoundStatement( ast: AstNode, line: number, lineStarts: readonly number[], ): AstNode | null { const lineStart = lineStarts[line - 1]; const lineEnd = lineStarts[line] ?? Number.POSITIVE_INFINITY; if (lineStart === undefined) return null; let match: AstNode | null = null; const pending: AstNode[] = [ast]; const statementTypes = new Set([ 'ExpressionStatement', 'IfStatement', 'ReturnStatement', 'ThrowStatement', 'VariableDeclaration', ]); while (pending.length > 0) { const node = pending.pop()!; const bounds = astNodeBounds(node); // A binding can sit mid-statement (e.g. a chained `.withColumn(...)` // line), so any statement whose span overlaps the line is a candidate; // the smallest span wins, which prefers statements starting on the line. if ( bounds && bounds.start < lineEnd && bounds.end > lineStart && statementTypes.has(node.type) ) { if ( !match || bounds.end - bounds.start < (astNodeBounds(match)?.end ?? 0) - (astNodeBounds(match)?.start ?? 0) ) { match = node; } } for (const child of Object.values(node)) { if (Array.isArray(child)) pending.push(...child.filter(isAstNode)); else if (isAstNode(child)) pending.push(child); } } return match; } /** * The statement types a docflow binding may attach to. Symbol resolution walks * up from a matched `withColumn(...)` call or variable declarator to the * enclosing member of this set, so a mid-chain match resolves to the same * outermost statement positional resolution would produce. */ const DOCFLOW_STATEMENT_TYPES = new Set([ 'ExpressionStatement', 'IfStatement', 'ReturnStatement', 'ThrowStatement', 'VariableDeclaration', ]); /** The first path segment of a contract output, e.g. `foo.bar` -> `foo`. */ export function docflowOutputRoot(binding: PlayDocflowBinding): string | null { const first = binding.outputs?.[0]; if (!first) return null; const root = first.split('.')[0]!; if (!root || root === '$output') return null; return root; } /** * Walks every node once, recording each node's parent, so a matched inner node * (a `withColumn` call, a variable declarator) can climb to the enclosing * bindable statement. Acorn nodes carry no parent pointer, so we build one. */ export function buildDocflowParentIndex(ast: AstNode): Map { const parents = new Map(); const pending: AstNode[] = [ast]; while (pending.length > 0) { const node = pending.pop()!; for (const child of Object.values(node)) { const children = Array.isArray(child) ? child.filter(isAstNode) : isAstNode(child) ? [child] : []; for (const nested of children) { parents.set(nested, node); pending.push(nested); } } } return parents; } /** Climbs `parents` from `node` to the nearest bindable statement, if any. */ export function enclosingDocflowStatement( node: AstNode, parents: ReadonlyMap, ): AstNode | null { let current: AstNode | null = node; while (current) { if (DOCFLOW_STATEMENT_TYPES.has(current.type)) return current; current = parents.get(current) ?? null; } return null; } /** * Is `node` a `withColumn('', …)` call — callee property named * `withColumn` with a first string-literal argument equal to `columnName`? */ function isWithColumnCallForName(node: AstNode, columnName: string): boolean { if (node.type !== 'CallExpression') return false; const callee = isAstNode(node.callee) ? node.callee : null; if ( !callee || callee.type !== 'MemberExpression' || callee.computed || !isAstNode(callee.property) || callee.property.type !== 'Identifier' || callee.property.name !== 'withColumn' ) { return false; } const firstArg = astArray(node.arguments)[0]; return Boolean( firstArg && firstArg.type === 'Literal' && typeof firstArg.value === 'string' && firstArg.value === columnName, ); } /** * Is `node` a `.step('', …)` call? * * Both `steps().step('hunter_email', …)` (a waterfall leg) and * `ctx.step('score', …)` (a durable scalar step) match, deliberately: the docflow * language cares that the call DECLARES a producer named ``, and both do. * The compiled pipeline distinguishes them; the resolver does not have to. * * Structurally identical to `withColumn('', …)`, which is exactly why a * leg was unbindable until now — `resolveDocflowBindingSymbol` knew that shape * only under the name `withColumn`. */ export function isDocflowStepCallForName( node: AstNode, stepName: string, ): boolean { if (node.type !== 'CallExpression') return false; const callee = isAstNode(node.callee) ? node.callee : null; if ( !callee || callee.type !== 'MemberExpression' || callee.computed || !isAstNode(callee.property) || callee.property.type !== 'Identifier' || callee.property.name !== 'step' ) { return false; } const firstArg = astArray(node.arguments)[0]; return Boolean( firstArg && firstArg.type === 'Literal' && typeof firstArg.value === 'string' && firstArg.value === stepName, ); } /** * Would observing `expression` time a `steps()` BUILDER rather than the leg the * binding names? * * True when a `.step('', …)` call for the binding's own symbol sits INSIDE * the expression but is not the expression's own outermost call. That is exactly * the waterfall-leg shape: a leg's enclosing statement is * `return steps().step('a', …).step('b', …).return(…)`, one expression shared by * every leg, which runs once and synchronously to build a program object. * Wrapping it reports the builder's construction — settled instantly, with the * program as its output — under each leg's node id. * * The "not the outermost call" clause is what keeps a legitimate * `const score = await ctx.step('score', …)` observable: there the step call IS * the observed expression, so observing it observes the step. */ export function docflowExpressionWrapsStepBuilder( expression: AstNode, stepName: string, ): boolean { const unwrapped = expression.type === 'AwaitExpression' && isAstNode(expression.argument) ? expression.argument : expression; if (isDocflowStepCallForName(unwrapped, stepName)) return false; const pending: AstNode[] = [unwrapped]; while (pending.length > 0) { const node = pending.pop()!; if (node !== unwrapped && isDocflowStepCallForName(node, stepName)) { return true; } for (const child of Object.values(node)) { if (Array.isArray(child)) pending.push(...child.filter(isAstNode)); else if (isAstNode(child)) pending.push(child); } } return false; } function getIdentifierName(node: unknown): string | null { return isAstNode(node) && node.type === 'Identifier' ? typeof node.name === 'string' ? node.name : null : null; } /** * The innermost `definePlay(…)` call whose span contains `line`, or null when * the line sits outside every play (a module-level helper, or a file whose * plays this parser cannot see). Position is the right resolver here and only * here: which play a source line belongs to is a fact about the text, not a * claim about execution. */ function enclosingDefinePlayCall( ast: AstNode, line: number, lineStarts: readonly number[], ): AstNode | null { const lineStart = lineStarts[line - 1]; const lineEnd = lineStarts[line] ?? Number.POSITIVE_INFINITY; if (lineStart === undefined) return null; let match: AstNode | null = null; const pending: AstNode[] = [ast]; while (pending.length > 0) { const node = pending.pop()!; const bounds = astNodeBounds(node); if ( bounds && bounds.start < lineEnd && bounds.end > lineStart && isDefinePlayCall(node) ) { const matchBounds = match ? astNodeBounds(match) : null; if ( !match || !matchBounds || bounds.end - bounds.start < matchBounds.end - matchBounds.start ) { match = node; } } for (const child of Object.values(node)) { if (Array.isArray(child)) pending.push(...child.filter(isAstNode)); else if (isAstNode(child)) pending.push(child); } } return match; } /** Does `node` declare a variable named `name` (VariableDeclarator id)? */ function isVariableDeclarationForName(node: AstNode, name: string): boolean { if (node.type !== 'VariableDeclaration') return false; return astArray(node.declarations).some( (declarator) => getIdentifierName(declarator.id) === name, ); } /** * Which shape produced a symbol match. Reported so a caller can tell a DECLARED * PRODUCER (`withColumn('x', …)` / `.step('x', …)`) from an ordinary `const x`; * the instrumentation skip tests the observed EXPRESSION instead, because a leg * built in a module-level helper resolves positionally and never reaches here. */ export type DocflowSymbolKind = 'declaredColumn' | 'declaredStep' | 'variable'; export type DocflowSymbolResolution = | { kind: 'resolved'; statement: AstNode; symbolKind: DocflowSymbolKind } | { kind: 'ambiguous'; candidates: AstNode[] } | { kind: 'abstain' }; /** * ADR 0016 rule 1: resolve a docflow binding to its statement by the symbol its * `out:` contract names, not by line proximity. * * - If the output root names a DECLARED PRODUCER — `withColumn('', …)` or * `.step('', …)` — the candidates are the statements containing such * calls. Both shapes are the same tier: each is a call whose first string * literal names the value it produces, and neither is more specific than the * other. A file where a column and a step share a name therefore yields TWO * candidates and falls to the selection rule below — it is never silently * decided by which shape the resolver happened to look for first. * - Else if the output root names a declared variable, the candidates are those * declarations. A declared producer still beats a same-named variable: the * producer call is the thing the compiler turns into a step, the variable is * ordinary binding. * - Else (no outputs / `$output` / decisions / returns), symbol resolution * abstains and position remains the resolver. * * Selection among candidates: the one whose span contains the annotated line * wins; failing that, a single candidate in the whole body wins; multiple * candidates with none containing the line is ambiguous (a loud drift error, * never a guess). */ export function resolveDocflowBindingSymbol( ast: AstNode, binding: PlayDocflowBinding, lineStarts: readonly number[], parents?: ReadonlyMap, ): DocflowSymbolResolution { const root = docflowOutputRoot(binding); if (!root) return { kind: 'abstain' }; const parentIndex = parents ?? buildDocflowParentIndex(ast); // A file can hold more than one play, and two plays routinely compute a // column or variable of the same name (`score`, `email`, `rows`). Searching // the whole file would let a symbol in the OTHER play capture this // annotation — resolved to a statement that never runs in this play, or // reported as drift when the annotation was right all along. Search the // enclosing `definePlay(…)` instead, which is exactly the play whose diagram // this node belongs to. const scope = enclosingDefinePlayCall(ast, binding.line, lineStarts) ?? ast; const inScope = (node: AstNode) => { if (scope === ast) return true; const bounds = astNodeBounds(node); const scopeBounds = astNodeBounds(scope); return Boolean( bounds && scopeBounds && bounds.start >= scopeBounds.start && bounds.end <= scopeBounds.end, ); }; const matchesColumn = (node: AstNode) => isWithColumnCallForName(node, root) && inScope(node); const matchesStep = (node: AstNode) => isDocflowStepCallForName(node, root) && inScope(node); const matchesVariable = (node: AstNode) => isVariableDeclarationForName(node, root) && inScope(node); const collectStatements = ( predicate: (node: AstNode) => boolean, ): Map => { const statements = new Map(); const pending: AstNode[] = [scope]; while (pending.length > 0) { const node = pending.pop()!; if (predicate(node)) { const statement = enclosingDocflowStatement(node, parentIndex); const kind: DocflowSymbolKind = matchesColumn(node) ? 'declaredColumn' : matchesStep(node) ? 'declaredStep' : 'variable'; // One statement can hold both shapes — a `withColumn('x', …)` whose body // builds a `steps().step('x', …)` program. The column is what the // compiler turns into the statement's step, so it wins regardless of // which node the walk reached first; without this the classification // would depend on traversal order. if ( statement && (!statements.has(statement) || kind === 'declaredColumn') ) { statements.set(statement, kind); } } for (const child of Object.values(node)) { if (Array.isArray(child)) pending.push(...child.filter(isAstNode)); else if (isAstNode(child)) pending.push(child); } } return statements; }; // Declared producers — `withColumn('R', …)` and `.step('R', …)` — are ONE // tier. Both are calls naming the value they produce, so neither can claim // priority over the other without guessing; a genuine collision falls through // to the containment rule and then errors. Both beat a same-named variable. let candidates = collectStatements( (node) => matchesColumn(node) || matchesStep(node), ); if (candidates.size === 0) candidates = collectStatements(matchesVariable); if (candidates.size === 0) return { kind: 'abstain' }; const resolved = (statement: AstNode): DocflowSymbolResolution => ({ kind: 'resolved', statement, symbolKind: candidates.get(statement) ?? 'variable', }); const lineStart = lineStarts[binding.line - 1]; const lineEnd = lineStarts[binding.line] ?? Number.POSITIVE_INFINITY; if (lineStart !== undefined) { const containing = [...candidates.keys()].filter((statement) => { const bounds = astNodeBounds(statement); return bounds && bounds.start < lineEnd && bounds.end > lineStart; }); if (containing.length === 1) return resolved(containing[0]!); if (containing.length > 1) { return { kind: 'ambiguous', candidates: containing }; } } if (candidates.size === 1) { return resolved([...candidates.keys()][0]!); } return { kind: 'ambiguous', candidates: [...candidates.keys()] }; } export type DocflowBindingDriftDetail = { binding: PlayDocflowBinding; /** The output root the symbol resolver keyed on (never `$output`). */ symbol: string; /** 1-based line where the annotated statement resolves positionally. */ annotatedLine: number | null; /** 1-based line where the named symbol actually lives, when known. */ symbolLine: number | null; /** Ambiguous when two chains compute the same column and none contains the line. */ ambiguous: boolean; }; export type DocflowBindingResolution = { binding: PlayDocflowBinding; /** The statement instrumentation should wrap; symbol wins when it resolves. */ statement: AstNode | null; drift: DocflowBindingDriftDetail | null; /** Which shape the symbol matched, when it matched one; null when position won. */ symbolKind: DocflowSymbolKind | null; }; function statementLine( statement: AstNode | null, lineStarts: readonly number[], ): number | null { const bounds = statement ? astNodeBounds(statement) : null; if (!bounds) return null; // The 1-based line is the count of line starts at or before the offset. let line = 1; for (let index = 0; index < lineStarts.length; index += 1) { if (lineStarts[index]! > bounds.start) break; line = index + 1; } return line; } /** * Resolves one docflow binding for BOTH instrumentation and lint, so the two * agree (ADR 0016 rule 1). Symbol resolution wins when it resolves * unambiguously; position is the fallback. Drift is reported when symbol and * position both resolve to different statements, or the symbol match is * ambiguous. */ export function resolveDocflowBinding( ast: AstNode, binding: PlayDocflowBinding, lineStarts: readonly number[], parents?: ReadonlyMap, ): DocflowBindingResolution { const positional = findDocflowBoundStatement(ast, binding.line, lineStarts); const symbol = resolveDocflowBindingSymbol(ast, binding, lineStarts, parents); const root = docflowOutputRoot(binding); if (symbol.kind === 'ambiguous') { return { binding, statement: positional, symbolKind: null, drift: { binding, symbol: root ?? '', annotatedLine: statementLine(positional, lineStarts), symbolLine: statementLine(symbol.candidates[0] ?? null, lineStarts), ambiguous: true, }, }; } if (symbol.kind === 'resolved') { const drift = positional && positional !== symbol.statement ? { binding, symbol: root ?? '', annotatedLine: statementLine(positional, lineStarts), symbolLine: statementLine(symbol.statement, lineStarts), ambiguous: false, } : null; // Symbol statement is the instrumentation target; it makes lint and the // observation wrapper agree even when the annotation drifted onto a // neighbouring line of the same chain. return { binding, statement: symbol.statement, symbolKind: symbol.symbolKind, drift, }; } return { binding, statement: positional, symbolKind: null, drift: null }; } /** * Resolves every docflow binding in `sourceCode` and returns the ones that * drift — where the annotated line and the named symbol disagree, or the symbol * match is ambiguous. `plays check` turns these into `docflow_binding_drift` * errors. Returns an empty array for undiagrammed plays and unparseable source * (other validators own those failures). */ export function collectDocflowBindingDrift( sourceCode: string, options: ParsePlayDocflowOptions = {}, ): DocflowBindingDriftDetail[] { const parsed = parsePlayDocflow(sourceCode, options); if (!parsed.docflow || parsed.errors.length > 0) return []; const ast = parseDocflowSourceAst(sourceCode); if (!ast) return []; const lineStarts = sourceLineStarts(sourceCode); const parents = buildDocflowParentIndex(ast); const drift: DocflowBindingDriftDetail[] = []; for (const binding of parsed.docflow.bindings) { const resolution = resolveDocflowBinding(ast, binding, lineStarts, parents); if (resolution.drift) drift.push(resolution.drift); } return drift; } /** * Maps each docflow binding node id to the 1-based line of the statement it * resolves to (symbol first, position as fallback). `plays check` loop lint * uses this to attribute a loop member to the dataset chain that runs it by the * column its `out:` names, not by the line the annotation drifted onto. * Bindings that abstain (no `out:` symbol) map to their positional line. */ export function resolveDocflowBindingLines( sourceCode: string, options: ParsePlayDocflowOptions = {}, ): Map { const lines = new Map(); const parsed = parsePlayDocflow(sourceCode, options); if (!parsed.docflow || parsed.errors.length > 0) return lines; const ast = parseDocflowSourceAst(sourceCode); if (!ast) return lines; const lineStarts = sourceLineStarts(sourceCode); const parents = buildDocflowParentIndex(ast); for (const binding of parsed.docflow.bindings) { const resolution = resolveDocflowBinding(ast, binding, lineStarts, parents); const resolvedLine = statementLine(resolution.statement, lineStarts); lines.set(binding.nodeId, resolvedLine ?? binding.line); } return lines; }