@loomwork/compiler 0.0.0-stage → 0.1.0

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 (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +62 -2
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +81 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/hash.d.ts +3 -0
  9. package/dist/hash.d.ts.map +1 -0
  10. package/dist/hash.js +16 -0
  11. package/dist/hash.js.map +1 -0
  12. package/dist/importResolve.d.ts +5 -0
  13. package/dist/importResolve.d.ts.map +1 -0
  14. package/dist/importResolve.js +130 -0
  15. package/dist/importResolve.js.map +1 -0
  16. package/dist/index.d.ts +14 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +26 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/loopIdentity.d.ts +18 -0
  21. package/dist/loopIdentity.d.ts.map +1 -0
  22. package/dist/loopIdentity.js +179 -0
  23. package/dist/loopIdentity.js.map +1 -0
  24. package/dist/manifest.d.ts +98 -0
  25. package/dist/manifest.d.ts.map +1 -0
  26. package/dist/manifest.js +123 -0
  27. package/dist/manifest.js.map +1 -0
  28. package/dist/nondeterminism.d.ts +25 -0
  29. package/dist/nondeterminism.d.ts.map +1 -0
  30. package/dist/nondeterminism.js +88 -0
  31. package/dist/nondeterminism.js.map +1 -0
  32. package/dist/plugin.d.ts +3 -0
  33. package/dist/plugin.d.ts.map +1 -0
  34. package/dist/plugin.js +71 -0
  35. package/dist/plugin.js.map +1 -0
  36. package/dist/runnerReach.d.ts +136 -0
  37. package/dist/runnerReach.d.ts.map +1 -0
  38. package/dist/runnerReach.js +1809 -0
  39. package/dist/runnerReach.js.map +1 -0
  40. package/dist/transform.d.ts +6 -0
  41. package/dist/transform.d.ts.map +1 -0
  42. package/dist/transform.js +416 -0
  43. package/dist/transform.js.map +1 -0
  44. package/dist/types.d.ts +73 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/dist/types.js +3 -0
  47. package/dist/types.js.map +1 -0
  48. package/package.json +64 -3
  49. package/src/cli.ts +90 -0
  50. package/src/hash.ts +12 -0
  51. package/src/importResolve.ts +133 -0
  52. package/src/index.ts +23 -0
  53. package/src/loopIdentity.ts +210 -0
  54. package/src/manifest.ts +185 -0
  55. package/src/nondeterminism.ts +133 -0
  56. package/src/plugin.ts +74 -0
  57. package/src/runnerReach.ts +1964 -0
  58. package/src/transform.ts +523 -0
  59. package/src/types.ts +79 -0
@@ -0,0 +1,1964 @@
1
+ import ts from "typescript";
2
+
3
+ // ── Runner-body reach analysis ───────────────────────────────────────────────
4
+ //
5
+ // Three `@loomwork/verify` rules — `capability-compat`, `effect-class` and
6
+ // `suspension-safety` — define "what a flow reaches" as the toolIds of its
7
+ // DECLARED `FlowDefinition.steps`. A step invoked through `ctx.run(...)` but not
8
+ // listed there is invisible to all three, and `FlowSpec.suspends` had no
9
+ // producer at all, so two of suspension-safety's checks could never fire.
10
+ //
11
+ // Suspend points and `ctx.run` targets live in the runner BODY, which is a
12
+ // source region — the same thing `analyzeNonDeterminism` already works on. This
13
+ // reads that region and reports what it finds.
14
+ //
15
+ // **What it can and cannot resolve, stated up front, because the whole point is
16
+ // not to imply coverage it does not have.** `ctx.run(chargeCard, …)` names a
17
+ // binding; a binding declared in the SAME region as `step$({ toolId: "…" })`
18
+ // resolves, and one imported from another module does not. An unresolved target
19
+ // is reported as unresolved rather than dropped, so the rules downstream can say
20
+ // "partially verified" instead of reporting clean — the distinction that makes
21
+ // this worth doing at all.
22
+
23
+ /** A `ctx.run` target resolved to a concrete tool. */
24
+ export interface RunnerStepRef {
25
+ /** The step's declared id, when the binding carried one. */
26
+ stepId?: string;
27
+ toolId: string;
28
+ /**
29
+ * The target was reached through `scope.invokeTransient(…)` inside a `ctx.decide`
30
+ * decider (L31), not through `ctx.run`. Such a call is transient: it is refused at
31
+ * runtime for a `journaled` effect, which the verifier can only check if it knows
32
+ * the call came from a scope.
33
+ */
34
+ viaDecider?: true;
35
+ }
36
+
37
+ /** A `ctx.run`/`ctx.suspend` whose target could not be resolved statically. */
38
+ export interface UnresolvedReach {
39
+ /** Source text of the unresolved expression, e.g. `mcpInvoke$(…)` or `steps[i]`. */
40
+ expr: string;
41
+ /** What was being resolved — a step target or a suspend condition. */
42
+ kind: "step" | "suspend";
43
+ location?: string;
44
+ }
45
+
46
+ /** A suspend point read from a `ctx.suspend({ … })` object literal. */
47
+ export interface RunnerSuspend {
48
+ /** The `on` event type, used to name the finding. */
49
+ id?: string;
50
+ correlationKey?: string;
51
+ /** A `timeout` was declared — the wake depends on a timer. */
52
+ timed?: boolean;
53
+ /** A `wakeAt` was declared explicitly in the condition. */
54
+ wakeAt?: boolean;
55
+ }
56
+
57
+ /**
58
+ * L32 — the flow's `ctx` (or its `ctx.decide`) is handed to code outside this region. The
59
+ * compiler cannot see that code, so it records WHERE it went; `@loomwork/verify`'s adapter
60
+ * resolves `callee`/`root` against the app's declared reach (`verify.reach`) and either
61
+ * counts the declared tools or reports the escape unresolved.
62
+ */
63
+ export interface ReachEscape {
64
+ /** Source text of the callee the value was handed to, e.g. `planner.compose`. */
65
+ callee: string;
66
+ /** The leftmost identifier of `callee` (`planner`), absent for `this.x` / call results. */
67
+ root?: string;
68
+ /** What was handed over: the whole `ctx`, or just `ctx.decide`. */
69
+ via: "ctx" | "decide";
70
+ /** When `via` is "ctx": the ctx METHOD handed over (`run` for `lib(ctx.run)`), absent for the whole ctx. */
71
+ member?: string;
72
+ /**
73
+ * L34 - the arguments of the hand-off that RESOLVE to a step, by position, for a callee whose
74
+ * declaration says it runs a step it is handed (`fanout$(ctx, step, items)`: argument 1). A
75
+ * position absent here did not resolve; the verifier reports that only if the declaration names
76
+ * the position. Absent for `via: "decide"`, a ctx method, and a pass-through
77
+ * (`setTimeout(lib, …)`), whose arguments are not the callee's.
78
+ */
79
+ args?: ReachEscapeArg[];
80
+ location?: string;
81
+ }
82
+
83
+ /** One argument of a ctx hand-off, see {@link ReachEscape.args}. */
84
+ export interface ReachEscapeArg {
85
+ index: number;
86
+ step: RunnerStepRef;
87
+ }
88
+
89
+ /**
90
+ * Resolution services the analysis cannot do on a single parsed module. Optional: without it
91
+ * the analysis is same-module only.
92
+ */
93
+ export interface AnalyzeReachOptions {
94
+ /**
95
+ * L37 - resolve a name this module IMPORTS to what its declaration shows: a string constant
96
+ * (`export const LLM_X = "llm.x"`) or a step-factory shape (`export function llmStep$(id, toolId)`).
97
+ */
98
+ importResolver?: (localName: string) => ImportedBinding | undefined;
99
+ }
100
+
101
+ /** What an imported name resolves to. */
102
+ export interface ImportedBinding {
103
+ value?: string;
104
+ factory?: StepFactoryShape;
105
+ }
106
+
107
+ /**
108
+ * L37 - how a step factory yields its effect id: a fixed one (`mcpInvoke$` always dispatches
109
+ * `mcp.invoke`; the step ID is minted but the EFFECT is what verify checks) or the value of one of
110
+ * its parameters (`llmStep$(id, toolId)`).
111
+ */
112
+ export type StepFactoryShape =
113
+ | { fixed: string }
114
+ /** The effect id is argument `argIndex`; when that argument is absent, `default` (a parameter default). */
115
+ | { argIndex: number; default?: string };
116
+
117
+ /**
118
+ * First-party factories whose effect id is fixed and whose source is in a package (never followed
119
+ * through an import). `mcp.invoke` is `MCP_INVOKE_TOOL_ID` in `@loomwork/plugin-mcp` and `sandbox.eval` is
120
+ * `SANDBOX_TOOL_ID` in `@loomwork/sandbox` (re-exported by `@loomwork/plugin-sandbox`, ADR 0144); a test pins each to its package.
121
+ */
122
+ export const KNOWN_STEP_FACTORIES: Readonly<Record<string, StepFactoryShape>> = {
123
+ mcpInvoke$: { fixed: "mcp.invoke" },
124
+ // `sandboxEval$(id, toolId = SANDBOX_TOOL_ID)` in `@loomwork/sandbox` (ADR 0144).
125
+ sandboxEval$: { argIndex: 1, default: "sandbox.eval" },
126
+ };
127
+
128
+ export interface RunnerReach {
129
+ steps: RunnerStepRef[];
130
+ unresolved: UnresolvedReach[];
131
+ suspends: RunnerSuspend[];
132
+ /** L32 — `ctx` handed to out-of-region callees. Absent when there are none. */
133
+ escapes?: ReachEscape[];
134
+ }
135
+
136
+ /** The string value of a property on an object literal, when it is a literal. */
137
+ function stringProp(obj: ts.ObjectLiteralExpression, name: string): string | undefined {
138
+ for (const p of obj.properties) {
139
+ if (!ts.isPropertyAssignment(p)) continue;
140
+ const key = ts.isIdentifier(p.name) || ts.isStringLiteral(p.name) ? p.name.text : undefined;
141
+ if (key !== name) continue;
142
+ if (ts.isStringLiteral(p.initializer) || ts.isNoSubstitutionTemplateLiteral(p.initializer)) {
143
+ return p.initializer.text;
144
+ }
145
+ return undefined;
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ /** Whether a property is present at all, regardless of whether its value is a literal. */
151
+ function hasProp(obj: ts.ObjectLiteralExpression, name: string): boolean {
152
+ return obj.properties.some((p) => {
153
+ if (!ts.isPropertyAssignment(p) && !ts.isShorthandPropertyAssignment(p)) return false;
154
+ return (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === name;
155
+ });
156
+ }
157
+
158
+ /** A method call `<anything>.<name>(…)` — receiver-agnostic, like the determinism analyzer. */
159
+ function methodCall(node: ts.Node, name: string): ts.CallExpression | undefined {
160
+ if (!ts.isCallExpression(node)) return undefined;
161
+ const e = node.expression;
162
+ return ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.name) && e.name.text === name
163
+ ? node
164
+ : undefined;
165
+ }
166
+
167
+ /**
168
+ * Collect `const NAME = anything$({ … toolId: "…" … })` bindings in this region.
169
+ *
170
+ * Shape-based rather than keyed to `step$` by name, so a project wrapper
171
+ * (`myStep$`, a re-export, a locally curried factory) resolves too. The
172
+ * discriminator is a string-literal `toolId`, which is the only thing that makes
173
+ * a binding useful here.
174
+ */
175
+ function collectBindings(root: ts.Node): Map<string, RunnerStepRef> {
176
+ const bindings = new Map<string, RunnerStepRef>();
177
+ function visit(node: ts.Node): void {
178
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
179
+ const init = node.initializer;
180
+ if (init !== undefined && ts.isCallExpression(init) && init.arguments.length > 0) {
181
+ const arg = init.arguments[0];
182
+ if (arg !== undefined && ts.isObjectLiteralExpression(arg)) {
183
+ const toolId = stringProp(arg, "toolId");
184
+ if (toolId !== undefined) {
185
+ const stepId = stringProp(arg, "id");
186
+ bindings.set(node.name.text, { toolId, ...(stepId !== undefined && { stepId }) });
187
+ }
188
+ }
189
+ }
190
+ }
191
+ ts.forEachChild(node, visit);
192
+ }
193
+ visit(root);
194
+ return bindings;
195
+ }
196
+
197
+ /** The name a call's callee ends in: `ctx.run` / `ctx["run"]` / `run`, through `.call/.apply/.bind`. */
198
+ function calleeMethodName(call: ts.CallExpression): string | undefined {
199
+ let e: ts.Expression = unwrap(call.expression);
200
+ for (;;) {
201
+ if (ts.isPropertyAccessExpression(e)) {
202
+ if (
203
+ ["call", "apply", "bind"].includes(e.name.text) &&
204
+ ts.isPropertyAccessExpression(unwrap(e.expression))
205
+ )
206
+ e = unwrap(e.expression);
207
+ else return e.name.text;
208
+ } else if (ts.isElementAccessExpression(e)) {
209
+ const a = unwrap(e.argumentExpression);
210
+ return ts.isStringLiteralLike(a) ? a.text : undefined;
211
+ } else return ts.isIdentifier(e) ? e.text : undefined;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Whether a step object bound to `name` can change between its declaration and its use (ADR 0086,
217
+ * 2026-10-10 amendment). The only uses that cannot change it are: the declaration, an argument of a
218
+ * ctx method call (`ctx.run(s)`) or a ctx hand-off (`lib(ctx, s)`, L34), an element of an array literal (a `ctx.parallel` tuple), a spread
219
+ * copy, and a property READ. Anything else - a property assignment / `delete` / `++`, being handed to
220
+ * `Object.assign(s, ...)` or any other function, aliased, returned, stored in another object -
221
+ * makes the binding "not followed" (unresolved): a guess at `toolId` would name the wrong effect.
222
+ */
223
+ function stepBindingEscapes(
224
+ sourceFile: ts.SourceFile,
225
+ name: string,
226
+ ctxNames: ReadonlySet<string>,
227
+ isLoomFlowBuilder: (callee: ts.Expression) => boolean = () => false
228
+ ): boolean {
229
+ let escapes = false;
230
+ const strip = (n: ts.Node): ts.Node => {
231
+ let top = n;
232
+ while (
233
+ top.parent !== undefined &&
234
+ (ts.isParenthesizedExpression(top.parent) ||
235
+ ts.isAsExpression(top.parent) ||
236
+ ts.isNonNullExpression(top.parent) ||
237
+ ts.isSatisfiesExpression(top.parent))
238
+ )
239
+ top = top.parent;
240
+ return top;
241
+ };
242
+ const isWriteTarget = (member: ts.Node): boolean => {
243
+ let top = strip(member);
244
+ // Walk out of destructuring-assignment patterns: `[s.toolId] = …`, `({ a: s.toolId } = …)`.
245
+ while (
246
+ top.parent !== undefined &&
247
+ (ts.isArrayLiteralExpression(top.parent) ||
248
+ ts.isPropertyAssignment(top.parent) ||
249
+ ts.isShorthandPropertyAssignment(top.parent) ||
250
+ ts.isSpreadElement(top.parent) ||
251
+ ts.isSpreadAssignment(top.parent) ||
252
+ ts.isObjectLiteralExpression(top.parent))
253
+ )
254
+ top = strip(top.parent);
255
+ const p = top.parent;
256
+ if (p === undefined) return false;
257
+ if (ts.isBinaryExpression(p))
258
+ return (
259
+ p.left === top &&
260
+ p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
261
+ p.operatorToken.kind <= ts.SyntaxKind.LastAssignment
262
+ );
263
+ if (ts.isDeleteExpression(p)) return true;
264
+ if (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p))
265
+ return (
266
+ p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken
267
+ );
268
+ // `for (s.toolId of xs)` / `for (s.toolId in o)`.
269
+ return (ts.isForOfStatement(p) || ts.isForInStatement(p)) && p.initializer === top;
270
+ };
271
+ const walk = (n: ts.Node): void => {
272
+ if (escapes) return;
273
+ if (ts.isIdentifier(n) && n.text === name) {
274
+ const p = n.parent;
275
+ const top = strip(n);
276
+ const q = top.parent;
277
+ const isDecl = ts.isVariableDeclaration(p) && p.name === n;
278
+ // `{ s: 1 }` key, `x.s` member name, `{ s }` shorthand is a VALUE use (handled by falling through).
279
+ const isNotAReference =
280
+ (ts.isPropertyAssignment(p) && p.name === n) ||
281
+ (ts.isPropertyAccessExpression(p) && p.name === n) ||
282
+ (ts.isPropertySignature(p) && p.name === n);
283
+ if (!isDecl && !isNotAReference) {
284
+ let ok = false;
285
+ // A ctx method call or a ctx hand-off (`lib(ctx, s)`, the declared-reach seam, L34) may take
286
+ // an argument: its step arguments are recorded for the callee's declaration.
287
+ const isCtxCall = (c: ts.CallExpression): boolean => {
288
+ const m = calleeMethodName(c);
289
+ return (
290
+ (m !== undefined && CTX_METHODS.has(m)) ||
291
+ c.arguments.some((a) => {
292
+ const u = unwrap(a);
293
+ return ts.isIdentifier(u) && ctxNames.has(u.text);
294
+ })
295
+ );
296
+ };
297
+ if (
298
+ q !== undefined &&
299
+ ts.isCallExpression(q) &&
300
+ q.arguments.includes(top as ts.Expression)
301
+ ) {
302
+ ok = isCtxCall(q);
303
+ } else if (q !== undefined && ts.isArrayLiteralExpression(q)) {
304
+ // A `ctx.parallel([[s, input]])` tuple: safe ONLY when the OUTERMOST enclosing array
305
+ // literal is itself directly an argument of a ctx call. Bound, iterated, destructured or
306
+ // spread (`const a = [s]`, `[s].forEach`, `f(...[s])`) it is an alias: an escape.
307
+ let outer: ts.Node = q;
308
+ for (;;) {
309
+ const o = strip(outer);
310
+ if (o.parent !== undefined && ts.isArrayLiteralExpression(o.parent)) outer = o.parent;
311
+ else {
312
+ outer = o;
313
+ break;
314
+ }
315
+ }
316
+ const host = outer.parent;
317
+ if (
318
+ host !== undefined &&
319
+ ts.isVariableDeclaration(host) &&
320
+ host.initializer === outer &&
321
+ ts.isIdentifier(host.name) &&
322
+ ts.isVariableDeclarationList(host.parent) &&
323
+ (host.parent.flags & ts.NodeFlags.Const) !== 0
324
+ ) {
325
+ // `const pairs = [[s, 1]]; ctx.parallel(pairs)`: the list is followed (rule 5) only if
326
+ // the binding is used nowhere but as a direct ctx-call argument.
327
+ const listName = host.name.text;
328
+ let clean = true;
329
+ const scan = (n: ts.Node): void => {
330
+ if (!clean) return;
331
+ if (ts.isIdentifier(n) && n.text === listName && n !== host.name) {
332
+ const isKey =
333
+ (ts.isPropertyAssignment(n.parent) && n.parent.name === n) ||
334
+ (ts.isPropertyAccessExpression(n.parent) && n.parent.name === n);
335
+ const t = strip(n);
336
+ const c = t.parent;
337
+ if (
338
+ !isKey &&
339
+ !(
340
+ c !== undefined &&
341
+ ts.isCallExpression(c) &&
342
+ c.arguments.includes(t as ts.Expression) &&
343
+ isCtxCall(c)
344
+ )
345
+ )
346
+ clean = false;
347
+ }
348
+ ts.forEachChild(n, scan);
349
+ };
350
+ scan(sourceFile);
351
+ ok = clean;
352
+ } else if (
353
+ host !== undefined &&
354
+ ts.isPropertyAssignment(host) &&
355
+ host.initializer === outer &&
356
+ propName(host.name) === "steps" &&
357
+ ts.isObjectLiteralExpression(strip(host.parent)) &&
358
+ (() => {
359
+ const lit = strip(host.parent);
360
+ const call = lit.parent;
361
+ return (
362
+ call !== undefined &&
363
+ ts.isCallExpression(call) &&
364
+ call.arguments[0] === lit &&
365
+ isLoomFlowBuilder(call.expression)
366
+ );
367
+ })()
368
+ ) {
369
+ // `flow$({ steps: [s] })` DECLARES the step: loom's own `flow$` (imported unshadowed from
370
+ // @loomwork/core / @loomwork/sdk) keeps the list and the runtime only reads it.
371
+ ok = true;
372
+ } else {
373
+ ok =
374
+ host !== undefined &&
375
+ ts.isCallExpression(host) &&
376
+ host.arguments.includes(outer as ts.Expression) &&
377
+ isCtxCall(host);
378
+ }
379
+ } else if (q !== undefined && ts.isSpreadAssignment(q)) ok = true;
380
+ else if (
381
+ q !== undefined &&
382
+ (ts.isPropertyAccessExpression(q) || ts.isElementAccessExpression(q)) &&
383
+ q.expression === top
384
+ ) {
385
+ // A property READ is fine; a write through it, or a method call on it, is not.
386
+ let chain: ts.Node = q;
387
+ while (
388
+ chain.parent !== undefined &&
389
+ (ts.isPropertyAccessExpression(chain.parent) ||
390
+ ts.isElementAccessExpression(chain.parent)) &&
391
+ chain.parent.expression === chain
392
+ )
393
+ chain = chain.parent;
394
+ const callee = strip(chain).parent;
395
+ const isMethodCall =
396
+ callee !== undefined &&
397
+ ts.isCallExpression(callee) &&
398
+ callee.expression === strip(chain);
399
+ ok = !isWriteTarget(chain) && !isMethodCall;
400
+ }
401
+ if (!ok) escapes = true;
402
+ }
403
+ }
404
+ ts.forEachChild(n, walk);
405
+ };
406
+ walk(sourceFile);
407
+ return escapes;
408
+ }
409
+
410
+ /** Short, stable source text for an unresolved expression (bounded for log safety). */
411
+ function exprText(node: ts.Node): string {
412
+ if (ts.isTaggedTemplateExpression(node)) return `${exprText(node.tag)}\`…\``;
413
+ const text = node.getText().replace(/\s+/g, " ").trim();
414
+ return text.length > 80 ? `${text.slice(0, 77)}…` : text;
415
+ }
416
+
417
+ /** Function-like nodes a decider can be written as. */
418
+ type FnNode = ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration;
419
+
420
+ function isFn(node: ts.Node): node is FnNode {
421
+ return (
422
+ ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)
423
+ );
424
+ }
425
+
426
+ /**
427
+ * Collect functions declared in this region by name (`function f(){}`,
428
+ * `const f = (s) => …`), so `ctx.decide("x", f)` can be followed when `f` is local.
429
+ *
430
+ * Resolution is by TEXTUAL name in one flat map, so a name is only followed when it is
431
+ * unambiguous: declared exactly once in the region (as a function, a variable, a
432
+ * parameter or a binding), with `const`/`function`, and never reassigned. Anything else
433
+ * is left out, which makes `ctx.decide("x", name)` unresolved rather than silently
434
+ * binding to whichever declaration came last.
435
+ */
436
+ function collectFunctions(root: ts.Node): Map<string, FnNode> {
437
+ return scanDeclarations(root).fns;
438
+ }
439
+
440
+ /** Same unambiguity rule as {@link collectFunctions}, for any `const NAME = <initializer>`. */
441
+ function collectConstInits(root: ts.Node): Map<string, ts.Expression> {
442
+ return scanDeclarations(root).inits;
443
+ }
444
+
445
+ function scanDeclarations(root: ts.Node): {
446
+ fns: Map<string, FnNode>;
447
+ inits: Map<string, ts.Expression>;
448
+ /** name -> how many times it is declared ANYWHERE in the region (any scope, any kind). */
449
+ declared: Map<string, number>;
450
+ ambiguous: Set<string>;
451
+ } {
452
+ const fns = new Map<string, FnNode>();
453
+ const inits = new Map<string, ts.Expression>();
454
+ const declared = new Map<string, number>();
455
+ const ambiguous = new Set<string>();
456
+ const declare = (name: string): void => {
457
+ declared.set(name, (declared.get(name) ?? 0) + 1);
458
+ };
459
+ function visit(node: ts.Node): void {
460
+ if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
461
+ declare(node.name.text);
462
+ fns.set(node.name.text, node);
463
+ } else if (
464
+ (ts.isClassDeclaration(node) ||
465
+ ts.isClassExpression(node) ||
466
+ ts.isEnumDeclaration(node) ||
467
+ ts.isFunctionExpression(node)) &&
468
+ node.name !== undefined
469
+ ) {
470
+ declare(node.name.text);
471
+ ambiguous.add(node.name.text);
472
+ } else if (ts.isModuleDeclaration(node) && ts.isIdentifier(node.name)) {
473
+ declare(node.name.text);
474
+ ambiguous.add(node.name.text);
475
+ } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
476
+ declare(node.name.text);
477
+ const isConst = (ts.getCombinedNodeFlags(node) & ts.NodeFlags.Const) !== 0;
478
+ if (!isConst) ambiguous.add(node.name.text);
479
+ if (
480
+ node.initializer !== undefined &&
481
+ (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
482
+ ) {
483
+ fns.set(node.name.text, node.initializer);
484
+ }
485
+ if (node.initializer !== undefined) inits.set(node.name.text, node.initializer);
486
+ } else if (ts.isParameter(node) && ts.isIdentifier(node.name)) {
487
+ declare(node.name.text);
488
+ } else if (ts.isBindingElement(node) && ts.isIdentifier(node.name)) {
489
+ declare(node.name.text);
490
+ } else if (
491
+ ts.isBinaryExpression(node) &&
492
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
493
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
494
+ ts.isIdentifier(node.left)
495
+ ) {
496
+ ambiguous.add(node.left.text);
497
+ }
498
+ ts.forEachChild(node, visit);
499
+ }
500
+ visit(root);
501
+ for (const name of [...fns.keys()]) {
502
+ if (ambiguous.has(name) || (declared.get(name) ?? 0) > 1) fns.delete(name);
503
+ }
504
+ for (const name of [...inits.keys()]) {
505
+ if (ambiguous.has(name) || (declared.get(name) ?? 0) > 1) inits.delete(name);
506
+ }
507
+ return { fns, inits, declared, ambiguous };
508
+ }
509
+
510
+ /** Every name bound by a binding name (identifier or destructuring pattern). */
511
+ function bindingNames(name: ts.BindingName, out: Set<string>): void {
512
+ if (ts.isIdentifier(name)) out.add(name.text);
513
+ else for (const el of name.elements) if (!ts.isOmittedExpression(el)) bindingNames(el.name, out);
514
+ }
515
+
516
+ /** Every name declared anywhere inside `node` (parameters, variables, patterns, functions, classes). */
517
+ function namesDeclaredWithin(node: ts.Node): Set<string> {
518
+ const out = new Set<string>();
519
+ const visit = (n: ts.Node): void => {
520
+ if ((ts.isVariableDeclaration(n) || ts.isParameter(n) || ts.isBindingElement(n)) && n.name)
521
+ bindingNames(n.name, out);
522
+ else if (
523
+ (ts.isFunctionDeclaration(n) ||
524
+ ts.isFunctionExpression(n) ||
525
+ ts.isClassDeclaration(n) ||
526
+ ts.isClassExpression(n) ||
527
+ ts.isEnumDeclaration(n)) &&
528
+ n.name !== undefined
529
+ )
530
+ out.add(n.name.text);
531
+ ts.forEachChild(n, visit);
532
+ };
533
+ visit(node);
534
+ return out;
535
+ }
536
+
537
+ /** Whether any identifier inside `e` is in `names`. */
538
+ function mentionsAny(e: ts.Node, names: ReadonlySet<string>): boolean {
539
+ if (ts.isIdentifier(e) && names.has(e.text)) return true;
540
+ return ts.forEachChild(e, (c) => (mentionsAny(c, names) ? true : undefined)) === true;
541
+ }
542
+
543
+ /** Import bindings of a module: local name -> module specifier and the imported (exported) name. */
544
+ /** Every local name an import binds (named, default and namespace; type-only included, harmlessly). */
545
+ function allImportBindings(sf: ts.SourceFile): Set<string> {
546
+ const out = new Set<string>();
547
+ for (const st of sf.statements) {
548
+ if (!ts.isImportDeclaration(st)) continue;
549
+ const clause = st.importClause;
550
+ if (clause === undefined) continue;
551
+ if (clause.name !== undefined) out.add(clause.name.text);
552
+ const nb = clause.namedBindings;
553
+ if (nb === undefined) continue;
554
+ if (ts.isNamespaceImport(nb)) out.add(nb.name.text);
555
+ else for (const el of nb.elements) out.add(el.name.text);
556
+ }
557
+ return out;
558
+ }
559
+
560
+ function importedNames(sf: ts.SourceFile): Map<string, { spec: string; imported: string }> {
561
+ const out = new Map<string, { spec: string; imported: string }>();
562
+ for (const st of sf.statements) {
563
+ if (!ts.isImportDeclaration(st) || !ts.isStringLiteral(st.moduleSpecifier)) continue;
564
+ const clause = st.importClause;
565
+ if (clause === undefined || clause.isTypeOnly) continue;
566
+ const nb = clause.namedBindings;
567
+ if (nb !== undefined && ts.isNamedImports(nb)) {
568
+ for (const el of nb.elements) {
569
+ if (el.isTypeOnly) continue;
570
+ out.set(el.name.text, {
571
+ spec: st.moduleSpecifier.text,
572
+ imported: (el.propertyName ?? el.name).text,
573
+ });
574
+ }
575
+ }
576
+ }
577
+ return out;
578
+ }
579
+
580
+ /**
581
+ * Module specifiers a {@link KNOWN_STEP_FACTORIES} entry may be imported from: its package, and
582
+ * `@loomwork/sdk` where the SDK re-exports it (`export * from "@loomwork/plugin-mcp"`; the sandbox plugin
583
+ * is only at the `@loomwork/sdk/sandbox` subpath).
584
+ */
585
+ const KNOWN_FACTORY_SOURCES: Readonly<Record<string, readonly string[]>> = {
586
+ mcpInvoke$: ["@loomwork/plugin-mcp", "@loomwork/sdk"],
587
+ sandboxEval$: ["@loomwork/plugin-sandbox", "@loomwork/sandbox", "@loomwork/sdk/sandbox"],
588
+ };
589
+
590
+ /** The string a literal-ish expression denotes (`"x"`, an expression-free template, `"x" as const`). */
591
+ export function stringLiteralValue(e: ts.Expression): string | undefined {
592
+ const u = unwrap(e);
593
+ return ts.isStringLiteral(u) || ts.isNoSubstitutionTemplateLiteral(u) ? u.text : undefined;
594
+ }
595
+
596
+ /**
597
+ * L37 - infer how a function yields a step's effect id, by SHAPE (like {@link collectBindings}):
598
+ * its single returned expression is a call whose first argument is an object literal with a
599
+ * `toolId` that is a constant (per `str`) or one of the function's plain parameters. Anything else
600
+ * (several returns, a spread, a computed toolId) is `undefined`, never a guess.
601
+ */
602
+ export function inferStepFactory(
603
+ fn: ts.Node,
604
+ rawStr: (e: ts.Expression) => string | undefined
605
+ ): StepFactoryShape | undefined {
606
+ if (!isFn(fn) || fn.body === undefined) return undefined;
607
+ // Names declared anywhere in the factory (parameters, body locals, nested scopes) shadow a
608
+ // module constant of the same name, so an expression that mentions one is never constant-folded.
609
+ const inner = namesDeclaredWithin(fn);
610
+ const str = (e: ts.Expression): string | undefined =>
611
+ mentionsAny(e, inner) ? undefined : rawStr(e);
612
+ let returned: ts.Expression | undefined;
613
+ if (!ts.isBlock(fn.body)) returned = fn.body;
614
+ else {
615
+ let count = 0;
616
+ const scan = (n: ts.Node): void => {
617
+ if (isFn(n) || ts.isMethodDeclaration(n) || ts.isClassLike(n)) return;
618
+ if (ts.isReturnStatement(n)) {
619
+ count++;
620
+ returned = n.expression;
621
+ }
622
+ ts.forEachChild(n, scan);
623
+ };
624
+ ts.forEachChild(fn.body, scan);
625
+ if (count !== 1) return undefined;
626
+ }
627
+ if (returned === undefined) return undefined;
628
+ const call = unwrap(returned);
629
+ if (!ts.isCallExpression(call)) return undefined;
630
+ const arg = call.arguments[0];
631
+ if (arg === undefined || !ts.isObjectLiteralExpression(arg)) return undefined;
632
+ const paramIndex = (name: string): number | undefined => {
633
+ const i = fn.parameters.findIndex((p) => ts.isIdentifier(p.name) && p.name.text === name);
634
+ const p = fn.parameters[i];
635
+ return i >= 0 && p !== undefined && p.dotDotDotToken === undefined ? i : undefined;
636
+ };
637
+ const viaParam = (i: number): StepFactoryShape => {
638
+ const init = fn.parameters[i]?.initializer;
639
+ const dflt = init === undefined ? undefined : str(init);
640
+ return { argIndex: i, ...(dflt !== undefined && { default: dflt }) };
641
+ };
642
+ for (const prop of arg.properties) {
643
+ if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === "toolId") {
644
+ const i = paramIndex("toolId");
645
+ return i === undefined ? undefined : viaParam(i);
646
+ }
647
+ if (ts.isPropertyAssignment(prop) && propName(prop.name) === "toolId") {
648
+ const v = unwrap(prop.initializer);
649
+ if (ts.isIdentifier(v)) {
650
+ const i = paramIndex(v.text);
651
+ if (i !== undefined) return viaParam(i);
652
+ }
653
+ const fixed = str(v);
654
+ return fixed !== undefined ? { fixed } : undefined;
655
+ }
656
+ }
657
+ return undefined;
658
+ }
659
+
660
+ /** ExecutionContext members whose VALUE, handed out, lets the receiver run or suspend the flow. */
661
+ const CTX_METHODS = new Set(["run", "execute", "parallel", "suspend", "sleep", "sleepUntil"]);
662
+
663
+ /** Strip parentheses and type-only wrappers. */
664
+ function unwrap(e: ts.Expression): ts.Expression {
665
+ let cur = e;
666
+ while (
667
+ ts.isParenthesizedExpression(cur) ||
668
+ ts.isAsExpression(cur) ||
669
+ ts.isNonNullExpression(cur) ||
670
+ ts.isSatisfiesExpression(cur) ||
671
+ ts.isTypeAssertionExpression(cur) ||
672
+ (ts.isBinaryExpression(cur) && cur.operatorToken.kind === ts.SyntaxKind.CommaToken)
673
+ ) {
674
+ cur = ts.isBinaryExpression(cur) ? cur.right : cur.expression;
675
+ }
676
+ return cur;
677
+ }
678
+
679
+ /** Something with a parameter list: a function, or an object-literal method. */
680
+ type ParamOwner = FnNode | ts.MethodDeclaration;
681
+
682
+ /** A runner expression that is plainly not a function value (no refusal needed). */
683
+ function isInertValue(e: ts.Expression): boolean {
684
+ return (
685
+ e.kind === ts.SyntaxKind.NullKeyword ||
686
+ e.kind === ts.SyntaxKind.TrueKeyword ||
687
+ e.kind === ts.SyntaxKind.FalseKeyword ||
688
+ ts.isStringLiteralLike(e) ||
689
+ ts.isNumericLiteral(e) ||
690
+ (ts.isIdentifier(e) && e.text === "undefined")
691
+ );
692
+ }
693
+
694
+ /**
695
+ * Runner functions declared in this region: a `runner:` property or `runner = …` class field
696
+ * (inline or a local identifier / shorthand), a `runner(ctx, …) {}` method, the second argument
697
+ * of `registerFlow(def, runner)`, or a function bound to a `…FlowRunner`-typed variable. Their
698
+ * FIRST parameter is the flow's ctx whatever it is called.
699
+ *
700
+ * A runner VALUE that is not a followable function is never silently skipped (round 2):
701
+ * - a call (`withRetry(async (c) => …)`, `makeRunner(lib)`): every function-literal argument
702
+ * (or local function passed by name) is treated as a runner, because a wrapper hands its ctx
703
+ * to the callback; and the call itself is reported `runner not analysable` since the wrapper
704
+ * may do anything else with ctx;
705
+ * - an identifier is followed to a local function or a `const` initializer, else refused
706
+ * (imported, ambiguous);
707
+ * - anything else (member access, conditional branches that are not functions) is refused.
708
+ */
709
+ function collectRunners(
710
+ root: ts.Node,
711
+ functions: Map<string, FnNode>
712
+ ): { runners: ParamOwner[]; bound: Set<ParamOwner>; unresolved: string[] } {
713
+ const out: ParamOwner[] = [];
714
+ /** Followed runners tied to a `flow$` definition (see {@link boundContext}). */
715
+ const bound = new Set<ParamOwner>();
716
+ let boundNow = false;
717
+ const isFlowCall = (e: ts.Expression | undefined): boolean => {
718
+ if (e === undefined) return false;
719
+ const u = unwrap(e);
720
+ if (!ts.isCallExpression(u)) return false;
721
+ const c = unwrap(u.expression);
722
+ return (
723
+ (ts.isIdentifier(c) ? c.text : ts.isPropertyAccessExpression(c) ? c.name.text : "") ===
724
+ "flow$"
725
+ );
726
+ };
727
+ /** An expression that IS a `flow$(...)` call, or an identifier bound once to a `const` holding one. */
728
+ const isFlowDefinition = (e: ts.Expression | undefined): boolean => {
729
+ if (e === undefined) return false;
730
+ const u = unwrap(e);
731
+ return isFlowCall(u) || (ts.isIdentifier(u) && isFlowCall(inits.get(u.text)));
732
+ };
733
+ /** Does this object literal / class body also hold a flow definition (`definition: flow$(...)`)? */
734
+ const holdsDefinition = (container: ts.Node | undefined): boolean => {
735
+ if (container === undefined) return false;
736
+ if (ts.isObjectLiteralExpression(container)) {
737
+ return container.properties.some(
738
+ (m) =>
739
+ (ts.isPropertyAssignment(m) && isFlowDefinition(m.initializer)) ||
740
+ (ts.isShorthandPropertyAssignment(m) && isFlowDefinition(m.name))
741
+ );
742
+ }
743
+ if (ts.isClassLike(container)) {
744
+ return container.members.some(
745
+ (m) => ts.isPropertyDeclaration(m) && isFlowDefinition(m.initializer)
746
+ );
747
+ }
748
+ return false;
749
+ };
750
+ const markBound = (f: ParamOwner): void => {
751
+ if (boundNow) bound.add(f);
752
+ };
753
+ const unresolved: string[] = [];
754
+ const inits = collectConstInits(root);
755
+ const seenValues = new Set<ts.Node>();
756
+ const refuse = (e: ts.Expression): void => {
757
+ const text = `runner not analysable: ${exprText(e)}`;
758
+ if (!unresolved.includes(text)) unresolved.push(text);
759
+ };
760
+ const argFn = (a: ts.Expression): FnNode | undefined => {
761
+ const u = unwrap(a);
762
+ if (isFn(u)) return u;
763
+ return ts.isIdentifier(u) ? functions.get(u.text) : undefined;
764
+ };
765
+ /** The functions a factory returns, or undefined unless EVERY return is a followable function. */
766
+ function returnedFns(factory: FnNode | ts.GetAccessorDeclaration): FnNode[] | undefined {
767
+ const body = factory.body;
768
+ if (body === undefined) return undefined;
769
+ const found: FnNode[] = [];
770
+ let ok = true;
771
+ const take = (e: ts.Expression | undefined): void => {
772
+ const f = e === undefined ? undefined : argFn(e);
773
+ if (f === undefined) ok = false;
774
+ else found.push(f);
775
+ };
776
+ if (!ts.isBlock(body)) take(body);
777
+ else {
778
+ const scan = (n: ts.Node): void => {
779
+ if (isFn(n) || ts.isMethodDeclaration(n) || ts.isClassLike(n)) return;
780
+ if (ts.isReturnStatement(n)) take(n.expression);
781
+ ts.forEachChild(n, scan);
782
+ };
783
+ ts.forEachChild(body, scan);
784
+ }
785
+ return ok && found.length > 0 ? found : undefined;
786
+ }
787
+ function runnerValue(e: ts.Expression | undefined): void {
788
+ if (e === undefined) return;
789
+ const u = unwrap(e);
790
+ if (seenValues.has(u)) {
791
+ if (isFn(u)) markBound(u);
792
+ return;
793
+ }
794
+ seenValues.add(u);
795
+ if (isFn(u)) {
796
+ out.push(u);
797
+ markBound(u);
798
+ } else if (isInertValue(u)) {
799
+ return;
800
+ } else if (ts.isIdentifier(u)) {
801
+ const f = functions.get(u.text);
802
+ const init = inits.get(u.text);
803
+ if (f !== undefined) {
804
+ out.push(f);
805
+ markBound(f);
806
+ } else if (init !== undefined) runnerValue(init);
807
+ else refuse(u);
808
+ } else if (ts.isCallExpression(u) || ts.isNewExpression(u)) {
809
+ for (const a of u.arguments ?? []) {
810
+ const f = argFn(a);
811
+ if (f !== undefined) {
812
+ out.push(f);
813
+ markBound(f);
814
+ }
815
+ }
816
+ // A LOCAL factory (`makeRunner(lib)` where `function makeRunner` is in this region) whose
817
+ // every return is a function: those returned functions are the runners, nothing hidden.
818
+ const callee = ts.isCallExpression(u) ? unwrap(u.expression) : undefined;
819
+ const factory =
820
+ callee !== undefined && ts.isIdentifier(callee) ? functions.get(callee.text) : undefined;
821
+ const returned = factory === undefined ? undefined : returnedFns(factory);
822
+ if (returned !== undefined) {
823
+ out.push(...returned);
824
+ for (const r of returned) markBound(r);
825
+ } else refuse(u);
826
+ } else if (ts.isConditionalExpression(u)) {
827
+ runnerValue(u.whenTrue);
828
+ runnerValue(u.whenFalse);
829
+ } else {
830
+ refuse(u);
831
+ }
832
+ }
833
+ function visit(node: ts.Node): void {
834
+ const prevBound = boundNow;
835
+ boundNow = false;
836
+ if (ts.isPropertyAssignment(node) && propName(node.name) === "runner") {
837
+ boundNow = holdsDefinition(node.parent);
838
+ runnerValue(node.initializer);
839
+ } else if (ts.isShorthandPropertyAssignment(node) && node.name.text === "runner") {
840
+ boundNow = holdsDefinition(node.parent);
841
+ runnerValue(node.name);
842
+ } else if (ts.isMethodDeclaration(node) && propName(node.name) === "runner") {
843
+ out.push(node);
844
+ boundNow = holdsDefinition(node.parent);
845
+ markBound(node);
846
+ } else if (ts.isPropertyDeclaration(node) && propName(node.name) === "runner") {
847
+ boundNow = holdsDefinition(node.parent);
848
+ runnerValue(node.initializer);
849
+ } else if (ts.isGetAccessorDeclaration(node) && propName(node.name) === "runner") {
850
+ // `get runner() { return async (c) => … }`: the returned function(s) are the runner, like a
851
+ // local factory; a getter returning anything else is refused, never silently skipped.
852
+ const returned = returnedFns(node);
853
+ if (returned !== undefined) {
854
+ out.push(...returned);
855
+ if (holdsDefinition(node.parent)) for (const r of returned) bound.add(r);
856
+ } else {
857
+ const text = "runner not analysable: get runner()";
858
+ if (!unresolved.includes(text)) unresolved.push(text);
859
+ }
860
+ } else if (
861
+ ts.isCallExpression(node) &&
862
+ (ts.isIdentifier(node.expression)
863
+ ? node.expression.text
864
+ : ts.isPropertyAccessExpression(node.expression)
865
+ ? node.expression.name.text
866
+ : "") === "registerFlow"
867
+ ) {
868
+ // Bound only when the definition argument IS (or names) this module's `flow$` call.
869
+ boundNow = isFlowDefinition(node.arguments[0]);
870
+ runnerValue(node.arguments[1]);
871
+ } else if (
872
+ ts.isVariableDeclaration(node) &&
873
+ node.type !== undefined &&
874
+ /FlowRunner\b/.test(node.type.getText())
875
+ ) {
876
+ runnerValue(node.initializer);
877
+ }
878
+ boundNow = false;
879
+ ts.forEachChild(node, visit);
880
+ boundNow = prevBound;
881
+ }
882
+ visit(root);
883
+ return { runners: out, bound, unresolved };
884
+ }
885
+
886
+ function propName(n: ts.PropertyName): string | undefined {
887
+ return ts.isIdentifier(n) || ts.isStringLiteralLike(n) ? n.text : undefined;
888
+ }
889
+
890
+ /** What the analysis knows denotes the flow's context (or a method taken off it) in this region. */
891
+ interface CtxModel {
892
+ /** Identifiers that denote the ctx object itself. */
893
+ names: Set<string>;
894
+ /** Local name -> the ctx member it was destructured from (`const { run: r } = ctx` -> r:run). */
895
+ members: Map<string, string>;
896
+ /** Shapes that bind ctx where it cannot be followed (nested/computed patterns, array patterns). */
897
+ unresolved: Set<string>;
898
+ /** How many runner functions `collectRunners` found and followed in this region. */
899
+ runners: number;
900
+ /** How many of those runners are BOUND to a `flow$` definition (see `collectRunners`). */
901
+ boundRunners: number;
902
+ }
903
+
904
+ /**
905
+ * L32 — names that denote a flow's execution context in this region. Textual and module-wide:
906
+ * - `ctx`, a parameter typed `…ExecutionContext` (or a local alias of that type), and the
907
+ * receiver of a `.decide/.suspend/.sleepUntil` call;
908
+ * - the FIRST parameter of every runner (`collectRunners`), whatever it is called, with an
909
+ * object pattern splitting into method values and a rest name (which IS the ctx);
910
+ * - propagation to a fixpoint: a ctx name handed as argument i to a same-module function adds
911
+ * that function's i-th parameter, and `const { …rest } = <ctx name>` adds the rest name.
912
+ * Over-reports by design (a textual name shadowed elsewhere is still treated as ctx).
913
+ */
914
+ function collectCtx(root: ts.Node, functions: Map<string, FnNode>): CtxModel {
915
+ const names = new Set<string>(["ctx"]);
916
+ const members = new Map<string, string>();
917
+ const unresolved = new Set<string>();
918
+ const methods = new Set(["decide", "suspend", "sleepUntil"]);
919
+
920
+ // Local type aliases that resolve to an ExecutionContext.
921
+ const ctxTypes = new Set<string>();
922
+ const isCtxType = (t: ts.TypeNode | undefined): boolean =>
923
+ t !== undefined &&
924
+ ts.isTypeReferenceNode(t) &&
925
+ (/ExecutionContext$/.test(t.typeName.getText()) || ctxTypes.has(t.typeName.getText()));
926
+ for (let grew = true; grew; ) {
927
+ grew = false;
928
+ const scanTypes = (n: ts.Node): void => {
929
+ if (ts.isTypeAliasDeclaration(n) && !ctxTypes.has(n.name.text) && isCtxType(n.type)) {
930
+ ctxTypes.add(n.name.text);
931
+ grew = true;
932
+ }
933
+ ts.forEachChild(n, scanTypes);
934
+ };
935
+ scanTypes(root);
936
+ }
937
+
938
+ // Worklist: every name is processed once per set it joins, against indexes built by ONE walk.
939
+ const queue: string[] = [];
940
+ const addName = (n: string): void => {
941
+ if (!names.has(n)) {
942
+ names.add(n);
943
+ queue.push(n);
944
+ }
945
+ };
946
+ const addMember = (n: string, m: string): void => {
947
+ if (!members.has(n)) {
948
+ members.set(n, m);
949
+ queue.push(n);
950
+ }
951
+ };
952
+ const bindPattern = (pattern: ts.ObjectBindingPattern): void => {
953
+ for (const el of pattern.elements) {
954
+ if (el.dotDotDotToken !== undefined) {
955
+ if (ts.isIdentifier(el.name)) addName(el.name.text);
956
+ else unresolved.add(`ctx pattern ${exprText(el)}`);
957
+ continue;
958
+ }
959
+ const key = el.propertyName ?? el.name;
960
+ const keyText = ts.isIdentifier(key) || ts.isStringLiteralLike(key) ? key.text : undefined;
961
+ if (keyText === "decide") continue; // L31 reports every decide alias
962
+ if (keyText === undefined || !ts.isIdentifier(el.name)) {
963
+ unresolved.add(`ctx pattern ${exprText(el)}`);
964
+ continue;
965
+ }
966
+ addMember(el.name.text, keyText);
967
+ }
968
+ };
969
+ const bindParam = (p: ts.ParameterDeclaration): void => {
970
+ if (ts.isIdentifier(p.name)) addName(p.name.text);
971
+ else if (ts.isObjectBindingPattern(p.name)) bindPattern(p.name);
972
+ else unresolved.add(`ctx parameter ${exprText(p.name)}`);
973
+ };
974
+
975
+ const found = collectRunners(root, functions);
976
+ for (const u of found.unresolved) unresolved.add(u);
977
+ for (const r of found.runners) {
978
+ const first = r.parameters[0];
979
+ if (first !== undefined) bindParam(first);
980
+ }
981
+
982
+ // One walk: seed names, and index the places a name can flow onward from.
983
+ /** identifier argument -> the same-module parameter it is handed to. */
984
+ const argUses = new Map<string, ts.ParameterDeclaration[]>();
985
+ /** identifier -> declarations initialised from it (`const { … } = n`, `const k = n`). */
986
+ const declUses = new Map<string, ts.VariableDeclaration[]>();
987
+ const push = <V>(m: Map<string, V[]>, k: string, v: V): void => {
988
+ const list = m.get(k);
989
+ if (list === undefined) m.set(k, [v]);
990
+ else list.push(v);
991
+ };
992
+ function index(node: ts.Node): void {
993
+ if (
994
+ ts.isCallExpression(node) &&
995
+ ts.isPropertyAccessExpression(node.expression) &&
996
+ methods.has(node.expression.name.text) &&
997
+ ts.isIdentifier(node.expression.expression)
998
+ ) {
999
+ addName(node.expression.expression.text);
1000
+ } else if (ts.isParameter(node) && ts.isIdentifier(node.name) && isCtxType(node.type)) {
1001
+ addName(node.name.text);
1002
+ }
1003
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
1004
+ const callee = unwrap(node.expression);
1005
+ const fn = ts.isIdentifier(callee) ? functions.get(callee.text) : undefined;
1006
+ if (fn !== undefined) {
1007
+ (node.arguments ?? []).forEach((arg, i) => {
1008
+ const a = unwrap(arg);
1009
+ const param = fn.parameters[i];
1010
+ if (
1011
+ ts.isIdentifier(a) &&
1012
+ param !== undefined &&
1013
+ param.dotDotDotToken === undefined &&
1014
+ ts.isIdentifier(param.name)
1015
+ ) {
1016
+ push(argUses, a.text, param);
1017
+ }
1018
+ });
1019
+ }
1020
+ } else if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
1021
+ const init = unwrap(node.initializer);
1022
+ const isConst = (ts.getCombinedNodeFlags(node) & ts.NodeFlags.Const) !== 0;
1023
+ if (
1024
+ ts.isIdentifier(init) &&
1025
+ (ts.isObjectBindingPattern(node.name) || (isConst && ts.isIdentifier(node.name)))
1026
+ ) {
1027
+ push(declUses, init.text, node);
1028
+ }
1029
+ }
1030
+ ts.forEachChild(node, index);
1031
+ }
1032
+ index(root);
1033
+ for (const n of [...names]) queue.push(n);
1034
+ for (const n of members.keys()) queue.push(n);
1035
+
1036
+ while (queue.length > 0) {
1037
+ const n = queue.pop() as string;
1038
+ const asName = names.has(n);
1039
+ const m = members.get(n);
1040
+ for (const param of argUses.get(n) ?? []) {
1041
+ const pn = (param.name as ts.Identifier).text;
1042
+ if (asName) addName(pn);
1043
+ if (m !== undefined) addMember(pn, m);
1044
+ }
1045
+ for (const decl of declUses.get(n) ?? []) {
1046
+ if (ts.isObjectBindingPattern(decl.name)) {
1047
+ if (asName) bindPattern(decl.name);
1048
+ } else if (ts.isIdentifier(decl.name)) {
1049
+ if (asName) addName(decl.name.text);
1050
+ if (m !== undefined) addMember(decl.name.text, m);
1051
+ }
1052
+ }
1053
+ }
1054
+ return {
1055
+ names,
1056
+ members,
1057
+ unresolved,
1058
+ runners: found.runners.length,
1059
+ boundRunners: found.bound.size,
1060
+ };
1061
+ }
1062
+
1063
+ /** The leftmost identifier of a member/call chain (`planner` for `planner.a.b(c).d`). */
1064
+ function chainRoot(e: ts.Expression): string | undefined {
1065
+ let cur: ts.Expression = e;
1066
+ for (;;) {
1067
+ if (ts.isIdentifier(cur)) return cur.text;
1068
+ if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur))
1069
+ cur = cur.expression;
1070
+ else if (ts.isCallExpression(cur) || ts.isNonNullExpression(cur)) cur = cur.expression;
1071
+ else if (ts.isParenthesizedExpression(cur)) cur = cur.expression;
1072
+ else return undefined;
1073
+ }
1074
+ }
1075
+
1076
+ /**
1077
+ * Where does a value go? Climb through literals/spreads/wrappers to the call or `new` that
1078
+ * receives it as an argument; `undefined` when the value is stored, returned or otherwise
1079
+ * leaves through something other than an argument. `direct` is true when the value IS the
1080
+ * argument (not wrapped in a literal/spread), with its index.
1081
+ */
1082
+ function receivingCall(node: ts.Node):
1083
+ | {
1084
+ call: ts.CallExpression | ts.NewExpression;
1085
+ direct: boolean;
1086
+ index: number;
1087
+ }
1088
+ | undefined {
1089
+ let child: ts.Node = node;
1090
+ let direct = true;
1091
+ let p: ts.Node | undefined = node.parent;
1092
+ while (p !== undefined) {
1093
+ if (
1094
+ ts.isParenthesizedExpression(p) ||
1095
+ ts.isAsExpression(p) ||
1096
+ ts.isNonNullExpression(p) ||
1097
+ ts.isSatisfiesExpression(p) ||
1098
+ ts.isTypeAssertionExpression(p)
1099
+ ) {
1100
+ child = p;
1101
+ p = p.parent;
1102
+ continue;
1103
+ }
1104
+ if (
1105
+ ts.isObjectLiteralExpression(p) ||
1106
+ ts.isArrayLiteralExpression(p) ||
1107
+ ts.isSpreadAssignment(p) ||
1108
+ ts.isSpreadElement(p) ||
1109
+ ts.isShorthandPropertyAssignment(p) ||
1110
+ (ts.isPropertyAssignment(p) && p.initializer === child)
1111
+ ) {
1112
+ direct = false;
1113
+ child = p;
1114
+ p = p.parent;
1115
+ continue;
1116
+ }
1117
+ if (ts.isCallExpression(p) || ts.isNewExpression(p)) {
1118
+ const index = p.arguments?.indexOf(child as ts.Expression) ?? -1;
1119
+ if (index >= 0) return { call: p, direct, index };
1120
+ }
1121
+ return undefined;
1122
+ }
1123
+ return undefined;
1124
+ }
1125
+
1126
+ /** Callees that run their first argument (or forward the rest) rather than being the real target. */
1127
+ const PASS_THROUGH = new Set([
1128
+ "Reflect.apply",
1129
+ "setTimeout",
1130
+ "setInterval",
1131
+ "setImmediate",
1132
+ "queueMicrotask",
1133
+ "process.nextTick",
1134
+ ]);
1135
+
1136
+ /** Analyze a parsed node (a flow runner body) for reachable steps and suspend points. */
1137
+ export function analyzeReachNode(
1138
+ root: ts.Node,
1139
+ location?: string,
1140
+ options: AnalyzeReachOptions = {}
1141
+ ): RunnerReach {
1142
+ return analyzeReachRegion(root, location, options).reach;
1143
+ }
1144
+
1145
+ /**
1146
+ * {@link analyzeReachNode} plus how many runner functions the region declares and the analysis
1147
+ * followed, and how many of them are BOUND to a `flow$` definition: declared in the same object
1148
+ * literal / class as a `flow$(...)` (or a const holding one), or passed to `registerFlow(def, …)`
1149
+ * whose `def` is a `flow$` call or names one. An EMPTY reach is ambiguous on its own: "the runner
1150
+ * reaches nothing" and "no runner of THIS flow is here" (an unrelated runner elsewhere in the
1151
+ * module, or one registered from another module) look the same. A runner that is NOT followable is
1152
+ * never silent (`unresolved`), so an empty reach with `boundRunners > 0` means the flow's own
1153
+ * runner was read and reaches no step, suspend or ctx hand-off: a pure runner, analysed.
1154
+ */
1155
+ export function analyzeReachRegion(
1156
+ root: ts.Node,
1157
+ location?: string,
1158
+ options: AnalyzeReachOptions = {}
1159
+ ): { reach: RunnerReach; runners: number; boundRunners: number } {
1160
+ const scan = scanDeclarations(root);
1161
+ const functions = scan.fns;
1162
+ const constInits = scan.inits;
1163
+ const sourceFile = ts.isSourceFile(root) ? root : root.getSourceFile();
1164
+ const declaredAnywhere = scan.declared;
1165
+ const imports = importedNames(sourceFile);
1166
+ // An imported name that is ALSO declared somewhere in the module (a same-named local in another
1167
+ // function) is ambiguous in BOTH directions: the use may be the import or the local, so neither
1168
+ // the local function/const/binding nor the import may answer for it (-> unresolved).
1169
+ const importBindings = allImportBindings(sourceFile);
1170
+ for (const name of importBindings) {
1171
+ functions.delete(name);
1172
+ constInits.delete(name);
1173
+ }
1174
+ // A step binding is followed only when its name is declared exactly once, never reassigned: a
1175
+ // shadowing parameter or a second same-named binding in another function makes it ambiguous.
1176
+ const bindings = collectBindings(root);
1177
+ for (const name of [...bindings.keys()]) {
1178
+ if (
1179
+ (declaredAnywhere.get(name) ?? 0) !== 1 ||
1180
+ scan.ambiguous.has(name) ||
1181
+ importBindings.has(name)
1182
+ )
1183
+ bindings.delete(name);
1184
+ }
1185
+ /** A name with no declaration anywhere in the module: only then can it be the import binding. */
1186
+ const unshadowed = (name: string): boolean => (declaredAnywhere.get(name) ?? 0) === 0;
1187
+ const importer = options.importResolver;
1188
+ const steps: RunnerStepRef[] = [];
1189
+ const unresolved: UnresolvedReach[] = [];
1190
+ const suspends: RunnerSuspend[] = [];
1191
+ const escapes: ReachEscape[] = [];
1192
+ const escapeSeen = new Set<string>();
1193
+ const ctx = collectCtx(root, functions);
1194
+ const ctxNames = ctx.names;
1195
+ const at = location !== undefined ? { location } : {};
1196
+ for (const expr of ctx.unresolved) unresolved.push({ expr, kind: "step", ...at });
1197
+
1198
+ /**
1199
+ * L37 - the string a constant expression denotes: a literal, an expression-free or
1200
+ * all-constant template, `a + b`, or an identifier bound once to such a `const` in this module
1201
+ * (or IMPORTED from a relative module, when an `importResolver` is supplied). `undefined` for
1202
+ * anything else - never a guess.
1203
+ */
1204
+ const constString = (e: ts.Expression, depth = 0): string | undefined => {
1205
+ if (depth > 8) return undefined;
1206
+ const u = unwrap(e);
1207
+ if (ts.isStringLiteral(u) || ts.isNoSubstitutionTemplateLiteral(u)) return u.text;
1208
+ if (ts.isTemplateExpression(u)) {
1209
+ let out = u.head.text;
1210
+ for (const span of u.templateSpans) {
1211
+ const v = constString(span.expression, depth + 1);
1212
+ if (v === undefined) return undefined;
1213
+ out += v + span.literal.text;
1214
+ }
1215
+ return out;
1216
+ }
1217
+ if (ts.isBinaryExpression(u) && u.operatorToken.kind === ts.SyntaxKind.PlusToken) {
1218
+ const l = constString(u.left, depth + 1);
1219
+ const r = l === undefined ? undefined : constString(u.right, depth + 1);
1220
+ return l !== undefined && r !== undefined ? l + r : undefined;
1221
+ }
1222
+ if (ts.isIdentifier(u)) {
1223
+ // Also declared somewhere in this module, yet imported: which one the use means is unknown.
1224
+ if (imports.has(u.text) && !unshadowed(u.text)) return undefined;
1225
+ const init = constInits.get(u.text);
1226
+ if (init !== undefined) return constString(init, depth + 1);
1227
+ return unshadowed(u.text) ? importer?.(u.text)?.value : undefined;
1228
+ }
1229
+ return undefined;
1230
+ };
1231
+
1232
+ /** A string property of an object literal, constant-resolved (`toolId: LLM_X`). */
1233
+ const constProp = (obj: ts.ObjectLiteralExpression, name: string): string | undefined => {
1234
+ // JS takes the LAST duplicate key, and a spread AFTER the key may override it: neither is
1235
+ // authoritative, so the property is unresolved (a spread BEFORE the key is fine).
1236
+ let found: string | undefined;
1237
+ let count = 0;
1238
+ let spreadAfter = false;
1239
+ for (const p of obj.properties) {
1240
+ if (ts.isSpreadAssignment(p)) {
1241
+ if (count > 0) spreadAfter = true;
1242
+ continue;
1243
+ }
1244
+ if (ts.isPropertyAssignment(p) && propName(p.name) === name) {
1245
+ count++;
1246
+ found = constString(p.initializer);
1247
+ } else if (ts.isShorthandPropertyAssignment(p) && p.name.text === name) {
1248
+ count++;
1249
+ found = constString(p.name);
1250
+ }
1251
+ }
1252
+ return count === 1 && !spreadAfter ? found : undefined;
1253
+ };
1254
+
1255
+ /**
1256
+ * L37 - a step-producing call: the shape-based `anything$({ toolId })` first, then a factory
1257
+ * (same-module function, imported function, or a first-party one in {@link KNOWN_STEP_FACTORIES})
1258
+ * whose effect id is fixed or one of its arguments. The step ID may be templated (`tool:${i}`):
1259
+ * verify needs the EFFECT id.
1260
+ */
1261
+ const resolveCall = (call: ts.CallExpression): RunnerStepRef | undefined => {
1262
+ const first = call.arguments[0];
1263
+ if (first !== undefined && ts.isObjectLiteralExpression(first)) {
1264
+ const toolId = constProp(first, "toolId");
1265
+ if (toolId !== undefined) {
1266
+ const stepId = constProp(first, "id");
1267
+ return { toolId, ...(stepId !== undefined && { stepId }) };
1268
+ }
1269
+ }
1270
+ const callee = unwrap(call.expression);
1271
+ // A step with NO `toolId` dispatches its own id: the runtime resolves `step.toolId ?? step.id`
1272
+ // (WorkflowRuntime). Followed only for `step$` itself (an unshadowed import from @loomwork/core or
1273
+ // @loomwork/sdk, any alias) whose object has no `toolId` key and no spread: a wrapper might add one.
1274
+ if (
1275
+ first !== undefined &&
1276
+ ts.isObjectLiteralExpression(first) &&
1277
+ ts.isIdentifier(callee) &&
1278
+ unshadowed(callee.text) &&
1279
+ first.properties.every(
1280
+ (p) =>
1281
+ !ts.isSpreadAssignment(p) &&
1282
+ !ts.isMethodDeclaration(p) &&
1283
+ !(p.name !== undefined && propName(p.name) === "toolId") &&
1284
+ !(p.name !== undefined && ts.isComputedPropertyName(p.name))
1285
+ )
1286
+ ) {
1287
+ const imp = imports.get(callee.text);
1288
+ if (
1289
+ imp?.imported === "step$" &&
1290
+ (imp.spec === "@loomwork/core" || imp.spec === "@loomwork/sdk")
1291
+ ) {
1292
+ const id = constProp(first, "id");
1293
+ return id === undefined ? undefined : { toolId: id, stepId: id };
1294
+ }
1295
+ }
1296
+ const name = ts.isIdentifier(callee)
1297
+ ? callee.text
1298
+ : ts.isPropertyAccessExpression(callee)
1299
+ ? callee.name.text
1300
+ : undefined;
1301
+ if (name === undefined || call.arguments.some((a) => ts.isSpreadElement(a))) return undefined;
1302
+ let shape: StepFactoryShape | undefined;
1303
+ const local = ts.isIdentifier(callee) ? functions.get(name) : undefined;
1304
+ if (local !== undefined) shape = inferStepFactory(local, constString);
1305
+ else if (!ts.isIdentifier(callee) || !unshadowed(name)) shape = undefined;
1306
+ else {
1307
+ // A bare, undeclared name: the import binding. A relative wrapper is followed through the
1308
+ // importer; a first-party factory only when imported from its own package (or the SDK).
1309
+ const imp = imports.get(name);
1310
+ shape = importer?.(name)?.factory;
1311
+ if (
1312
+ shape === undefined &&
1313
+ imp !== undefined &&
1314
+ Object.hasOwn(KNOWN_STEP_FACTORIES, imp.imported) &&
1315
+ KNOWN_FACTORY_SOURCES[imp.imported]?.includes(imp.spec) === true
1316
+ ) {
1317
+ shape = KNOWN_STEP_FACTORIES[imp.imported];
1318
+ }
1319
+ }
1320
+ if (shape === undefined) return undefined;
1321
+ if ("fixed" in shape) return { toolId: shape.fixed };
1322
+ const arg = call.arguments[shape.argIndex];
1323
+ const toolId = arg === undefined ? shape.default : constString(arg);
1324
+ return toolId === undefined ? undefined : { toolId };
1325
+ };
1326
+
1327
+ /**
1328
+ * Resolve a `ctx.run` / `ctx.parallel` / hand-off step target to a tool, or `undefined` when it
1329
+ * cannot be (reported unresolved by the caller - never silently dropped).
1330
+ */
1331
+ const isLoomFlowBuilder = (callee: ts.Expression): boolean => {
1332
+ const c = unwrap(callee);
1333
+ if (!ts.isIdentifier(c) || !unshadowed(c.text)) return false;
1334
+ const imp = imports.get(c.text);
1335
+ return (
1336
+ imp?.imported === "flow$" && (imp.spec === "@loomwork/core" || imp.spec === "@loomwork/sdk")
1337
+ );
1338
+ };
1339
+ const escapeCache = new Map<string, boolean>();
1340
+ const resolveTarget = (target: ts.Expression, depth = 0): RunnerStepRef | undefined => {
1341
+ const t = unwrap(target);
1342
+ if (ts.isIdentifier(t)) {
1343
+ // A step object that is mutated, aliased or handed to another function may no longer be what
1344
+ // its declaration says (ADR 0086 amendment 2026-10-10): not followed, never guessed.
1345
+ let escapes = escapeCache.get(t.text);
1346
+ if (escapes === undefined) {
1347
+ escapes = stepBindingEscapes(sourceFile, t.text, ctxNames, isLoomFlowBuilder);
1348
+ escapeCache.set(t.text, escapes);
1349
+ }
1350
+ if (escapes) return undefined;
1351
+ const bound = bindings.get(t.text);
1352
+ if (bound !== undefined) return bound;
1353
+ const init = constInits.get(t.text);
1354
+ return init !== undefined && depth < 4 ? resolveTarget(init, depth + 1) : undefined;
1355
+ }
1356
+ if (ts.isObjectLiteralExpression(t)) {
1357
+ const toolId = constProp(t, "toolId");
1358
+ if (toolId === undefined) return undefined;
1359
+ const stepId = constProp(t, "id");
1360
+ return { toolId, ...(stepId !== undefined && { stepId }) };
1361
+ }
1362
+ if (ts.isCallExpression(t)) return resolveCall(t);
1363
+ return undefined;
1364
+ };
1365
+
1366
+ const stripCallish = (c: ts.Expression): ts.Expression => {
1367
+ let cur = unwrap(c);
1368
+ while (
1369
+ ts.isPropertyAccessExpression(cur) &&
1370
+ ["call", "apply", "bind"].includes(cur.name.text)
1371
+ ) {
1372
+ cur = unwrap(cur.expression);
1373
+ }
1374
+ return cur;
1375
+ };
1376
+ const isPlainChain = (e: ts.Expression): boolean =>
1377
+ ts.isIdentifier(e) || (ts.isPropertyAccessExpression(e) && isPlainChain(e.expression));
1378
+
1379
+ /**
1380
+ * L34 - the hand-off's arguments (at most the first 8) that resolve to a step.
1381
+ */
1382
+ function escapeArgs(site: ts.CallExpression | ts.NewExpression): ReachEscapeArg[] {
1383
+ const out: ReachEscapeArg[] = [];
1384
+ (site.arguments ?? []).slice(0, 8).forEach((a, index) => {
1385
+ const step = resolveTarget(a);
1386
+ if (step !== undefined) out.push({ index, step });
1387
+ });
1388
+ return out;
1389
+ }
1390
+
1391
+ /**
1392
+ * Record a hand-off to whatever `site` calls. `.call/.apply/.bind` on the callee is stripped
1393
+ * and a pass-through (`Reflect.apply(lib, …)`, `setTimeout(lib, …)`) is unwrapped to its real
1394
+ * target when that is a plain name; otherwise the hand-off is reported unresolved. A
1395
+ * same-module callee is already analysed for a `decide` alias; for ctx or a ctx method it is
1396
+ * reported (the value is wrapped or the parameter cannot be followed).
1397
+ */
1398
+ function recordEscape(
1399
+ site: ts.CallExpression | ts.NewExpression,
1400
+ via: "ctx" | "decide",
1401
+ member?: string
1402
+ ): void {
1403
+ const handed = via === "decide" ? "ctx.decide" : member !== undefined ? `ctx.${member}` : "ctx";
1404
+ let callee = unwrap(site.expression);
1405
+ const direct = exprText(callee);
1406
+ if (!PASS_THROUGH.has(direct)) callee = stripCallish(callee);
1407
+ if (PASS_THROUGH.has(direct)) {
1408
+ const target =
1409
+ site.arguments?.[0] === undefined ? undefined : stripCallish(site.arguments[0]);
1410
+ if (target === undefined || !isPlainChain(target)) {
1411
+ unresolved.push({
1412
+ expr: `${handed} handed through ${direct} (target not static)`,
1413
+ kind: "step",
1414
+ ...at,
1415
+ });
1416
+ return;
1417
+ }
1418
+ callee = target;
1419
+ } else if (/^Promise\./.test(direct)) {
1420
+ unresolved.push({ expr: `${handed} handed to ${direct}`, kind: "step", ...at });
1421
+ return;
1422
+ }
1423
+ if (ts.isIdentifier(callee) && functions.has(callee.text)) {
1424
+ if (via === "decide") return;
1425
+ unresolved.push({
1426
+ expr: `${handed} handed to same-module ${callee.text} in a shape that is not followed`,
1427
+ kind: "step",
1428
+ ...at,
1429
+ });
1430
+ return;
1431
+ }
1432
+ const text = exprText(callee);
1433
+ const rootName = chainRoot(callee);
1434
+ // The receiving callee is itself the ctx (`ctx.emit("x", ctx)`, `setTimeout(ctx.run, 0)`,
1435
+ // `Reflect.apply(ctx.run, ctx, [step])`): there is no outside code to look up a declaration
1436
+ // for, and `verify.reach["ctx"]` would be nonsense. The call is a ctx-method call whose
1437
+ // argument this walk does not resolve, so refuse it (once per callee).
1438
+ if (rootName !== undefined && (ctxNames.has(rootName) || ctx.members.has(rootName))) {
1439
+ const key = `ctx-rooted:${text}`;
1440
+ if (escapeSeen.has(key)) return;
1441
+ escapeSeen.add(key);
1442
+ unresolved.push({
1443
+ expr: `ctx value handed to ctx-rooted ${text} (not resolved)`,
1444
+ kind: "step",
1445
+ ...at,
1446
+ });
1447
+ return;
1448
+ }
1449
+ // L34 - the other arguments, for a whole-ctx hand-off made directly (not through a
1450
+ // pass-through, whose arguments are not the callee's).
1451
+ const args =
1452
+ via === "ctx" && member === undefined && !PASS_THROUGH.has(direct)
1453
+ ? escapeArgs(site)
1454
+ : undefined;
1455
+ const key = `${via}:${member ?? ""}:${text}:${args === undefined ? "" : JSON.stringify(args)}`;
1456
+ if (escapeSeen.has(key)) return;
1457
+ escapeSeen.add(key);
1458
+ escapes.push({
1459
+ callee: text,
1460
+ ...(rootName !== undefined && { root: rootName }),
1461
+ via,
1462
+ ...(member !== undefined && { member }),
1463
+ ...(args !== undefined && args.length > 0 && { args }),
1464
+ ...at,
1465
+ });
1466
+ }
1467
+
1468
+ /** Route a ctx (or ctx-member) VALUE at `node` to its receiving callee, or report it stored. */
1469
+ function routeValue(node: ts.Node, member?: string): void {
1470
+ const recv = receivingCall(node);
1471
+ if (recv === undefined) {
1472
+ const what = member !== undefined ? `ctx.${member}` : "ctx";
1473
+ // A value interpolated into a tagged template reads as the whole tagged expression.
1474
+ let where: ts.Node = node.parent;
1475
+ for (let p: ts.Node | undefined = node.parent; p !== undefined; p = p.parent) {
1476
+ if (ts.isTaggedTemplateExpression(p)) {
1477
+ where = p;
1478
+ break;
1479
+ }
1480
+ if (!ts.isTemplateSpan(p) && !ts.isTemplateExpression(p)) break;
1481
+ }
1482
+ unresolved.push({ expr: `${what} stored as ${exprText(where)}`, kind: "step", ...at });
1483
+ return;
1484
+ }
1485
+ // Direct hand-off to a same-module function: its i-th parameter was added to the ctx
1486
+ // names (propagation) and its body is walked here, so it is followed — but only for the
1487
+ // ctx itself, and only when the parameter is a plain identifier.
1488
+ const callee = unwrap(recv.call.expression);
1489
+ if (member === undefined && ts.isIdentifier(callee) && functions.has(callee.text)) {
1490
+ const fn = functions.get(callee.text);
1491
+ const param = fn?.parameters[recv.index];
1492
+ if (
1493
+ recv.direct &&
1494
+ param !== undefined &&
1495
+ param.dotDotDotToken === undefined &&
1496
+ ts.isIdentifier(param.name)
1497
+ ) {
1498
+ return;
1499
+ }
1500
+ unresolved.push({
1501
+ expr: `ctx handed to same-module ${callee.text} where the parameter cannot be followed`,
1502
+ kind: "step",
1503
+ ...at,
1504
+ });
1505
+ return;
1506
+ }
1507
+ recordEscape(recv.call, "ctx", member);
1508
+ }
1509
+
1510
+ /**
1511
+ * `ctx.<m>` / `ctx["m"]`. A direct call is the normal path (the walk analyses it). A method
1512
+ * taken as a VALUE (`lib(ctx.run)`, `ctx.run.bind(ctx)`, `ctx.run.call(ctx, step)`) hands the
1513
+ * receiver the ability to run or suspend the flow: an escape like `ctx.decide`.
1514
+ */
1515
+ function flagCtxMember(access: ts.PropertyAccessExpression | ts.ElementAccessExpression): void {
1516
+ let member: string | undefined;
1517
+ if (ts.isPropertyAccessExpression(access)) member = access.name.text;
1518
+ else if (ts.isStringLiteralLike(access.argumentExpression))
1519
+ member = access.argumentExpression.text;
1520
+ else {
1521
+ unresolved.push({ expr: `ctx dynamic member ${exprText(access)}`, kind: "step", ...at });
1522
+ return;
1523
+ }
1524
+ if (member === "decide" || !CTX_METHODS.has(member)) return; // L31 / data properties
1525
+ // The expression the member value flows out of, skipping wrappers.
1526
+ let top: ts.Node = access;
1527
+ while (
1528
+ top.parent !== undefined &&
1529
+ (ts.isParenthesizedExpression(top.parent) ||
1530
+ ts.isNonNullExpression(top.parent) ||
1531
+ ts.isAsExpression(top.parent))
1532
+ ) {
1533
+ top = top.parent;
1534
+ }
1535
+ const parent = top.parent;
1536
+ if (parent !== undefined && ts.isCallExpression(parent) && parent.expression === top) {
1537
+ // Direct call. Property form is analysed by the receiver-agnostic walk; the
1538
+ // element-access form is not, so do it here.
1539
+ if (ts.isElementAccessExpression(access)) {
1540
+ if (member === "run") visitRun(parent);
1541
+ else if (member === "parallel") visitParallel(parent);
1542
+ else if (member === "suspend") visitSuspend(parent);
1543
+ }
1544
+ return;
1545
+ }
1546
+ if (
1547
+ parent !== undefined &&
1548
+ ts.isPropertyAccessExpression(parent) &&
1549
+ parent.expression === top
1550
+ ) {
1551
+ const op = parent.name.text;
1552
+ const gp = parent.parent;
1553
+ const called = gp !== undefined && ts.isCallExpression(gp) && gp.expression === parent;
1554
+ if (called && (op === "call" || op === "apply")) {
1555
+ if (op === "call" && member === "run") visitRun(gp, 1);
1556
+ else if (op === "call" && member === "parallel") visitParallel(gp, 1);
1557
+ else if (op === "call" && member === "suspend") visitSuspend(gp, 1);
1558
+ else if (
1559
+ op === "apply" &&
1560
+ (member === "run" || member === "suspend" || member === "parallel")
1561
+ ) {
1562
+ unresolved.push({
1563
+ expr: `ctx.${member}.apply(…)`,
1564
+ kind: member === "suspend" ? "suspend" : "step",
1565
+ ...at,
1566
+ });
1567
+ }
1568
+ return;
1569
+ }
1570
+ if (called && op === "bind") {
1571
+ routeValue(gp, member);
1572
+ return;
1573
+ }
1574
+ return; // `ctx.run.length` and the like
1575
+ }
1576
+ routeValue(top, member);
1577
+ }
1578
+
1579
+ /** A reference to the execution context (or a method taken off it) in a value position. */
1580
+ function flagCtxReference(node: ts.Identifier, kind: "ctx" | "method"): void {
1581
+ const parent = node.parent;
1582
+ if (
1583
+ (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
1584
+ (ts.isPropertyAssignment(parent) && parent.name === node) ||
1585
+ (ts.isParameter(parent) && parent.name === node) ||
1586
+ (ts.isVariableDeclaration(parent) && parent.name === node) ||
1587
+ (ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
1588
+ ts.isTypeOfExpression(parent) ||
1589
+ (ts.isBinaryExpression(parent) &&
1590
+ [
1591
+ ts.SyntaxKind.EqualsEqualsToken,
1592
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
1593
+ ts.SyntaxKind.ExclamationEqualsToken,
1594
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
1595
+ ].includes(parent.operatorToken.kind))
1596
+ ) {
1597
+ return;
1598
+ }
1599
+ // `const k = ctx` — a const alias is itself a ctx name (collectCtx), so it is followed
1600
+ // rather than reported stored. `let`/reassigned stays stored-unresolved.
1601
+ if (
1602
+ ts.isVariableDeclaration(parent) &&
1603
+ parent.initializer !== undefined &&
1604
+ unwrap(parent.initializer) === node &&
1605
+ ts.isIdentifier(parent.name) &&
1606
+ (ts.getCombinedNodeFlags(parent) & ts.NodeFlags.Const) !== 0
1607
+ ) {
1608
+ return;
1609
+ }
1610
+ if (kind === "method") {
1611
+ const member = ctx.members.get(node.text) as string;
1612
+ if (ts.isCallExpression(parent) && parent.expression === node) {
1613
+ if (member === "run") visitRun(parent);
1614
+ else if (member === "parallel") visitParallel(parent);
1615
+ else if (member === "suspend") visitSuspend(parent);
1616
+ return;
1617
+ }
1618
+ routeValue(node, member);
1619
+ return;
1620
+ }
1621
+ if (
1622
+ (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) &&
1623
+ parent.expression === node
1624
+ ) {
1625
+ flagCtxMember(parent);
1626
+ return;
1627
+ }
1628
+ // `const { decide, …rest } = ctx` — `decide` is L31's, the rest names were bound as ctx.
1629
+ if (
1630
+ ts.isVariableDeclaration(parent) &&
1631
+ parent.initializer === node &&
1632
+ ts.isObjectBindingPattern(parent.name)
1633
+ ) {
1634
+ return;
1635
+ }
1636
+ // `ctx.run.bind(ctx)` / `.call(ctx, …)`: the ctx argument belongs to the member call,
1637
+ // which flagCtxMember already routes.
1638
+ const recv = receivingCall(node);
1639
+ if (recv !== undefined) {
1640
+ const e = unwrap(recv.call.expression);
1641
+ if (ts.isPropertyAccessExpression(e) && ["bind", "call", "apply"].includes(e.name.text)) {
1642
+ const m = unwrap(e.expression);
1643
+ const mm =
1644
+ ts.isPropertyAccessExpression(m) && ts.isIdentifier(unwrap(m.expression))
1645
+ ? (unwrap(m.expression) as ts.Identifier).text
1646
+ : undefined;
1647
+ if (
1648
+ mm !== undefined &&
1649
+ ctxNames.has(mm) &&
1650
+ CTX_METHODS.has((m as ts.PropertyAccessExpression).name.text)
1651
+ )
1652
+ return;
1653
+ }
1654
+ }
1655
+ routeValue(node);
1656
+ }
1657
+
1658
+ function visitRun(call: ts.CallExpression, argOffset = 0): void {
1659
+ const target = call.arguments[argOffset];
1660
+ if (target === undefined) return;
1661
+ const ref = resolveTarget(target);
1662
+ if (ref !== undefined) {
1663
+ steps.push(ref);
1664
+ return;
1665
+ }
1666
+ // An element access, a parameter, a factory whose effect id is not constant - all real and
1667
+ // all unresolvable from this region alone.
1668
+ unresolved.push({ expr: exprText(target), kind: "step", ...at });
1669
+ }
1670
+
1671
+ /**
1672
+ * L35 - `ctx.parallel([[step, input], …])`. Each tuple's first element is a `ctx.run` target,
1673
+ * resolved the same way. A branch written as a callback is already walked (its `b.run(...)` is
1674
+ * found like any `run`). A pair list that is not an array literal (or a const bound to one), a
1675
+ * spread, or a branch that is neither a tuple nor a function is reported unresolved: the steps
1676
+ * it holds cannot be seen, and an empty answer would read as "reaches nothing".
1677
+ */
1678
+ function listUsedBeyondParallel(name: string): boolean {
1679
+ let used = false;
1680
+ const walk = (n: ts.Node): void => {
1681
+ if (used) return;
1682
+ if (ts.isIdentifier(n) && n.text === name) {
1683
+ let top: ts.Node = n;
1684
+ while (
1685
+ top.parent !== undefined &&
1686
+ (ts.isParenthesizedExpression(top.parent) ||
1687
+ ts.isAsExpression(top.parent) ||
1688
+ ts.isNonNullExpression(top.parent) ||
1689
+ ts.isSatisfiesExpression(top.parent))
1690
+ )
1691
+ top = top.parent;
1692
+ const p = n.parent;
1693
+ const isDecl = ts.isVariableDeclaration(p) && p.name === n;
1694
+ const isMemberName = ts.isPropertyAccessExpression(p) && p.name === n;
1695
+ const isKey = (ts.isPropertyAssignment(p) || ts.isPropertySignature(p)) && p.name === n;
1696
+ const isParallelArg =
1697
+ top.parent !== undefined &&
1698
+ ts.isCallExpression(top.parent) &&
1699
+ methodCall(top.parent, "parallel") !== undefined &&
1700
+ top.parent.arguments.includes(top as ts.Expression);
1701
+ if (!isDecl && !isMemberName && !isKey && !isParallelArg) used = true;
1702
+ }
1703
+ ts.forEachChild(n, walk);
1704
+ };
1705
+ walk(sourceFile);
1706
+ return used;
1707
+ }
1708
+
1709
+ function visitParallel(call: ts.CallExpression, argOffset = 0): void {
1710
+ const arg = call.arguments[argOffset];
1711
+ if (arg === undefined) return;
1712
+ let list = unwrap(arg);
1713
+ if (ts.isIdentifier(list)) {
1714
+ const init = constInits.get(list.text);
1715
+ if (init !== undefined) {
1716
+ // A list that is also pushed to / passed on / iterated is not the literal it was bound to.
1717
+ if (listUsedBeyondParallel(list.text)) {
1718
+ unresolved.push({ expr: `parallel(${exprText(arg)})`, kind: "step", ...at });
1719
+ return;
1720
+ }
1721
+ list = unwrap(init);
1722
+ }
1723
+ }
1724
+ if (!ts.isArrayLiteralExpression(list)) {
1725
+ unresolved.push({ expr: `parallel(${exprText(arg)})`, kind: "step", ...at });
1726
+ return;
1727
+ }
1728
+ for (const el of list.elements) {
1729
+ if (ts.isSpreadElement(el)) {
1730
+ unresolved.push({ expr: `parallel(...${exprText(el.expression)})`, kind: "step", ...at });
1731
+ continue;
1732
+ }
1733
+ const u = unwrap(el);
1734
+ if (ts.isArrayLiteralExpression(u)) {
1735
+ const first = u.elements[0];
1736
+ if (first === undefined || ts.isSpreadElement(first)) {
1737
+ unresolved.push({ expr: `parallel branch ${exprText(u)}`, kind: "step", ...at });
1738
+ continue;
1739
+ }
1740
+ const ref = resolveTarget(first);
1741
+ if (ref !== undefined) steps.push(ref);
1742
+ else unresolved.push({ expr: exprText(first), kind: "step", ...at });
1743
+ } else if (isFn(u) || (ts.isIdentifier(u) && functions.has(u.text))) {
1744
+ // A callback branch: its body is part of this walk.
1745
+ } else {
1746
+ unresolved.push({ expr: `parallel branch ${exprText(u)}`, kind: "step", ...at });
1747
+ }
1748
+ }
1749
+ }
1750
+
1751
+ function visitSuspend(call: ts.CallExpression, argOffset = 0): void {
1752
+ const condition = call.arguments[argOffset];
1753
+ if (condition === undefined || !ts.isObjectLiteralExpression(condition)) {
1754
+ // A condition built elsewhere. Recording it as unresolved keeps a flow that
1755
+ // suspends from looking like a flow that does not.
1756
+ unresolved.push({
1757
+ expr: condition === undefined ? "suspend()" : exprText(condition),
1758
+ kind: "suspend",
1759
+ ...at,
1760
+ });
1761
+ return;
1762
+ }
1763
+ const on = stringProp(condition, "on");
1764
+ const correlationKey = stringProp(condition, "correlationKey");
1765
+ suspends.push({
1766
+ ...(on !== undefined && { id: on }),
1767
+ ...(correlationKey !== undefined && { correlationKey }),
1768
+ ...(hasProp(condition, "timeout") && { timed: true }),
1769
+ ...(hasProp(condition, "wakeAt") && { wakeAt: true }),
1770
+ });
1771
+ }
1772
+
1773
+ /**
1774
+ * L31 — a `ctx.decide(id, decider)` decider receives a scope whose
1775
+ * `invokeTransient(toolId, …)` runs an effect as the deciding execution. Those calls
1776
+ * are reached effects exactly like `ctx.run` targets, but live in a callback the
1777
+ * `ctx.run` walk never classified.
1778
+ *
1779
+ * The scope is tracked by BINDING: the first parameter, either an identifier
1780
+ * (`(scope) =>`) whose only permitted use is as the receiver of a called
1781
+ * `.invokeTransient`, or an object pattern (`({ invokeTransient })`, with or without a
1782
+ * rename) whose bound name may only be called. ANY other use — passing the scope on,
1783
+ * aliasing it, `scope["invokeTransient"]`, a rest element — is reported as unresolved,
1784
+ * because the call it hides cannot be seen. Name matching is textual: a nested
1785
+ * function that shadows the scope's name is treated as the scope (over-reports, never
1786
+ * under-reports).
1787
+ */
1788
+ function visitDecide(call: ts.CallExpression): void {
1789
+ if (call.arguments.some((a) => ts.isSpreadElement(a))) {
1790
+ unresolved.push({
1791
+ expr: `decide(${exprText(call.arguments[0] ?? call)})`,
1792
+ kind: "step",
1793
+ ...at,
1794
+ });
1795
+ return;
1796
+ }
1797
+ const decider = call.arguments[1];
1798
+ if (decider === undefined) return;
1799
+ let fn: FnNode | undefined;
1800
+ if (isFn(decider)) fn = decider;
1801
+ else if (ts.isIdentifier(decider)) fn = functions.get(decider.text);
1802
+ if (fn === undefined) {
1803
+ // A decider whose body is not in this region (imported, returned from a factory,
1804
+ // a member). It may take a scope and call anything; same treatment as an
1805
+ // unresolvable `ctx.run` target.
1806
+ unresolved.push({ expr: `decide(${exprText(decider)})`, kind: "step", ...at });
1807
+ return;
1808
+ }
1809
+ const param = fn.parameters[0];
1810
+ if (param === undefined || fn.body === undefined) return; // never receives a scope
1811
+ const scopeNames = new Set<string>();
1812
+ const callNames = new Set<string>();
1813
+ if (ts.isIdentifier(param.name)) {
1814
+ scopeNames.add(param.name.text);
1815
+ } else if (ts.isObjectBindingPattern(param.name)) {
1816
+ for (const el of param.name.elements) {
1817
+ if (el.dotDotDotToken !== undefined) {
1818
+ unresolved.push({ expr: `decide scope rest ${exprText(el)}`, kind: "step", ...at });
1819
+ continue;
1820
+ }
1821
+ const key = el.propertyName ?? el.name;
1822
+ const keyText =
1823
+ ts.isIdentifier(key) || ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)
1824
+ ? key.text
1825
+ : undefined;
1826
+ // A computed key could be anything; a nested pattern or non-identifier name
1827
+ // could bind `invokeTransient` where the call cannot be followed.
1828
+ const couldBind = keyText === "invokeTransient" || ts.isComputedPropertyName(key);
1829
+ if (!couldBind) continue;
1830
+ if (keyText === "invokeTransient" && ts.isIdentifier(el.name)) {
1831
+ callNames.add(el.name.text);
1832
+ } else {
1833
+ unresolved.push({ expr: `decide scope ${exprText(el)}`, kind: "step", ...at });
1834
+ }
1835
+ }
1836
+ } else {
1837
+ unresolved.push({ expr: `decide(${exprText(param)})`, kind: "step", ...at });
1838
+ return;
1839
+ }
1840
+ if (scopeNames.size === 0 && callNames.size === 0) return;
1841
+
1842
+ const record = (c: ts.CallExpression): void => {
1843
+ const first = c.arguments[0];
1844
+ const id = first === undefined ? undefined : constString(first);
1845
+ if (id !== undefined) steps.push({ toolId: id, viaDecider: true });
1846
+ else {
1847
+ const arg = c.arguments[0];
1848
+ unresolved.push({
1849
+ expr: `invokeTransient(${arg === undefined ? "" : exprText(arg)})`,
1850
+ kind: "step",
1851
+ ...at,
1852
+ });
1853
+ }
1854
+ };
1855
+ const escaped = (n: ts.Node): void => {
1856
+ unresolved.push({ expr: `decide scope used as ${exprText(n.parent)}`, kind: "step", ...at });
1857
+ };
1858
+
1859
+ function scan(node: ts.Node): void {
1860
+ if (ts.isIdentifier(node)) {
1861
+ const parent = node.parent;
1862
+ // Property names and declaration names are not references.
1863
+ const isName =
1864
+ (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
1865
+ (ts.isPropertyAssignment(parent) && parent.name === node) ||
1866
+ (ts.isBindingElement(parent) && parent.propertyName === node);
1867
+ if (!isName) {
1868
+ if (scopeNames.has(node.text)) {
1869
+ const grand = parent.parent;
1870
+ if (
1871
+ ts.isPropertyAccessExpression(parent) &&
1872
+ parent.expression === node &&
1873
+ parent.name.text === "invokeTransient" &&
1874
+ grand !== undefined &&
1875
+ ts.isCallExpression(grand) &&
1876
+ grand.expression === parent
1877
+ ) {
1878
+ record(grand);
1879
+ } else {
1880
+ escaped(node);
1881
+ }
1882
+ } else if (callNames.has(node.text)) {
1883
+ if (ts.isCallExpression(parent) && parent.expression === node) record(parent);
1884
+ else escaped(node);
1885
+ }
1886
+ }
1887
+ }
1888
+ ts.forEachChild(node, scan);
1889
+ }
1890
+ scan(fn.body);
1891
+ }
1892
+
1893
+ /**
1894
+ * `ctx.decide` reached by any route other than a direct `<x>.decide(…)` call hides a
1895
+ * decider this walk would have followed: `ctx.decide.bind(ctx)`, `const f = ctx.decide`,
1896
+ * `const { decide } = ctx`, a `({ decide }) =>` runner parameter, `ctx["decide"](…)`.
1897
+ * Receiver-agnostic like `methodCall`; reported as unresolved so the shape cannot pass
1898
+ * vacuously.
1899
+ */
1900
+ function flagIndirectDecide(node: ts.Node): void {
1901
+ let hit = false;
1902
+ if (ts.isPropertyAccessExpression(node) && node.name.text === "decide") {
1903
+ const p = node.parent;
1904
+ hit = !(ts.isCallExpression(p) && p.expression === node);
1905
+ } else if (
1906
+ ts.isElementAccessExpression(node) &&
1907
+ ts.isStringLiteralLike(node.argumentExpression) &&
1908
+ node.argumentExpression.text === "decide"
1909
+ ) {
1910
+ hit = true;
1911
+ } else if (ts.isBindingElement(node) && node.dotDotDotToken === undefined) {
1912
+ const key = node.propertyName ?? node.name;
1913
+ hit = (ts.isIdentifier(key) || ts.isStringLiteralLike(key)) && key.text === "decide";
1914
+ }
1915
+ if (!hit) return;
1916
+ // L32 — `lib({ decide: ctx.decide })` hands the decider seam to a callee a declaration may
1917
+ // cover; every other alias shape has no callee to look up.
1918
+ if (ts.isPropertyAccessExpression(node)) {
1919
+ const recv = receivingCall(node);
1920
+ if (recv !== undefined) {
1921
+ recordEscape(recv.call, "decide");
1922
+ return;
1923
+ }
1924
+ }
1925
+ unresolved.push({ expr: `decide alias ${exprText(node)}`, kind: "step", ...at });
1926
+ }
1927
+
1928
+ function visit(node: ts.Node): void {
1929
+ const run = methodCall(node, "run");
1930
+ if (run !== undefined) visitRun(run);
1931
+ const parallel = methodCall(node, "parallel");
1932
+ if (parallel !== undefined) visitParallel(parallel);
1933
+ const decide = methodCall(node, "decide");
1934
+ if (decide !== undefined) visitDecide(decide);
1935
+ else flagIndirectDecide(node);
1936
+ if (ts.isIdentifier(node)) {
1937
+ if (ctxNames.has(node.text)) flagCtxReference(node, "ctx");
1938
+ else if (ctx.members.has(node.text)) flagCtxReference(node, "method");
1939
+ }
1940
+ const suspend = methodCall(node, "suspend");
1941
+ if (suspend !== undefined) visitSuspend(suspend);
1942
+ // `ctx.sleep`/`sleepUntil` are deliberately NOT recorded. The kernel authors
1943
+ // their condition (`on: TIMER_FIRED`, `wakeAt` always set), so treating them
1944
+ // as user-authored suspend points would manufacture findings about code the
1945
+ // developer never wrote.
1946
+ ts.forEachChild(node, visit);
1947
+ }
1948
+
1949
+ visit(root);
1950
+ return {
1951
+ reach: { steps, unresolved, suspends, ...(escapes.length > 0 && { escapes }) },
1952
+ runners: ctx.runners,
1953
+ boundRunners: ctx.boundRunners,
1954
+ };
1955
+ }
1956
+
1957
+ /**
1958
+ * Analyze a source string (a flow runner / orchestration body). Feed the result
1959
+ * to `@loomwork/verify` via `resolveVerifyInput({ runnerReachByFlow })`.
1960
+ */
1961
+ export function analyzeRunnerReach(source: string, location?: string): RunnerReach {
1962
+ const sf = ts.createSourceFile("runner.ts", source, ts.ScriptTarget.ESNext, true);
1963
+ return analyzeReachNode(sf, location);
1964
+ }