waygraph 0.15.21 → 0.15.23

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.
@@ -0,0 +1,490 @@
1
+ // Moved verbatim from the former CHAIN_RUNNER_SCRIPT template string in cli.ts (see src/ARCHITECTURE.md).
2
+ // Runs inside the target project's own waygraph copy - keep it dependency-light and self-contained.
3
+ import { ANSI, ansiPaint, demoFixture, demoHighlight, demoLog } from "./demo-log.js";
4
+ import { playSwipeTrail } from "./cursor.js";
5
+ import { advanceTodoDock, formatHighlightCaption, normalizeHighlightSize, normalizeHighlightTone, normalizeHighlightWeight, normalizeTodos, resolveFixtureDwellMs } from "../highlights.js";
6
+ import { join } from "node:path";
7
+
8
+ /**
9
+ * Scroll a locator into view for demo theater.
10
+ * Prefer CSS smooth scroll + scrollend wait. Do NOT call Playwright's
11
+ * scrollIntoViewIfNeeded first - that jumps instantly and makes the
12
+ * following smooth scroll a no-op (looks like a teleport), especially
13
+ * noticeable under --fast when dwell is shorter.
14
+ * Pass opts.instant=true only for skipTheater / blitz.
15
+ */
16
+ export async function ensureLocatorInView(locator, opts) {
17
+ const instant = !!(opts && opts.instant);
18
+ try {
19
+ if (instant) {
20
+ await locator.scrollIntoViewIfNeeded().catch(() => {});
21
+ return;
22
+ }
23
+ // Touch mode: if the target is off-screen, show a swipe trail in the
24
+ // finger direction that would scroll it into view (not device morph).
25
+ const scrollPlan = await locator
26
+ .evaluate((el) => {
27
+ if (!el || typeof el.getBoundingClientRect !== "function") return null;
28
+ const touch =
29
+ window.__wgDeviceTouch === true ||
30
+ document.documentElement.dataset.wgTouch === "1" ||
31
+ window.__wgDevicePreset === "mobile" ||
32
+ window.__wgDevicePreset === "tablet";
33
+ if (!touch) return null;
34
+ const r = el.getBoundingClientRect();
35
+ const vw = window.innerWidth || 390;
36
+ const vh = window.innerHeight || 844;
37
+ const margin = 48;
38
+ const below = r.top > vh - margin;
39
+ const above = r.bottom < margin;
40
+ const right = r.left > vw - margin;
41
+ const left = r.right < margin;
42
+ if (!below && !above && !left && !right) return null;
43
+ // Finger direction to reveal the target (swipe up => content moves up).
44
+ let dir = "up";
45
+ if (below) dir = "up";
46
+ else if (above) dir = "down";
47
+ else if (right) dir = "left";
48
+ else if (left) dir = "right";
49
+ return { dir, hint: "scroll" };
50
+ })
51
+ .catch(() => null);
52
+ if (scrollPlan && scrollPlan.dir) {
53
+ demoLog(" swipe trail dir=" + scrollPlan.dir + " hint=scroll");
54
+ await playSwipeTrail(locator.page(), {
55
+ dir: scrollPlan.dir,
56
+ label: "swipe \u00b7 scroll",
57
+ hint: "scroll",
58
+ ms: Number(process.env.WAYGRAPH_SWIPE_MS || 650),
59
+ });
60
+ }
61
+ await locator
62
+ .evaluate(async (el) => {
63
+ if (!el || typeof el.scrollIntoView !== "function") return;
64
+ const waitScroll = (node, ms) =>
65
+ new Promise((resolve) => {
66
+ let done = false;
67
+ const finish = () => {
68
+ if (done) return;
69
+ done = true;
70
+ try {
71
+ node.removeEventListener("scrollend", finish);
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ clearTimeout(t);
76
+ resolve();
77
+ };
78
+ const t = setTimeout(finish, ms);
79
+ try {
80
+ node.addEventListener("scrollend", finish, { once: true });
81
+ } catch {
82
+ /* scrollend unsupported - timeout only */
83
+ }
84
+ });
85
+ try {
86
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" });
87
+ } catch {
88
+ try {
89
+ el.scrollIntoView({ block: "center", inline: "center" });
90
+ } catch {
91
+ try {
92
+ el.scrollIntoView(true);
93
+ } catch {
94
+ /* ignore */
95
+ }
96
+ }
97
+ }
98
+ await waitScroll(document.scrollingElement || document.documentElement, 650);
99
+ let p = el.parentElement;
100
+ while (p && p !== document.documentElement && p !== document.body) {
101
+ const st = getComputedStyle(p);
102
+ const ox = st.overflowX;
103
+ const oy = st.overflowY;
104
+ if (/(auto|scroll|overlay)/.test(ox) || /(auto|scroll|overlay)/.test(oy)) {
105
+ const er = el.getBoundingClientRect();
106
+ const pr = p.getBoundingClientRect();
107
+ if (er.left < pr.left || er.right > pr.right || er.top < pr.top || er.bottom > pr.bottom) {
108
+ const left = p.scrollLeft + (er.left + er.width / 2 - (pr.left + pr.width / 2));
109
+ const top = p.scrollTop + (er.top + er.height / 2 - (pr.top + pr.height / 2));
110
+ try {
111
+ p.scrollTo({ left, top, behavior: "smooth" });
112
+ } catch {
113
+ p.scrollLeft = left;
114
+ p.scrollTop = top;
115
+ }
116
+ await waitScroll(p, 650);
117
+ }
118
+ }
119
+ p = p.parentElement;
120
+ }
121
+ })
122
+ .catch(() => {});
123
+ } catch {
124
+ /* best-effort */
125
+ }
126
+ }
127
+
128
+
129
+ export async function ensureSelectorInView(page, selector, opts) {
130
+ await ensureLocatorInView(page.locator(selector).first(), opts);
131
+ }
132
+
133
+
134
+ export async function applyHighlightZoom(page, selector, zoom, zoomOut) {
135
+ const z = Number(zoom);
136
+ if (!Number.isFinite(z) || z <= 1.001) {
137
+ await page.evaluate(() => {
138
+ window.__wgZoomOutOnHide = true;
139
+ if (window.__wgClearZoom) window.__wgClearZoom();
140
+ }).catch(() => {});
141
+ return;
142
+ }
143
+ demoFixture("zoom", "ctx/ring zoom(" + z + ") zoomOut=" + (zoomOut !== false), selector);
144
+ await page
145
+ .evaluate(
146
+ ({ sel, scale, zoomOut }) => {
147
+ // false = keep camera after ring hide (ctx.zoomOut(false) / ring.zoomOut).
148
+ window.__wgZoomOutOnHide = zoomOut !== false;
149
+ if (window.__wgApplyZoom) window.__wgApplyZoom(sel, scale);
150
+ },
151
+ { sel: selector, scale: z, zoomOut: zoomOut !== false },
152
+ )
153
+ .catch(() => {});
154
+ }
155
+
156
+
157
+ async function applyHighlightFocus(page, box, focus) {
158
+ if (!focus || !box) {
159
+ await page
160
+ .evaluate(() => {
161
+ if (window.__wgClearFocus) window.__wgClearFocus();
162
+ })
163
+ .catch(() => {});
164
+ return;
165
+ }
166
+ demoFixture("focus", "ctx/ring focus(true)", {
167
+ x: Math.round(box.x),
168
+ y: Math.round(box.y),
169
+ w: Math.round(box.width),
170
+ h: Math.round(box.height),
171
+ });
172
+ await page
173
+ .evaluate((b) => {
174
+ if (window.__wgApplyFocus) window.__wgApplyFocus(b);
175
+ }, box)
176
+ .catch(() => {});
177
+ }
178
+
179
+
180
+ export async function cycleHighlightRings(page, highlights, gatesFast, opts) {
181
+ const list = highlights || [];
182
+ // defaultHoldMs: when set (fail path), use instead of legacy 900/200 so BUG
183
+ // rings stay visible ~2s before the error panel (PIA stubOnError RFC).
184
+ const defaultHoldMs = opts && opts.defaultHoldMs != null ? opts.defaultHoldMs : null;
185
+ const pace = opts && opts.pace !== undefined ? opts.pace : undefined;
186
+ let dock = opts && opts.todoDock;
187
+ const advanceTodos = !(opts && opts.advanceTodos === false);
188
+ // Push stubAfter/before dock before the first ring so Method after-panels
189
+ // show the updated checklist immediately (not only after the ring cycle).
190
+ if (dock) {
191
+ await page
192
+ .evaluate((d) => {
193
+ if (window.__wgSyncTodos) window.__wgSyncTodos({ sync: "set", dock: d });
194
+ }, dock)
195
+ .catch(() => {});
196
+ } else if (opts && opts.todoSync === "clear") {
197
+ await page
198
+ .evaluate(() => {
199
+ if (window.__wgSyncTodos) window.__wgSyncTodos({ sync: "clear" });
200
+ })
201
+ .catch(() => {});
202
+ }
203
+ let lastRingEndedAt = null;
204
+ for (let i = 0; i < list.length; i++) {
205
+ const h = list[i];
206
+ const ringStarted = Date.now();
207
+ try {
208
+ await ensureSelectorInView(page, h.selector);
209
+ await applyHighlightZoom(page, h.selector, h.zoom, h.zoomOut);
210
+ // Prefer live in-page rect (honors device-shell scale) over Playwright box.
211
+ const box = await page
212
+ .evaluate((sel) => {
213
+ const el = document.querySelector(sel);
214
+ if (!el) return null;
215
+ const r = el.getBoundingClientRect();
216
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
217
+ }, h.selector)
218
+ .catch(() => null);
219
+ if (box && box.width > 0 && box.height > 0) {
220
+ await showRing(page, box, h.label, h.tone || "planned", {
221
+ size: h.size,
222
+ weight: h.weight,
223
+ selector: h.selector,
224
+ focus: !!h.focus,
225
+ });
226
+ // focus is applied by FollowRing / showRing - no second apply needed
227
+ // Do NOT advanceTodoDock by ring index after stubAfter already set
228
+ // progress - that reset current back to 0 and the next block looked
229
+ // blank. Mid-act Method advance still uses syncTodoDockAdvance.
230
+ if (dock && advanceTodos) {
231
+ dock = advanceTodoDock(dock, i);
232
+ if (opts && opts.todoDockRef) opts.todoDockRef.current = dock;
233
+ await page
234
+ .evaluate((d) => {
235
+ if (window.__wgSyncTodos) window.__wgSyncTodos({ sync: "set", dock: d });
236
+ }, dock)
237
+ .catch(() => {});
238
+ } else if (!dock && ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length))) {
239
+ const rows =
240
+ opts && opts.todos && opts.todos.length
241
+ ? opts.todos
242
+ : normalizeTodos(h.todos, h.todoIndex);
243
+ await page
244
+ .evaluate((todos) => {
245
+ if (window.__wgSyncTodos) {
246
+ window.__wgSyncTodos({ sync: "set", list: todos });
247
+ return;
248
+ }
249
+ // Fallback for pages that never got installOverlay sync helper.
250
+ const ul = document.getElementById("wg-todos");
251
+ if (!ul) return;
252
+ ul.innerHTML = todos
253
+ .map((t) => {
254
+ const cls = t.current
255
+ ? "wg-todo-current"
256
+ : t.done
257
+ ? "wg-todo-done"
258
+ : "wg-todo-pending";
259
+ const mark = t.done ? "\u2713" : t.current ? "\u2192" : "\u25CB";
260
+ const esc = (s) =>
261
+ String(s)
262
+ .replace(/&/g, "&amp;")
263
+ .replace(/</g, "&lt;")
264
+ .replace(/>/g, "&gt;");
265
+ return (
266
+ "<li class=\"" +
267
+ cls +
268
+ "\"><span class=\"wg-todo-mark\">" +
269
+ mark +
270
+ "</span><span>" +
271
+ esc(t.text || "") +
272
+ "</span></li>"
273
+ );
274
+ })
275
+ .join("");
276
+ }, rows)
277
+ .catch(() => {});
278
+ }
279
+ const authored = resolveFixtureDwellMs(h, { gatesFast, pace });
280
+ const legacyMs = i === list.length - 1 ? 200 : 900;
281
+ const holdMs =
282
+ authored != null ? authored : defaultHoldMs != null ? defaultHoldMs : legacyMs;
283
+ const gap = lastRingEndedAt != null ? ringStarted - lastRingEndedAt : null;
284
+ const holdExtra =
285
+ "hold=" +
286
+ holdMs +
287
+ "ms" +
288
+ (authored != null ? " authored" : " default") +
289
+ " box=" +
290
+ Math.round(box.x) +
291
+ "," +
292
+ Math.round(box.y) +
293
+ " " +
294
+ Math.round(box.width) +
295
+ "x" +
296
+ Math.round(box.height) +
297
+ (gap != null ? " gap=" + gap + "ms" : "");
298
+ demoHighlight(h, holdExtra);
299
+ demoLog(
300
+ " highlight " +
301
+ (i + 1) +
302
+ "/" +
303
+ list.length +
304
+ (gap != null && gap < 120 ? ansiPaint(ANSI.yellow, " WARN gap<120ms") : "") +
305
+ (holdMs < 250 && list.length > 1 ? ansiPaint(ANSI.yellow, " WARN short hold") : ""),
306
+ );
307
+ await new Promise((res) => setTimeout(res, holdMs));
308
+ lastRingEndedAt = Date.now();
309
+ } else {
310
+ demoHighlight(h, "SKIP no boundingBox");
311
+ }
312
+ } catch (err) {
313
+ demoHighlight(
314
+ h || { selector: h && h.selector, label: "(error)" },
315
+ "SKIP " + String(err && err.message ? err.message : err).slice(0, 80),
316
+ );
317
+ }
318
+ }
319
+ if (list.length > 0) {
320
+ const last = list[list.length - 1];
321
+ await page
322
+ .evaluate((h) => {
323
+ if (!window.__wgFollowRing && !window.__wgPositionRing) return;
324
+ if (window.__wgFollowRing) {
325
+ window.__wgFollowRing(
326
+ h.selector,
327
+ h.label,
328
+ h.tone || "planned",
329
+ { size: h.size || "md", weight: h.weight || "normal" },
330
+ !!h.focus,
331
+ );
332
+ return;
333
+ }
334
+ const reposition = () => {
335
+ const el = document.querySelector(h.selector);
336
+ if (!el) {
337
+ if (window.__wgHideRing) window.__wgHideRing();
338
+ return;
339
+ }
340
+ const box = el.getBoundingClientRect();
341
+ window.__wgPositionRing(box, h.label, h.tone || "planned", {
342
+ size: h.size || "md",
343
+ weight: h.weight || "normal",
344
+ });
345
+ if (h.focus && window.__wgApplyFocus) {
346
+ window.__wgApplyFocus({
347
+ x: box.x,
348
+ y: box.y,
349
+ width: box.width,
350
+ height: box.height,
351
+ });
352
+ }
353
+ };
354
+ reposition();
355
+ window.__wgRingTrack = reposition;
356
+ window.addEventListener("resize", reposition);
357
+ window.addEventListener("scroll", reposition, true);
358
+ }, last)
359
+ .catch(() => {});
360
+ } else {
361
+ await hideRing(page);
362
+ }
363
+ }
364
+
365
+
366
+ export async function showRing(page, box, label, tone, style) {
367
+ const size = normalizeHighlightSize(style && style.size);
368
+ const weight = normalizeHighlightWeight(style && style.weight);
369
+ const selector = style && style.selector ? String(style.selector) : "";
370
+ const focus = !!(style && style.focus);
371
+ await page
372
+ .evaluate(
373
+ ({ box, label, tone, size, weight, selector, focus }) => {
374
+ if (selector && window.__wgFollowRing) {
375
+ window.__wgFollowRing(selector, label, tone || "planned", { size, weight }, focus);
376
+ return;
377
+ }
378
+ if (window.__wgStopRingFollow) window.__wgStopRingFollow();
379
+ if (window.__wgPositionRing) {
380
+ window.__wgPositionRing(box, label, tone || "planned", { size, weight });
381
+ }
382
+ if (focus && box && window.__wgApplyFocus) window.__wgApplyFocus(box);
383
+ else if (!focus && window.__wgClearFocus) window.__wgClearFocus();
384
+ },
385
+ { box, label, tone: tone || "planned", size, weight, selector, focus },
386
+ )
387
+ .catch(() => {});
388
+ }
389
+
390
+
391
+ /** Match a locator to a stubBefore slot by overlapping bounding boxes. */
392
+ async function matchStubForLocator(page, locator, stubs) {
393
+ if (!stubs || stubs.length === 0) return null;
394
+ const box = await locator.boundingBox().catch(() => null);
395
+ if (!box) return null;
396
+ for (const s of stubs) {
397
+ try {
398
+ const b = await page.locator(s.selector).first().boundingBox();
399
+ if (
400
+ b &&
401
+ Math.abs(b.x - box.x) < 4 &&
402
+ Math.abs(b.y - box.y) < 4 &&
403
+ Math.abs(b.width - box.width) < 8
404
+ ) {
405
+ return s;
406
+ }
407
+ } catch {
408
+ /* skip */
409
+ }
410
+ }
411
+ return null;
412
+ }
413
+
414
+
415
+ /** 0-based stub index for Method fill/click todo advance; -1 if none. */
416
+ export async function matchStubIndexForLocator(page, locator, stubs) {
417
+ if (!stubs || stubs.length === 0) return -1;
418
+ const box = await locator.boundingBox().catch(() => null);
419
+ if (!box) return -1;
420
+ for (let i = 0; i < stubs.length; i++) {
421
+ const s = stubs[i];
422
+ try {
423
+ const b = await page.locator(s.selector).first().boundingBox();
424
+ if (
425
+ b &&
426
+ Math.abs(b.x - box.x) < 4 &&
427
+ Math.abs(b.y - box.y) < 4 &&
428
+ Math.abs(b.width - box.width) < 8
429
+ ) {
430
+ return i;
431
+ }
432
+ } catch {
433
+ /* skip */
434
+ }
435
+ }
436
+ return -1;
437
+ }
438
+
439
+
440
+ export async function captionForLocator(page, locator, stubs, fallback) {
441
+ const matched = await matchStubForLocator(page, locator, stubs);
442
+ if (matched) {
443
+ return {
444
+ label: formatHighlightCaption(matched),
445
+ tone: normalizeHighlightTone(matched.tone),
446
+ size: normalizeHighlightSize(matched.size),
447
+ weight: normalizeHighlightWeight(matched.weight),
448
+ };
449
+ }
450
+ return { label: fallback, tone: "auto", size: "md", weight: "normal" };
451
+ }
452
+
453
+
454
+ export async function dwellMatchedStub(page, locator, stubs, pacing) {
455
+ const matched = await matchStubForLocator(page, locator, stubs);
456
+ if (!matched) return;
457
+ const ms = resolveFixtureDwellMs(matched, {
458
+ gatesFast: !!(pacing && pacing.gatesFast),
459
+ pace: pacing && pacing.demoPace,
460
+ });
461
+ if (ms != null && ms > 0) {
462
+ await new Promise((res) => setTimeout(res, ms));
463
+ }
464
+ }
465
+
466
+
467
+ export async function hideRing(page) {
468
+ await page
469
+ .evaluate(() => {
470
+ if (window.__wgHideRing) window.__wgHideRing();
471
+ })
472
+ .catch(() => {});
473
+ }
474
+
475
+
476
+ /**
477
+ * True if the engine's own narrate() (waygraph's public export, for a Block
478
+ * author explicitly captioning one action) JUST positioned the ring for
479
+ * THIS action, moments ago - the automatic per-fill/per-click narration
480
+ * below should not immediately overwrite an author's own explicit caption
481
+ * with its generic guess.
482
+ */
483
+ export async function wasJustNarrated(page) {
484
+ return page
485
+ .evaluate(() => {
486
+ const w = window;
487
+ return typeof w.__wgLastNarrate === "number" && Date.now() - w.__wgLastNarrate < 500;
488
+ })
489
+ .catch(() => false);
490
+ }
@@ -0,0 +1,110 @@
1
+ // Moved verbatim from the former CHAIN_RUNNER_SCRIPT template string in cli.ts (see src/ARCHITECTURE.md).
2
+ // Runs inside the target project's own waygraph copy - keep it dependency-light and self-contained.
3
+ import { join } from "node:path";
4
+ import { getMemStub } from "../mem-stub.js";
5
+
6
+ function seedMemFromRequires(mem, requires, json, label, stubActive) {
7
+ if (requires.length === 0) {
8
+ if (json !== undefined) {
9
+ throw new Error(
10
+ "waygraph chain: \"" + label + "\" takes no input (empty requires) but got a payload: " + json,
11
+ );
12
+ }
13
+ return;
14
+ }
15
+ if (json === undefined) {
16
+ // --data / WAYGRAPH_DATA supplies the same payload shape as blockName({...}).
17
+ const fromEnv = process.env.WAYGRAPH_DATA;
18
+ if (fromEnv && fromEnv.trim()) json = fromEnv;
19
+ }
20
+
21
+ let parsed;
22
+ let haveParsed = false;
23
+ if (json !== undefined) {
24
+ try {
25
+ parsed = JSON.parse(json);
26
+ haveParsed = true;
27
+ } catch (err) {
28
+ throw new Error("waygraph chain: \"" + label + "\" payload is not valid JSON - " + String(err));
29
+ }
30
+ }
31
+
32
+ if (!haveParsed) {
33
+ // No payload at all - only a hard failure when memStub isn't active for
34
+ // this flow; when it is, fall through with an empty object so the
35
+ // per-key loop below gets a chance to fill each one from the registry.
36
+ if (!stubActive) {
37
+ throw new Error(
38
+ "waygraph chain: \"" + label + "\" requires " + requires.map((k) => k.name).join(", ") +
39
+ " - give a JSON payload (inline blockName({...}) or --data '{...}'), or enable --mem-stub" +
40
+ " with registerMemStub for these keys",
41
+ );
42
+ }
43
+ parsed = {};
44
+ }
45
+
46
+ // Keyed-by-name when every require name is a top-level key (preferred for
47
+ // --data and for {"saucedemo.credentials":{...}} even with one require).
48
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
49
+ const keyed = requires.every((k) => k.name in parsed);
50
+ if (keyed) {
51
+ for (const k of requires) mem.set(k, parsed[k.name]);
52
+ return;
53
+ }
54
+ }
55
+ // Single-key shorthand: the whole payload IS that one key's value - only
56
+ // when a real payload was actually given (the stub-only "{}" fallback
57
+ // above must fall through to per-key stub resolution instead).
58
+ if (haveParsed && requires.length === 1) {
59
+ mem.set(requires[0], parsed);
60
+ return;
61
+ }
62
+ if (haveParsed && (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
63
+ throw new Error(
64
+ "waygraph chain: \"" + label + "\" requires " + requires.length + " keys (" +
65
+ requires.map((k) => k.name).join(", ") + ") - payload must be an object keyed by each key's name",
66
+ );
67
+ }
68
+ const stillMissing = [];
69
+ for (const k of requires) {
70
+ if (k.name in parsed) {
71
+ mem.set(k, parsed[k.name]);
72
+ continue;
73
+ }
74
+ const fake = stubActive ? getMemStub(k) : undefined;
75
+ if (fake) {
76
+ mem.set(k, fake());
77
+ continue;
78
+ }
79
+ stillMissing.push(k);
80
+ }
81
+ if (stillMissing.length > 0) {
82
+ throw new Error(
83
+ "waygraph chain: \"" + label + "\" payload is missing required key(s): " +
84
+ stillMissing.map((k) => "\"" + k.name + "\"").join(", "),
85
+ );
86
+ }
87
+ }
88
+
89
+
90
+ export function seedMemForBlock(mem, resolved, json, stubActive) {
91
+ seedMemFromRequires(mem, resolved.block.requires ?? [], json, resolved.exportName, stubActive);
92
+ }
93
+
94
+
95
+ /**
96
+ * Same idea as seedMemForBlock, but for a whole Flow segment
97
+ * ("loginFlow({...}) then ...") - a Flow has no single .requires of its
98
+ * own, so this unions every constituent Block's requires (deduped by key
99
+ * name) and seeds them all from one JSON payload, same keyed-by-name shape
100
+ * as a multi-key Block payload already uses.
101
+ */
102
+ export function seedMemForFlow(mem, flowBlocks, json, label, stubActive) {
103
+ const seen = new Map();
104
+ for (const bi of flowBlocks) {
105
+ for (const k of bi.block.requires ?? []) {
106
+ if (!seen.has(k.name)) seen.set(k.name, k);
107
+ }
108
+ }
109
+ seedMemFromRequires(mem, Array.from(seen.values()), json, label, stubActive);
110
+ }