@glubean/scanner 0.5.2 → 0.8.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.
@@ -14,12 +14,15 @@
14
14
  */
15
15
  import { parseSource, forEachExportedConst, unwrapExpression, stringFromExpression, objectFromExpression, objectProperty, stringProperty, propertyNameText, findPropertyCall, walk, lineOf, } from "./ast.js";
16
16
  import { resolveDataPath } from "./data-path.js";
17
- const BASE_FNS = new Set(["test", "task"]);
17
+ // `workflow` is the vNext graph authoring factory (@glubean/sdk workflow());
18
+ // its build() wraps the graph in a simple Test, so a `workflow("id")...` export
19
+ // is a runnable test for discovery purposes (S2.5).
20
+ const BASE_FNS = new Set(["test", "task", "workflow"]);
18
21
  /** Whether a factory identifier name is a recognized test/task function. */
19
22
  function isTestFnName(name, customFns) {
20
23
  if (customFns)
21
24
  return customFns.has(name);
22
- // Convention fallback: test | task | *Test | *Task (capitalized suffix).
25
+ // Convention fallback: test | task | workflow | *Test | *Task (capitalized suffix).
23
26
  return BASE_FNS.has(name) || /(?:Test|Task)$/.test(name);
24
27
  }
25
28
  /** Parse `{ id, name, tags, timeout, requires, defaultRun }` from a TestMeta object literal. */
@@ -31,6 +34,12 @@ function parseMetaObject(obj) {
31
34
  const name = stringProperty(obj, "name");
32
35
  if (name !== undefined)
33
36
  out.name = name;
37
+ // WorkflowMeta.skip is a string reason (TestMeta.skip is boolean — a string
38
+ // here only ever comes from a workflow). Carried as `deferred` so the
39
+ // upload gate can exclude skipped workflows (codex S2.6 R13 P2).
40
+ const skipReason = stringProperty(obj, "skip");
41
+ if (skipReason !== undefined)
42
+ out.deferred = skipReason;
34
43
  const tagsProp = objectProperty(obj, "tags");
35
44
  if (tagsProp) {
36
45
  const value = unwrapExpression(tagsProp.value);
@@ -64,6 +73,16 @@ function parseMetaObject(obj) {
64
73
  out.defaultRun = defaultRun;
65
74
  return out;
66
75
  }
76
+ /** `parallel: true` from a meta OBJECT — read only for data-driven (.each/
77
+ * .pick) declarations; a plain test() meta must not gain a parallel group
78
+ * (codex S2.12 R5 P2). */
79
+ function parseMetaParallel(obj) {
80
+ const parallelProp = objectProperty(obj, "parallel");
81
+ if (!parallelProp)
82
+ return false;
83
+ const value = unwrapExpression(parallelProp.value);
84
+ return value?.type === "BooleanLiteral" && value.value === true;
85
+ }
67
86
  /** Builder `.meta({...})` fields (name/tags/timeout/requires/defaultRun) from the chain. */
68
87
  function builderMeta(init) {
69
88
  const metaCall = findPropertyCall(init, "meta");
@@ -110,7 +129,611 @@ function chainHead(init) {
110
129
  }
111
130
  return node;
112
131
  }
113
- function parseTestDeclaration(decl, statement, customFns) {
132
+ /** Like `walk`, but does NOT descend into nested function scopes (arrow /
133
+ * function expressions / declarations). The factory's graph calls live at its
134
+ * top level — branch sides etc. are flagged by the outer `.branch(` on the
135
+ * chain — while runtime callbacks may declare shadowing locals (`const wf =
136
+ * ...` inside an action) that must not leak into the scan (codex S2.12 R4). */
137
+ function walkSameScope(node, cb, onNestedFn) {
138
+ cb(node);
139
+ for (const key of Object.keys(node)) {
140
+ const value = node[key];
141
+ const visit = (v) => {
142
+ if (!v || typeof v !== "object")
143
+ return;
144
+ const child = v;
145
+ if (typeof child.type !== "string")
146
+ return;
147
+ if (child.type === "ArrowFunctionExpression" ||
148
+ child.type === "FunctionExpression" ||
149
+ child.type === "FunctionDeclaration") {
150
+ onNestedFn?.(child); // nested scope — the caller decides what to do
151
+ return;
152
+ }
153
+ walkSameScope(child, cb, onNestedFn);
154
+ };
155
+ if (Array.isArray(value))
156
+ value.forEach(visit);
157
+ else
158
+ visit(value);
159
+ }
160
+ }
161
+ /** Babel emits Optional* variants for `?.` — every call/member check must
162
+ * accept both or optional chaining bypasses the gate (codex S2.13 R9 P2). */
163
+ function isCallNode(n) {
164
+ return n?.type === "CallExpression" || n?.type === "OptionalCallExpression";
165
+ }
166
+ function isMemberNode(n) {
167
+ return n?.type === "MemberExpression" || n?.type === "OptionalMemberExpression";
168
+ }
169
+ /** The branch-family + poll method names the Cloud-render gate flags. */
170
+ const BRANCH_FAMILY_METHODS = new Set(["branch", "poll", "pollAction", "switch", "route"]);
171
+ /** The full builder chain surface — used to decide whether a member call on
172
+ * a live name is the builder's OWN chain call (callbacks handled by the
173
+ * shadow-aware recursion) or an unknown method on a live CONTAINER
174
+ * (`h.makeFlow(() => b)`) whose function args must be deep-checked
175
+ * (codex S2.13 R17 P2). */
176
+ const KNOWN_BUILDER_METHODS = new Set([
177
+ "setup",
178
+ "teardown",
179
+ "meta",
180
+ "call",
181
+ "action",
182
+ "check",
183
+ "compute",
184
+ "branch",
185
+ "switch",
186
+ "route",
187
+ "poll",
188
+ "pollAction",
189
+ "use",
190
+ "group",
191
+ "build",
192
+ ]);
193
+ /** The delegated callback of a use/group call: the FIRST function-shaped
194
+ * argument (use carries it at 0, group at 1 behind the id). None found →
195
+ * undefined → the caller fails closed. */
196
+ function delegatedCallbackOf(args) {
197
+ for (const arg of args ?? []) {
198
+ const u = unwrapExpression(arg);
199
+ if (u?.type === "ArrowFunctionExpression" || u?.type === "FunctionExpression")
200
+ return u;
201
+ }
202
+ return undefined;
203
+ }
204
+ /** Chain methods that DELEGATE part of the graph to a callback argument —
205
+ * the callback is scanned like an each-factory; references fail closed.
206
+ * "use" (S2.13) and "group" (S2.14) — phase4 §4's shared rule. */
207
+ const DELEGATING_CHAIN_METHODS = new Set(["use", "group"]);
208
+ /** Descend a receiver/initializer expression to its root identifier —
209
+ * through call chains AND member access (`h.b.branch(...)` roots at `h`:
210
+ * a container holding a live builder is itself suspect — codex S2.13 R7). */
211
+ function chainRootOf(expr) {
212
+ let root = expr ? unwrapExpression(expr) : undefined;
213
+ for (;;) {
214
+ if (!root)
215
+ return root;
216
+ if (isCallNode(root)) {
217
+ const innerCallee = unwrapExpression(root.callee);
218
+ if (isMemberNode(innerCallee)) {
219
+ root = unwrapExpression(innerCallee.object);
220
+ continue;
221
+ }
222
+ return root;
223
+ }
224
+ if (isMemberNode(root)) {
225
+ // computed or not — for ROOT tracking, descending the object is the
226
+ // fail-closed direction (`h["b"].branch(...)` roots at h — codex R8).
227
+ root = unwrapExpression(root.object);
228
+ continue;
229
+ }
230
+ return root;
231
+ }
232
+ }
233
+ /** Does the expression subtree REFERENCE any live builder name? Same-scope
234
+ * only (nested functions are handled by the scanner's own recursion), and
235
+ * non-computed member/property KEY identifiers are skipped — `client.b` is a
236
+ * property name, not a reference (codex S2.13 R7). */
237
+ function containsLiveIdentifier(expr, live, opts) {
238
+ if (!expr || typeof expr !== "object")
239
+ return false;
240
+ let found = false;
241
+ const visit = (node) => {
242
+ if (found || !node || typeof node.type !== "string")
243
+ return;
244
+ // TYPE-ONLY subtrees never hold runtime references — a type argument
245
+ // named like the builder (`makeClient<b>()`) must not taint anything
246
+ // (codex S2.13 R15 P2). But TS EXPRESSION wrappers (`b as any`, `b!`)
247
+ // carry a real runtime expression — descend into it (codex R16 P2).
248
+ if (node.type.startsWith("TS")) {
249
+ const inner = node.expression;
250
+ if (inner && typeof inner.type === "string")
251
+ visit(inner);
252
+ return;
253
+ }
254
+ if (!opts?.intoFunctions &&
255
+ (node.type === "ArrowFunctionExpression" ||
256
+ node.type === "FunctionExpression" ||
257
+ node.type === "FunctionDeclaration")) {
258
+ return; // nested scope — the scanner recurses there separately
259
+ }
260
+ if (node.type === "Identifier") {
261
+ if (live.has(node.name))
262
+ found = true;
263
+ return;
264
+ }
265
+ for (const key of Object.keys(node)) {
266
+ // skip NAME positions: a non-computed member's property and a
267
+ // non-computed object-property key are labels, not references.
268
+ if (key === "typeParameters" ||
269
+ key === "typeAnnotation" ||
270
+ key === "returnType" ||
271
+ key === "superTypeParameters" ||
272
+ (isMemberNode(node) && node.computed !== true && key === "property") ||
273
+ ((node.type === "ObjectProperty" ||
274
+ node.type === "Property" ||
275
+ // `{ b() {} }` — a method NAME is a label too (codex S2.13 R11 P2)
276
+ node.type === "ObjectMethod" ||
277
+ node.type === "ClassMethod") &&
278
+ node.computed !== true &&
279
+ key === "key")) {
280
+ continue;
281
+ }
282
+ const value = node[key];
283
+ if (Array.isArray(value)) {
284
+ for (const v of value) {
285
+ if (v && typeof v.type === "string")
286
+ visit(v);
287
+ }
288
+ }
289
+ else if (value && typeof value.type === "string") {
290
+ visit(value);
291
+ }
292
+ if (found)
293
+ return;
294
+ }
295
+ };
296
+ visit(unwrapExpression(expr) ?? expr);
297
+ return found;
298
+ }
299
+ /**
300
+ * The ONE shared rule (phase4 §4): any construct handing graph authorship to
301
+ * a callback — each/pick factories, `.use(` fragments, (S2.14) `.group(`
302
+ * bodies — is scanned with the binding-aware live/everLive dataflow
303
+ * (codex S2.12 R2/R4/R10/R12/R13/R14/R15/R18) when INLINE.
304
+ *
305
+ * Returns `true` (branch-family call rooted at the builder found), `false`
306
+ * (inline and clean), or `undefined` (not an inspectable inline function —
307
+ * the caller fails CLOSED).
308
+ */
309
+ function scanCallbackForBranchFamily(fn) {
310
+ if (!fn ||
311
+ (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression")) {
312
+ return undefined;
313
+ }
314
+ // TS `this` parameters are erased at runtime but occupy params[0] in the
315
+ // AST (`function (this: void, b) {...}`) — skip them or the scan tracks
316
+ // "this" instead of the builder (codex S2.13 R21 P2).
317
+ const params = (fn.params ?? []).filter((p) => !(p.type === "Identifier" && p.name === "this"));
318
+ const builderParam = params[0]?.type === "Identifier" ? params[0].name : undefined;
319
+ if (!builderParam)
320
+ return undefined; // destructured/absent — cannot root the dataflow
321
+ // A default-parameter INITIALIZER can author the delegated chain before the
322
+ // body (`(b, r = b.branch(...)) => r`) — the body-only scan would miss it
323
+ // (codex S2.13 R5 P2). Only initializers can EXECUTE code, so fail closed
324
+ // exactly on AssignmentPattern (incl. nested inside destructuring); a plain
325
+ // destructured row param (`(wf, { region }) => ...`) is pure binding and
326
+ // stays scannable (codex S2.13 R6 P2).
327
+ for (const param of (params ?? []).slice(1)) {
328
+ let hasInitializer = false;
329
+ walk(param, (n) => {
330
+ if (n.type === "AssignmentPattern")
331
+ hasInitializer = true;
332
+ });
333
+ if (hasInitializer)
334
+ return undefined;
335
+ }
336
+ let found = false;
337
+ // Per-scope dataflow: every scope folds its own bindings into the inherited
338
+ // builder-name set in SOURCE ORDER, detects calls against the live set, then
339
+ // recurses into nested functions with the everLive snapshot (closures
340
+ // capture bindings — see the S2.12 R14/R15 commit messages for the cases).
341
+ const scanScope = (scopeBody, inherited, inheritedDirect) => {
342
+ const live = new Set(inherited);
343
+ const everLive = new Set(inherited);
344
+ // DIRECT builder aliases (the chain itself) vs tainted CONTAINERS — the
345
+ // own-chain callback exemption may only apply to direct aliases: a
346
+ // container can carry a foreign function under a builder-method name
347
+ // (`{ b, compute: makeFlow }` — codex S2.13 R18 P2).
348
+ const liveDirect = new Set(inheritedDirect);
349
+ const everDirect = new Set(inheritedDirect);
350
+ // All names BOUND by a target — an Identifier, or every binding inside a
351
+ // destructuring pattern (`const { b: x } = h` binds x — codex S2.13 R10).
352
+ const boundNames = (target) => {
353
+ if (!target)
354
+ return [];
355
+ if (target.type === "Identifier")
356
+ return [target.name];
357
+ const names = [];
358
+ const visit = (node) => {
359
+ if (!node || typeof node.type !== "string")
360
+ return;
361
+ if (node.type === "Identifier") {
362
+ names.push(node.name);
363
+ return;
364
+ }
365
+ if (node.type === "ObjectPattern") {
366
+ for (const propNode of node.properties ?? []) {
367
+ if (propNode.type === "ObjectProperty" || propNode.type === "Property") {
368
+ visit(propNode.value); // the BINDING side, never the key
369
+ }
370
+ else if (propNode.type === "RestElement") {
371
+ visit(propNode.argument);
372
+ }
373
+ }
374
+ return;
375
+ }
376
+ if (node.type === "ArrayPattern") {
377
+ for (const el of node.elements ?? []) {
378
+ if (el)
379
+ visit(el);
380
+ }
381
+ return;
382
+ }
383
+ if (node.type === "RestElement")
384
+ visit(node.argument);
385
+ if (node.type === "AssignmentPattern")
386
+ visit(node.left);
387
+ };
388
+ visit(target);
389
+ return names;
390
+ };
391
+ const bind = (nameNode, valueExpr, isDeclaration) => {
392
+ const names = boundNames(nameNode);
393
+ if (names.length === 0)
394
+ return;
395
+ // A PATTERN-INTERNAL default can smuggle the builder into a binding
396
+ // even when the RHS is clean: `const { x = b } = {}` (codex S2.13 R13).
397
+ let patternDefaultIsLive = false;
398
+ if (nameNode && nameNode.type !== "Identifier") {
399
+ walk(nameNode, (n) => {
400
+ if (n.type === "AssignmentPattern" &&
401
+ containsLiveIdentifier(n.right, live)) {
402
+ patternDefaultIsLive = true;
403
+ }
404
+ });
405
+ }
406
+ // A value that CONTAINS a live builder reference (chain root, or a
407
+ // wrapper like `{ b }` — codex S2.13 R7) makes every bound name
408
+ // suspect: alias them all (fail-closed; destructuring from a live
409
+ // container — codex S2.13 R10).
410
+ if (patternDefaultIsLive || containsLiveIdentifier(valueExpr, live)) {
411
+ // A plain Identifier bound to a chain rooted at a DIRECT alias is
412
+ // itself direct (`const c = b.compute(...)`); anything else (wrapper
413
+ // objects, destructured pulls, pattern defaults) is tainted-only.
414
+ const valueRoot = chainRootOf(valueExpr);
415
+ const isDirectChain = nameNode?.type === "Identifier" &&
416
+ valueRoot?.type === "Identifier" &&
417
+ liveDirect.has(valueRoot.name);
418
+ for (const name of names) {
419
+ live.add(name);
420
+ everLive.add(name);
421
+ if (isDirectChain) {
422
+ liveDirect.add(name);
423
+ everDirect.add(name);
424
+ }
425
+ }
426
+ }
427
+ else if (valueExpr !== undefined) {
428
+ for (const name of names) {
429
+ live.delete(name);
430
+ liveDirect.delete(name);
431
+ if (isDeclaration) {
432
+ everLive.delete(name);
433
+ everDirect.delete(name);
434
+ }
435
+ }
436
+ }
437
+ };
438
+ const deferredFns = [];
439
+ // Block-aware traversal (codex S2.13 R9 P2): a block-scoped shadow
440
+ // (`{ const b = client; }`) must not delete the outer builder for the
441
+ // REST of the function. On block exit the pre-block names are UNIONED
442
+ // back in: block-local deletions are undone, block-born aliases leak
443
+ // outward (fail-closed direction; a block-local `b = client` assignment
444
+ // being undone is the same conservative trade).
445
+ const handleNode = (n) => {
446
+ if (n.type === "VariableDeclarator") {
447
+ bind(n.id, n.init, true);
448
+ return;
449
+ }
450
+ if (n.type === "AssignmentExpression" &&
451
+ (n.operator === "=" ||
452
+ // logical assignments MAY assign at runtime — taint like plain
453
+ // `=` on the live side; never delete on the foreign side (the
454
+ // old value may survive) — codex S2.13 R16 P2.
455
+ n.operator === "??=" ||
456
+ n.operator === "||=" ||
457
+ n.operator === "&&=")) {
458
+ const conditional = n.operator !== "=";
459
+ const left = unwrapExpression(n.left);
460
+ // A MEMBER write storing a live builder taints the container:
461
+ // `h.b = b; h.b.branch(...)` (codex S2.13 R14 P2).
462
+ if (left && isMemberNode(left)) {
463
+ if (containsLiveIdentifier(n.right, live)) {
464
+ const root = chainRootOf(left);
465
+ if (root?.type === "Identifier") {
466
+ live.add(root.name);
467
+ everLive.add(root.name);
468
+ }
469
+ }
470
+ return;
471
+ }
472
+ if (conditional) {
473
+ // taint-only path: add aliases when the RHS is live, never delete
474
+ // (and never as DIRECT — the assignment may not happen)
475
+ if (left?.type === "Identifier" && containsLiveIdentifier(n.right, live)) {
476
+ live.add(left.name);
477
+ everLive.add(left.name);
478
+ }
479
+ return;
480
+ }
481
+ bind(left, n.right, false);
482
+ return;
483
+ }
484
+ if (found)
485
+ return;
486
+ // CONSTRUCTOR delegation: `new Fragment(b)` / `new X(() => b)` hands
487
+ // the builder (or a capturing closure) to uninspectable code — same
488
+ // fail-closed rule as call arguments (codex S2.13 R17 P2).
489
+ if (n.type === "NewExpression") {
490
+ for (const arg of n.arguments ?? []) {
491
+ const u = unwrapExpression(arg) ?? arg;
492
+ const isFnArg = u.type === "ArrowFunctionExpression" || u.type === "FunctionExpression";
493
+ if (containsLiveIdentifier(arg, live, { intoFunctions: isFnArg })) {
494
+ found = true;
495
+ return;
496
+ }
497
+ }
498
+ return;
499
+ }
500
+ if (!isCallNode(n))
501
+ return;
502
+ const callee = unwrapExpression(n.callee);
503
+ // An EXTRACTED method alias (`const branch = b.branch; branch(...)`)
504
+ // calls a live alias directly — no member access to inspect; fail
505
+ // closed (codex S2.13 R13 P2).
506
+ if (callee?.type === "Identifier" && live.has(callee.name)) {
507
+ found = true;
508
+ return;
509
+ }
510
+ let methodName;
511
+ if (callee && isMemberNode(callee)) {
512
+ const prop = unwrapExpression(callee.property);
513
+ if (callee.computed !== true && prop?.type === "Identifier") {
514
+ methodName = prop.name;
515
+ }
516
+ else if (callee.computed === true && prop?.type === "StringLiteral") {
517
+ // bracket syntax with a literal name resolves statically
518
+ methodName = prop.value;
519
+ }
520
+ else if (callee.computed === true &&
521
+ containsLiveIdentifier(callee.object, live)) {
522
+ // a DYNAMIC method on a live builder cannot be resolved — fail
523
+ // closed (codex S2.13 R8 P2).
524
+ found = true;
525
+ return;
526
+ }
527
+ }
528
+ // Nested delegation (`b.use(innerFragment)`): recurse into inline
529
+ // callbacks; references fail closed — BEFORE the generic
530
+ // argument-delegation check so an inline-and-clean fragment doesn't
531
+ // false-positive on its own builder argument.
532
+ if (methodName !== undefined &&
533
+ DELEGATING_CHAIN_METHODS.has(methodName) &&
534
+ (() => {
535
+ const root = chainRootOf(callee.object);
536
+ // DIRECT aliases only — `h.use(cb)` on a tainted container is a
537
+ // foreign method wearing the name (codex S2.13 R19 P2); it falls
538
+ // through to the foreign-call deep checks below.
539
+ return root?.type === "Identifier" && liveDirect.has(root.name);
540
+ })()) {
541
+ const cb = delegatedCallbackOf(n.arguments);
542
+ const verdict = scanCallbackForBranchFamily(cb);
543
+ if (verdict !== false)
544
+ found = true;
545
+ return;
546
+ }
547
+ // DELEGATION fails closed (codex S2.12 R18 P2): passing a live
548
+ // builder to any call — bare (`makeFlow(wf)`) or WRAPPED
549
+ // (`makeFlow({ wf })`, codex S2.13 R7) — hands the graph to code
550
+ // this scan cannot see. Nested-function arguments are exempt (the
551
+ // scanner recurses into them itself).
552
+ // Whose call is this? A live builder's OWN chain call (`b.action("x",
553
+ // cb)`) hands its callbacks to the runtime — the recursion scans them
554
+ // shadow-aware. A FOREIGN call's function argument capturing the
555
+ // builder (`makeFlow(() => b)`) hands authoring capability to code we
556
+ // can't see — deep-check those (codex S2.13 R15 P2; shadow-unaware by
557
+ // design, fail-closed).
558
+ const calleeRootForArgs = callee && isMemberNode(callee)
559
+ ? chainRootOf(callee.object)
560
+ : undefined;
561
+ // The exemption holds only for the builder's OWN chain surface — an
562
+ // unknown method on a live CONTAINER (`h.makeFlow(() => b)`) is a
563
+ // foreign call wearing a live root (codex S2.13 R17 P2).
564
+ const calleeIsLiveChain = calleeRootForArgs?.type === "Identifier" &&
565
+ liveDirect.has(calleeRootForArgs.name) &&
566
+ methodName !== undefined &&
567
+ KNOWN_BUILDER_METHODS.has(methodName);
568
+ for (const arg of n.arguments ?? []) {
569
+ const unwrappedArg = unwrapExpression(arg) ?? arg;
570
+ const isFnArg = unwrappedArg.type === "ArrowFunctionExpression" ||
571
+ unwrappedArg.type === "FunctionExpression";
572
+ if (containsLiveIdentifier(arg, live, {
573
+ intoFunctions: isFnArg && !calleeIsLiveChain,
574
+ })) {
575
+ found = true;
576
+ return;
577
+ }
578
+ }
579
+ // A member call on a LIVE root that is NOT the builder's own chain
580
+ // surface hands the receiver — and the builder inside it — to foreign
581
+ // code as `this` (`h.use(cb)` reaching b via this.b — codex S2.13 R19
582
+ // P2). Fail closed.
583
+ if (calleeRootForArgs?.type === "Identifier" &&
584
+ live.has(calleeRootForArgs.name) &&
585
+ !calleeIsLiveChain) {
586
+ found = true;
587
+ return;
588
+ }
589
+ if (methodName === undefined || !BRANCH_FAMILY_METHODS.has(methodName))
590
+ return;
591
+ const root = chainRootOf(callee.object);
592
+ if (root?.type === "Identifier") {
593
+ if (live.has(root.name))
594
+ found = true;
595
+ return;
596
+ }
597
+ // The receiver didn't root at an identifier — a conditional/sequence
598
+ // or other expression (`(cond ? b : other).branch(...)`). If it
599
+ // CONTAINS a live builder anywhere, fail closed (codex S2.13 R14 P2).
600
+ if (containsLiveIdentifier(callee.object, live)) {
601
+ found = true;
602
+ }
603
+ };
604
+ const visitTree = (node) => {
605
+ if (node.type === "ArrowFunctionExpression" ||
606
+ node.type === "FunctionExpression" ||
607
+ node.type === "FunctionDeclaration") {
608
+ deferredFns.push({ fn: node, excluded: new Set() });
609
+ return;
610
+ }
611
+ const isBlock = node.type === "BlockStatement" && node !== scopeBody;
612
+ const liveSnap = isBlock ? new Set(live) : undefined;
613
+ const everSnap = isBlock ? new Set(everLive) : undefined;
614
+ const directSnap = isBlock ? new Set(liveDirect) : undefined;
615
+ const everDirectSnap = isBlock ? new Set(everDirect) : undefined;
616
+ const deferredStart = isBlock ? deferredFns.length : 0;
617
+ handleNode(node);
618
+ for (const key of Object.keys(node)) {
619
+ const value = node[key];
620
+ const visitChild = (v) => {
621
+ if (!v || typeof v !== "object")
622
+ return;
623
+ const child = v;
624
+ if (typeof child.type !== "string")
625
+ return;
626
+ visitTree(child);
627
+ };
628
+ if (Array.isArray(value))
629
+ value.forEach(visitChild);
630
+ else
631
+ visitChild(value);
632
+ }
633
+ if (isBlock) {
634
+ // Names a block-local DECLARATION shadowed (present before, deleted
635
+ // by a declaration inside): closures defined IN this block captured
636
+ // the shadow binding, not the outer builder — scanning them against
637
+ // the restored outer names would false-positive a linear workflow
638
+ // (codex S2.13 R20 P2). Record exclusions before the union restore.
639
+ // Late-ASSIGNED aliases (R15) are unaffected: assignments never
640
+ // delete from everLive, so they don't appear here.
641
+ for (const name of everSnap) {
642
+ if (!everLive.has(name)) {
643
+ for (let i = deferredStart; i < deferredFns.length; i++) {
644
+ deferredFns[i].excluded.add(name);
645
+ }
646
+ }
647
+ }
648
+ for (const n of liveSnap)
649
+ live.add(n);
650
+ for (const n of everSnap)
651
+ everLive.add(n);
652
+ for (const n of directSnap)
653
+ liveDirect.add(n);
654
+ for (const n of everDirectSnap)
655
+ everDirect.add(n);
656
+ }
657
+ };
658
+ visitTree(scopeBody);
659
+ const fnQueue = [...deferredFns];
660
+ while (fnQueue.length > 0) {
661
+ if (found)
662
+ return;
663
+ const { fn: fnNode, excluded } = fnQueue.shift();
664
+ const childInherited = new Set(everLive);
665
+ const childDirect = new Set(everDirect);
666
+ for (const name of excluded) {
667
+ childInherited.delete(name);
668
+ childDirect.delete(name);
669
+ }
670
+ for (const param of fnNode.params ?? []) {
671
+ // A default-parameter INITIALIZER referencing a live builder executes
672
+ // at call time with the closure's bindings (`const make = (x =
673
+ // b.branch(...)) => x`) — the body-only recursion never sees it, and
674
+ // the param may BECOME the builder (`(x = b) => x.branch()`).
675
+ // Initializers can hide ANYWHERE in a destructuring pattern
676
+ // (`({ x = b.branch(...) } = {}) => x` — codex S2.13 R11 P2), so the
677
+ // WHOLE param subtree is searched. Fail closed on the reference.
678
+ // A CLOSURE inside an initializer (`(x = () => b.branch(...)) =>`)
679
+ // is invisible to containsLiveIdentifier (it skips nested fns) —
680
+ // queue it for the same recursive scan as any other nested function
681
+ // (codex S2.13 R12 P2).
682
+ let liveInitializer = false;
683
+ walk(param, (n) => {
684
+ if (n.type === "AssignmentPattern" &&
685
+ containsLiveIdentifier(n.right, everLive)) {
686
+ liveInitializer = true;
687
+ }
688
+ if (n.type === "ArrowFunctionExpression" || n.type === "FunctionExpression") {
689
+ fnQueue.push({ fn: n, excluded: new Set(excluded) });
690
+ }
691
+ });
692
+ if (liveInitializer) {
693
+ found = true;
694
+ return;
695
+ }
696
+ for (const name of boundNames(param)) {
697
+ childInherited.delete(name);
698
+ childDirect.delete(name);
699
+ }
700
+ }
701
+ scanScope(fnNode.body, childInherited, childDirect);
702
+ }
703
+ };
704
+ scanScope(fn.body, new Set([builderParam]), new Set([builderParam]));
705
+ return found;
706
+ }
707
+ /**
708
+ * Local names bound to the SDK's `workflow` factory via named-import aliases
709
+ * (`import { workflow as journeyTest } ...`). The literal `workflow` is always
710
+ * included. Without this, an alias that happens to satisfy the `*Test`/`*Task`
711
+ * convention (or customFns) would be classified as a plain test and bypass the
712
+ * workflow marker + the --upload branch/poll gate (codex S2.6 R12 P2).
713
+ */
714
+ function collectWorkflowAliases(source) {
715
+ const aliases = new Set(["workflow"]);
716
+ const body = source.program.body ?? [];
717
+ for (const stmt of body) {
718
+ if (stmt.type !== "ImportDeclaration")
719
+ continue;
720
+ const specifiers = stmt.specifiers ?? [];
721
+ for (const spec of specifiers) {
722
+ if (spec.type !== "ImportSpecifier")
723
+ continue;
724
+ const imported = spec.imported;
725
+ const importedName = imported?.type === "Identifier"
726
+ ? imported.name
727
+ : imported?.value; // string import names
728
+ const local = spec.local;
729
+ if (importedName === "workflow" && local?.type === "Identifier") {
730
+ aliases.add(local.name);
731
+ }
732
+ }
733
+ }
734
+ return aliases;
735
+ }
736
+ function parseTestDeclaration(decl, statement, customFns, workflowAliases) {
114
737
  const exportName = decl.id?.name;
115
738
  if (!exportName)
116
739
  return undefined;
@@ -152,8 +775,13 @@ function parseTestDeclaration(decl, statement, customFns) {
152
775
  else {
153
776
  return undefined;
154
777
  }
155
- if (!factoryName || !isTestFnName(factoryName, customFns))
778
+ // A workflow import alias is always accepted (even when it matches no
779
+ // test-name convention) and always classified as a workflow — the marker
780
+ // must not depend on what the alias happens to look like (codex S2.6 R12).
781
+ const isWorkflowFactory = workflowAliases?.has(factoryName) ?? factoryName === "workflow";
782
+ if (!factoryName || (!isTestFnName(factoryName, customFns) && !isWorkflowFactory)) {
156
783
  return undefined;
784
+ }
157
785
  // Resolve id + inline meta from the first argument (string id or TestMeta obj).
158
786
  let fields = {};
159
787
  const metaObj = objectFromExpression(metaArg);
@@ -177,10 +805,15 @@ function parseTestDeclaration(decl, statement, customFns) {
177
805
  fields.requires = bMeta.requires;
178
806
  if (fields.defaultRun === undefined && bMeta.defaultRun !== undefined)
179
807
  fields.defaultRun = bMeta.defaultRun;
808
+ if (fields.deferred === undefined && bMeta.deferred !== undefined)
809
+ fields.deferred = bMeta.deferred;
180
810
  if (fields.id === undefined)
181
811
  return undefined;
182
812
  // `.each(data, { parallel: true })`.
183
- let parallel = false;
813
+ // meta-object `parallel` counts only for data-driven declarations
814
+ // (workflow.each puts it there); legacy each-args form parsed below.
815
+ const metaObjForParallel = (variant === "each" || variant === "pick") && metaArg ? objectFromExpression(metaArg) : undefined;
816
+ let parallel = metaObjForParallel ? parseMetaParallel(metaObjForParallel) : false;
184
817
  if (variant === "each" && eachArgs && eachArgs.length > 1) {
185
818
  const optsObj = objectFromExpression(eachArgs[1]);
186
819
  const parallelProp = optsObj ? objectProperty(optsObj, "parallel") : undefined;
@@ -211,6 +844,20 @@ function parseTestDeclaration(decl, statement, customFns) {
211
844
  result.steps = steps;
212
845
  if (parallel)
213
846
  result.parallel = true;
847
+ // vNext workflow exports get a static marker so downstream consumers can
848
+ // classify them as graph orchestrators ("flow"-kind runnables) without
849
+ // importing the file, plus an AST-level branch/poll flag so the --upload
850
+ // gate fails closed for .test.ts workflows the runtime extractor can never
851
+ // safely inspect (codex S2.6 R10 P2).
852
+ if (isWorkflowFactory) {
853
+ result.workflow = true;
854
+ // WorkflowMeta.skip (string reason) — surfaced so the upload gate can
855
+ // exclude skipped workflows like deferred flows (codex S2.6 R13 P2).
856
+ // Only emitted for workflows: TestMeta.skip is boolean, so a string skip
857
+ // never legitimately appears on a plain test.
858
+ if (fields.deferred !== undefined)
859
+ result.deferred = fields.deferred;
860
+ }
214
861
  return result;
215
862
  }
216
863
  /**
@@ -229,9 +876,10 @@ export function extractFromSource(content, customFns) {
229
876
  return [];
230
877
  }
231
878
  const fns = customFns && customFns.length > 0 ? new Set([...BASE_FNS, ...customFns]) : undefined;
879
+ const workflowAliases = collectWorkflowAliases(source);
232
880
  const results = [];
233
881
  forEachExportedConst(source, (statement, declaration) => {
234
- const meta = parseTestDeclaration(declaration, statement, fns);
882
+ const meta = parseTestDeclaration(declaration, statement, fns, workflowAliases);
235
883
  if (meta)
236
884
  results.push(meta);
237
885
  });
@@ -320,15 +968,35 @@ function readCases(casesObj) {
320
968
  const key = propertyNameText(property);
321
969
  if (key === undefined)
322
970
  continue;
323
- const body = objectFromExpression(property.value);
971
+ // `evt: inboundCase({...})` (I2 authoring helper) — unwrap to the spec
972
+ // argument so description etc. stay readable, and mark the direction so
973
+ // static-fallback discovery never advertises a non-runnable case.
974
+ const rawValue = unwrapExpression(property.value);
975
+ let inbound = false;
976
+ let bodySource = rawValue;
977
+ if (rawValue?.type === "CallExpression") {
978
+ const callee = unwrapExpression(rawValue.callee);
979
+ if (callee?.type === "Identifier" && callee.name === "inboundCase") {
980
+ inbound = true;
981
+ bodySource = rawValue.arguments?.[0];
982
+ }
983
+ }
984
+ const body = objectFromExpression(bodySource);
324
985
  if (!body) {
325
986
  // Reference / shorthand case value (`cases: { ok }` or `ok: sharedCase`):
326
987
  // the case body isn't an inline object, so per-case fields can't be read,
327
988
  // but the KEY is still a real case — preserve it (VSCode discovery needs it).
328
- out.push({ key, line: lineOf(property.key ?? property.value) });
989
+ out.push({
990
+ key,
991
+ line: lineOf(property.key ?? property.value),
992
+ ...(inbound ? { direction: "inbound" } : {}),
993
+ });
329
994
  continue;
330
995
  }
331
996
  const meta = { key, line: lineOf(property.value) };
997
+ if (inbound || stringProperty(body, "direction") === "inbound") {
998
+ meta.direction = "inbound";
999
+ }
332
1000
  const description = stringProperty(body, "description");
333
1001
  if (description !== undefined)
334
1002
  meta.description = description;
@@ -412,85 +1080,6 @@ export function extractContractCases(content, opts) {
412
1080
  });
413
1081
  return results;
414
1082
  }
415
- /**
416
- * Structural (marker-free) flow extraction: every `export const X = <fn>.flow("id")…`
417
- * → `FlowStaticMeta`. Replaces vscode's `// @flow`-gated extractMarkedFlows — AST
418
- * recognizes the `.flow("id")` call shape directly, so no magic comment is needed
419
- * (aligns vscode discovery with the CLI, which never required markers). Returns []
420
- * on parse error.
421
- */
422
- export function extractFlows(content) {
423
- let source;
424
- try {
425
- source = parseSource(content);
426
- }
427
- catch {
428
- return [];
429
- }
430
- const results = [];
431
- forEachExportedConst(source, (statement, declaration) => {
432
- const exportName = declaration.id?.name;
433
- const init = declaration.init;
434
- if (!exportName || !init)
435
- return;
436
- const flowCall = findFlowCall(init);
437
- if (!flowCall)
438
- return;
439
- // `flow(idOrMeta: string | FlowMeta)` — string id or `{ id, ... }`. Runtime
440
- // honors the object's `id`; its `skip` is IGNORED at runtime (skip is only
441
- // applied from a chained `.meta({ skip })`), so we read skip only from there.
442
- const flowArg = flowCall.arguments?.[0];
443
- const flowMetaObj = objectFromExpression(flowArg);
444
- const flowId = stringFromExpression(flowArg) ?? (flowMetaObj && stringProperty(flowMetaObj, "id"));
445
- if (!flowId)
446
- return;
447
- const metaCall = findPropertyCall(init, "meta");
448
- const metaObj = metaCall
449
- ? objectFromExpression(metaCall.arguments?.[0])
450
- : undefined;
451
- const skip = metaObj ? stringProperty(metaObj, "skip") : undefined;
452
- const meta = { exportName, line: lineOf(statement), flowId };
453
- if (skip !== undefined)
454
- meta.skip = skip;
455
- results.push(meta);
456
- });
457
- return results;
458
- }
459
- /**
460
- * Find the Glubean flow call in a chain: `contract.flow(...)` — a `.flow` member
461
- * on the `contract` namespace. Does NOT match an arbitrary `.flow(...)` on some
462
- * other object (`otherLib.flow(...)`) nor a bare `flow(...)`, which the runtime
463
- * would not recognize as a flow.
464
- *
465
- * Like `findContractCall` / the former regex, this keys off the literal name
466
- * `contract` and does not resolve `import { contract as x }` aliases — a known,
467
- * consistent limitation across the contract + flow static extractors (real code
468
- * uses `contract.flow(...)`).
469
- */
470
- function findFlowCall(init) {
471
- let node = unwrapExpression(init);
472
- while (node && node.type === "CallExpression") {
473
- const callee = unwrapExpression(node.callee);
474
- if (!callee)
475
- break;
476
- if (callee.type === "MemberExpression" && callee.computed !== true) {
477
- const object = callee.object;
478
- const property = callee.property;
479
- if (property.type === "Identifier" && property.name === "flow" &&
480
- object.type === "Identifier" && object.name === "contract") {
481
- return node; // contract.flow("id")
482
- }
483
- node = unwrapExpression(object);
484
- }
485
- else if (callee.type === "CallExpression") {
486
- node = callee;
487
- }
488
- else {
489
- break;
490
- }
491
- }
492
- return undefined;
493
- }
494
1083
  /** Classify a data-loader call (`fromDir(...)`, `fromYaml.map(...)`, …) → {type, raw path}. */
495
1084
  function classifyLoader(call) {
496
1085
  const callee = unwrapExpression(call.callee);