waygraph 0.12.18 → 0.12.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -228,11 +228,14 @@ real acts). `--ff-expand` is the older expand-only flag (same flatten). Saucedem
228
228
  `checkoutFlow` uses `ff-owner-auth` for the login prefix; `loginFlow` stays expanded for
229
229
  Sign In narration.
230
230
 
231
- `waygraph traverse` walks the Block graph (Phase B+D). `--parallel N` (default
231
+ `waygraph traverse` walks the Block graph (Phase B-E). `--parallel N` (default
232
232
  `--session clone`) bootstraps one context, forks N workers with `storageState` + mem
233
233
  snapshot, partitions edges by hash, and claims via in-process edge leases (audit under
234
- `.waygraph-traverse/`). `--session inherit` is refused when `parallel > 1`. See
235
- `docs/proposals/traverse-ffcompose-rfc.md`.
234
+ `.waygraph-traverse/`). `--session inherit` is refused when `parallel > 1`. Phase E
235
+ writes `.waygraph-traverse/coverage.json` and prints `[Coverage edges=H/T ratio=R%...]`;
236
+ `--min-edge-coverage 80%` fails the suite with exit 2 when the ratio is below the gate
237
+ (even if every worker leaf-PASSed). `--coverage-out PATH` / `--no-coverage-report` adjust
238
+ the JSON. See `docs/proposals/traverse-ffcompose-rfc.md`.
236
239
 
237
240
  **Richer demo narration:** automation rings are **gray**; authored stubs/slides can use
238
241
  `tone: "info" | "warning" | "danger" | "success"` (iconified captions), plus
@@ -194,20 +194,23 @@ async function runOneBlock(engine, entry, context, page, mem, headful) {
194
194
  const flow = engine.defineFlow([start, entry.block, end]);
195
195
  const BLOCK_TIMEOUT_MS = 45_000;
196
196
  let timer;
197
+ let result;
197
198
  try {
198
- await Promise.race([
199
+ const ran = await Promise.race([
199
200
  flow.run(context, mem, { page, closeOnFinish: false }),
200
201
  new Promise((_, reject) => {
201
202
  timer = setTimeout(() => reject(new Error(`Block "${entry.block.name}" timed out after ${BLOCK_TIMEOUT_MS / 1000}s - ` +
202
203
  "click Quit and retry, or check network / mem credentials")), BLOCK_TIMEOUT_MS);
203
204
  }),
204
205
  ]);
206
+ result = ran.result;
205
207
  }
206
208
  finally {
207
209
  if (timer)
208
210
  clearTimeout(timer);
209
211
  }
210
212
  await page.evaluate("delete window.__wgPendingNavClickLabel").catch(() => { });
213
+ return result;
211
214
  }
212
215
  const PANEL_CSS = "#wg-auto-panel{position:fixed;z-index:2147483647;left:50%;bottom:12px;transform:translateX(-50%);" +
213
216
  "max-width:min(92vw,720px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;" +
@@ -540,13 +543,16 @@ export async function runAutoExplore(projectDir, options = {}) {
540
543
  }
541
544
  wiredPage = target;
542
545
  }
546
+ let here = null;
543
547
  try {
544
548
  for (;;) {
545
549
  page = await ensureLivePage(context, page, startUrl);
546
550
  await wirePage(page);
547
551
  const pickWait = armPickWait();
548
552
  pickSlot.resolve = pickWait.resolve;
549
- const here = await detectHere(page, library.navBlocks);
553
+ if (here === null) {
554
+ here = await detectHere(page, library.navBlocks);
555
+ }
550
556
  if (cli)
551
557
  console.error("waygraph auto: scanning page for moves ...");
552
558
  const menu = await buildExploreMenu(page, graph, library, here);
@@ -595,10 +601,11 @@ export async function runAutoExplore(projectDir, options = {}) {
595
601
  }
596
602
  const pickLabel = edge.label ?? entry.block.name;
597
603
  console.error(`waygraph auto: running [${pick.index + 1}] ${pickLabel} ...`);
598
- await runOneBlock(engine, entry, context, page, mem, !cli);
599
- const landed = await detectHere(page, library.navBlocks);
600
- lastRunNote = `${entry.block.name} -> ${landed ?? edge.to}`;
601
- console.error(`waygraph auto: ran ${entry.block.name} -> ${landed ?? edge.to}`);
604
+ const result = await runOneBlock(engine, entry, context, page, mem, !cli);
605
+ // Trust block output over locate() - nav verify traits collide on /dashboard.
606
+ here = result.__state ?? edge.to;
607
+ lastRunNote = `${entry.block.name} -> ${here}`;
608
+ console.error(`waygraph auto: ran ${entry.block.name} -> ${here}`);
602
609
  }
603
610
  catch (err) {
604
611
  lastRunNote = `${entry.block.name} failed`;
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./gra
21
21
  import { runAutoExplore } from "./auto-explore-run.js";
22
22
  import { runAgentDive } from "./agent-dive.js";
23
23
  import { runTraverse } from "./traverse-run.js";
24
+ import { parseMinEdgeCoverage } from "./traverse-coverage.js";
24
25
  import { isFileSelectToken, parseBlocksSelect, } from "./blocks-select.js";
25
26
  // ---------------------------------------------------------------------------
26
27
  // Filesystem
@@ -221,6 +222,8 @@ import {
221
222
  demoPaceIsBlitz,
222
223
  demoPaceIsFast,
223
224
  demoPaceIsSlow,
225
+ formatDemoPaceBadge,
226
+ formatDemoPaceLabel,
224
227
  } from "waygraph";
225
228
 
226
229
  function walkDir(dir, pattern) {
@@ -491,6 +494,18 @@ const RING_CSS =
491
494
  // block chain shows no episode heading at all.
492
495
  "#wg-panel .wg-episode{margin:0 0 6px;font:700 15px system-ui,sans-serif;color:#fff;" +
493
496
  "padding-bottom:6px;border-bottom:1px solid rgba(124,58,237,.35);}" +
497
+ // Pace chip - numbers speak in the overlay (2.5x / 4500ms / slow).
498
+ "#wg-panel .wg-pace{display:inline-flex;align-items:center;gap:8px;margin:0 0 10px;" +
499
+ "padding:6px 10px;border-radius:8px;background:rgba(124,58,237,.22);" +
500
+ "border:1px solid rgba(124,58,237,.45);font:600 12px/1.35 system-ui,sans-serif;color:#f0e8ff;}" +
501
+ "#wg-panel .wg-pace-badge{display:inline-block;padding:2px 8px;border-radius:999px;" +
502
+ "background:#7C3AED;color:#fff;font:800 11px/1.2 system-ui,sans-serif;letter-spacing:.04em;" +
503
+ "text-transform:uppercase;}" +
504
+ "#wg-panel .wg-pace[data-pace-kind=slow] .wg-pace-badge," +
505
+ "#wg-panel .wg-pace[data-pace-kind=num-slow] .wg-pace-badge{background:#EAB308;color:#1c1917;}" +
506
+ "#wg-panel .wg-pace[data-pace-kind=fast] .wg-pace-badge," +
507
+ "#wg-panel .wg-pace[data-pace-kind=blitz] .wg-pace-badge{background:#22C55E;color:#052e16;}" +
508
+ "#wg-panel .wg-pace[data-pace-kind=ms] .wg-pace-badge{background:#3B82F6;color:#fff;}" +
494
509
  "#wg-panel .wg-narration{margin:0 0 12px;font:italic 14px/1.4 system-ui,sans-serif;color:#f0e8ff;}" +
495
510
  "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
496
511
  "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
@@ -957,9 +972,20 @@ async function renderBeforeStep(page, info) {
957
972
  const narrationHtml = info.description
958
973
  ? "<div class=\\"wg-narration\\">" + esc(info.description) + "</div>"
959
974
  : "";
975
+ const paceHtml =
976
+ info.paceBadge || info.paceLabel
977
+ ? "<div class=\\"wg-pace\\" data-pace-kind=\\"" +
978
+ esc(info.paceKind || "normal") +
979
+ "\\"><span class=\\"wg-pace-badge\\">" +
980
+ esc(info.paceBadge || "1x") +
981
+ "</span><span>" +
982
+ esc(info.paceLabel || "") +
983
+ "</span></div>"
984
+ : "";
960
985
  let html =
961
986
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
962
987
  episodesHtml +
988
+ paceHtml +
963
989
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
964
990
  "<h3>Step " + (info.index + 1) + " / " + info.total + " - " + info.blockName + "</h3>" +
965
991
  narrationHtml;
@@ -995,7 +1021,12 @@ async function renderBeforeStep(page, info) {
995
1021
  }
996
1022
  if (window.__wgWirePanelChrome) {
997
1023
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
998
- stepLabel: (info.index + 1) + " / " + info.total + (info.blockName ? " · " + info.blockName : ""),
1024
+ stepLabel:
1025
+ (info.index + 1) +
1026
+ " / " +
1027
+ info.total +
1028
+ (info.blockName ? " · " + info.blockName : "") +
1029
+ (info.paceBadge ? " · " + info.paceBadge : ""),
999
1030
  forceCollapsed: info.forceCollapsed === true ? true : info.forceCollapsed === false ? false : undefined,
1000
1031
  });
1001
1032
  }
@@ -1214,9 +1245,20 @@ async function renderAfterStep(page, info) {
1214
1245
  .join("") +
1215
1246
  "</div>"
1216
1247
  : "";
1248
+ const paceHtml =
1249
+ info.paceBadge || info.paceLabel
1250
+ ? "<div class=\\"wg-pace\\" data-pace-kind=\\"" +
1251
+ escA(info.paceKind || "normal") +
1252
+ "\\"><span class=\\"wg-pace-badge\\">" +
1253
+ escA(info.paceBadge || "1x") +
1254
+ "</span><span>" +
1255
+ escA(info.paceLabel || "") +
1256
+ "</span></div>"
1257
+ : "";
1217
1258
  panel.innerHTML =
1218
1259
  "<div id=\\"wg-progress\\"><div id=\\"wg-progress-bar\\" style=\\"width:" + pct + "%\\"></div></div>" +
1219
1260
  episodesHtml +
1261
+ paceHtml +
1220
1262
  "<div id=\\"wg-modules\\" class=\\"" + modulesClass + "\\">" + modulesHtml + "</div>" +
1221
1263
  "<h3>" + heading + "</h3>" +
1222
1264
  resultHtml +
@@ -1227,7 +1269,12 @@ async function renderAfterStep(page, info) {
1227
1269
  }
1228
1270
  if (window.__wgWirePanelChrome) {
1229
1271
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1230
- stepLabel: (info.index + 1) + " / " + info.total + (info.blockName ? " · " + info.blockName : ""),
1272
+ stepLabel:
1273
+ (info.index + 1) +
1274
+ " / " +
1275
+ info.total +
1276
+ (info.blockName ? " · " + info.blockName : "") +
1277
+ (info.paceBadge ? " · " + info.paceBadge : ""),
1231
1278
  forceCollapsed: info.forceCollapsed === true ? true : info.forceCollapsed === false ? false : undefined,
1232
1279
  });
1233
1280
  }
@@ -1329,7 +1376,12 @@ async function renderStepError(page, info) {
1329
1376
  }
1330
1377
  if (window.__wgWirePanelChrome) {
1331
1378
  window.__wgWirePanelChrome(panel, "wg-panel-hidden", "waygraph demo", {
1332
- stepLabel: (info.index + 1) + " / " + info.total + (info.blockName ? " · " + info.blockName : ""),
1379
+ stepLabel:
1380
+ (info.index + 1) +
1381
+ " / " +
1382
+ info.total +
1383
+ (info.blockName ? " · " + info.blockName : "") +
1384
+ (info.paceBadge ? " · " + info.paceBadge : ""),
1333
1385
  forceCollapsed: info.forceCollapsed === true ? true : info.forceCollapsed === false ? false : undefined,
1334
1386
  });
1335
1387
  }
@@ -1537,14 +1589,13 @@ async function presentSlides(page, slides, _gate, opts) {
1537
1589
  const episodeTitle = (opts && opts.episodeTitle) || "";
1538
1590
  const gatesFast = !!(opts && opts.fast);
1539
1591
  const demoPace = normalizeDemoPace((opts && opts.pace) || (gatesFast ? "fast" : "normal"));
1540
- const autoplayMs =
1592
+ const baseAutoplay =
1541
1593
  opts && opts.autoplayMs
1542
1594
  ? Number(opts.autoplayMs)
1543
1595
  : process.env.WAYGRAPH_AUTOPLAY_MS
1544
1596
  ? Number(process.env.WAYGRAPH_AUTOPLAY_MS)
1545
- : demoPace === "slow"
1546
- ? 3500
1547
- : 1800;
1597
+ : 1800;
1598
+ const autoplayMs = demoPaceGateMs(demoPace, baseAutoplay);
1548
1599
  for (let i = 0; i < slides.length; i++) {
1549
1600
  const s = slides[i];
1550
1601
  const caption = formatHighlightCaption(s);
@@ -1584,11 +1635,22 @@ async function presentSlides(page, slides, _gate, opts) {
1584
1635
  (info.episodeTitle ? ": " + esc(info.episodeTitle) : "") +
1585
1636
  " · yap slides</div>"
1586
1637
  : "";
1638
+ const paceHtml =
1639
+ info.paceBadge || info.paceLabel
1640
+ ? "<div class=\\"wg-pace\\" data-pace-kind=\\"" +
1641
+ esc(info.paceKind || "normal") +
1642
+ "\\"><span class=\\"wg-pace-badge\\">" +
1643
+ esc(info.paceBadge || "1x") +
1644
+ "</span><span>" +
1645
+ esc(info.paceLabel || "") +
1646
+ "</span></div>"
1647
+ : "";
1587
1648
  panel.innerHTML =
1588
1649
  "<div class=\\"wg-chrome\\"><span class=\\"wg-chrome-title\\">waygraph demo</span>" +
1589
1650
  "<button type=\\"button\\" class=\\"wg-hide-btn\\" data-wg-toggle=\\"1\\">Hide</button></div>" +
1590
1651
  "<div class=\\"wg-body\\">" +
1591
1652
  episodeLine +
1653
+ paceHtml +
1592
1654
  "<h3>Slide " +
1593
1655
  (info.index + 1) +
1594
1656
  " / " +
@@ -1706,6 +1768,9 @@ async function presentSlides(page, slides, _gate, opts) {
1706
1768
  dwellMs,
1707
1769
  episodeNumber: episodeNumber !== undefined ? episodeNumber : null,
1708
1770
  episodeTitle,
1771
+ paceBadge: formatDemoPaceBadge(demoPace, baseAutoplay),
1772
+ paceLabel: formatDemoPaceLabel(demoPace, baseAutoplay),
1773
+ paceKind: demoPaceKind(demoPace),
1709
1774
  },
1710
1775
  );
1711
1776
  const started = Date.now();
@@ -1738,6 +1803,25 @@ async function presentSlides(page, slides, _gate, opts) {
1738
1803
  await hideRing(page);
1739
1804
  }
1740
1805
 
1806
+ function demoPaceKind(pace) {
1807
+ const p = normalizeDemoPace(pace);
1808
+ if (typeof p === "number" && p > 20) return "ms";
1809
+ if (typeof p === "number") return demoPaceIsSlow(p) ? "num-slow" : demoPaceIsFast(p) ? "fast" : "normal";
1810
+ if (p === "blitz" || p === "fast" || p === "slow") return p;
1811
+ return "normal";
1812
+ }
1813
+
1814
+ function paceSpeakFields(pace, baseAutoplayMs) {
1815
+ const p = normalizeDemoPace(pace);
1816
+ const base = Number.isFinite(baseAutoplayMs) && baseAutoplayMs > 0 ? baseAutoplayMs : 1800;
1817
+ return {
1818
+ pace: p,
1819
+ paceBadge: formatDemoPaceBadge(p, base),
1820
+ paceLabel: formatDemoPaceLabel(p, base),
1821
+ paceKind: demoPaceKind(p),
1822
+ };
1823
+ }
1824
+
1741
1825
  async function hideRing(page) {
1742
1826
  await page
1743
1827
  .evaluate(() => {
@@ -2302,6 +2386,20 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2302
2386
  }
2303
2387
  return { name: k.name, value };
2304
2388
  });
2389
+ const paceSpeak = paceSpeakFields(pacing.demoPace, autoplayMs);
2390
+ if (process.env.WAYGRAPH_JSON !== "1") {
2391
+ console.log(
2392
+ "waygraph demo: step " +
2393
+ (i + 1) +
2394
+ "/" +
2395
+ resolved.length +
2396
+ " " +
2397
+ r.block.name +
2398
+ " · " +
2399
+ paceSpeak.paceLabel +
2400
+ (r.episodeNumber ? " · episode " + r.episodeNumber : ""),
2401
+ );
2402
+ }
2305
2403
  await renderBeforeStep(page, {
2306
2404
  index: i,
2307
2405
  total: resolved.length,
@@ -2318,6 +2416,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2318
2416
  episodeTitle: r.episodeTitle,
2319
2417
  stepperMode,
2320
2418
  forceCollapsed,
2419
+ ...paceSpeak,
2321
2420
  });
2322
2421
  const edits = await gate();
2323
2422
  for (const k of requires) {
@@ -2487,6 +2586,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2487
2586
  highlights,
2488
2587
  gatesFast: pacing.gatesFast,
2489
2588
  pace: pacing.demoPace,
2589
+ ...paceSpeakFields(pacing.demoPace, autoplayMs),
2490
2590
  isLast: i === resolved.length - 1,
2491
2591
  allNames: moduleNames,
2492
2592
  allDescriptions: moduleDescriptions,
@@ -3851,7 +3951,7 @@ Primary (less is more):
3851
3951
 
3852
3952
  Also:
3853
3953
  waygraph list | nav | validate | check | graph | init <name>
3854
- waygraph traverse [project] Graph crawl (Phase B+D)
3954
+ waygraph traverse [project] Graph crawl (Phase B-E)
3855
3955
  --blocks <glob|/re/|sub> Phase C: filter *.block.ts discovery
3856
3956
  --parallel N Phase D: N clone workers (max 4)
3857
3957
  --session clone|inherit Phase D: default clone
@@ -3977,6 +4077,9 @@ async function main() {
3977
4077
  let blocksFilter;
3978
4078
  let parallel;
3979
4079
  let session;
4080
+ let minEdgeCoverageRaw;
4081
+ let coverageOut;
4082
+ let noCoverageReport = false;
3980
4083
  let projectDir = process.cwd();
3981
4084
  for (let i = 0; i < rest.length; i++) {
3982
4085
  const a = rest[i];
@@ -4016,6 +4119,22 @@ async function main() {
4016
4119
  session = v;
4017
4120
  continue;
4018
4121
  }
4122
+ if (a === "--min-edge-coverage" || a.startsWith("--min-edge-coverage=")) {
4123
+ minEdgeCoverageRaw = a.startsWith("--min-edge-coverage=")
4124
+ ? a.slice("--min-edge-coverage=".length)
4125
+ : rest[++i];
4126
+ continue;
4127
+ }
4128
+ if (a === "--coverage-out" || a.startsWith("--coverage-out=")) {
4129
+ coverageOut = a.startsWith("--coverage-out=")
4130
+ ? a.slice("--coverage-out=".length)
4131
+ : rest[++i];
4132
+ continue;
4133
+ }
4134
+ if (a === "--no-coverage-report") {
4135
+ noCoverageReport = true;
4136
+ continue;
4137
+ }
4019
4138
  if (a === "--max-steps" || a.startsWith("--max-steps=")) {
4020
4139
  const v = a.startsWith("--max-steps=") ? a.slice(12) : rest[++i];
4021
4140
  maxSteps = Number(v);
@@ -4044,12 +4163,15 @@ async function main() {
4044
4163
  if (a === "--help" || a === "-h") {
4045
4164
  console.log(`waygraph traverse [project] [flags]
4046
4165
 
4047
- Graph crawl (RFC Phase B+D). Walks unused legal edges until a leaf,
4166
+ Graph crawl (RFC Phase B-E). Walks unused legal edges until a leaf,
4048
4167
  budget kill, or first broken edge. --parallel N uses session clone + edge leases.
4049
4168
 
4050
4169
  --blocks <glob|/regex/|substr> Phase C: filter *.block.ts discovery
4051
4170
  --parallel N Phase D: N clone workers (default 1, max 4)
4052
4171
  --session clone|inherit Phase D: default clone; inherit refused if parallel>1
4172
+ --min-edge-coverage X Phase E: suite gate 0.8 | 80% | 80 (exit 2 if below)
4173
+ --coverage-out PATH Phase E: write JSON (default .waygraph-traverse/coverage.json)
4174
+ --no-coverage-report Phase E: print Coverage line only (no JSON file)
4053
4175
  --from <Checkpoint> start checkpoint (optional)
4054
4176
  --data '{...}' Mem seed JSON
4055
4177
  --max-steps N default 50 (per worker)
@@ -4061,9 +4183,11 @@ budget kill, or first broken edge. --parallel N uses session clone + edge leases
4061
4183
  Examples:
4062
4184
  waygraph traverse --blocks '**/mailpit/**/*.block.ts'
4063
4185
  waygraph traverse --parallel 2 --session clone --max-steps 20
4186
+ waygraph traverse --min-edge-coverage 80% --coverage-out ./cov.json
4064
4187
 
4065
4188
  PASS: [Reached Leaf Node[traverse-1] at=... steps=N]
4066
4189
  FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
4190
+ COVER: [Coverage edges=H/T ratio=R% min=M% PASS|FAIL]
4067
4191
  `);
4068
4192
  process.exit(0);
4069
4193
  }
@@ -4078,6 +4202,15 @@ FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
4078
4202
  const blocksSelect = blocksFilter
4079
4203
  ? parseBlocksSelect(blocksFilter)
4080
4204
  : undefined;
4205
+ let minEdgeCoverage;
4206
+ if (minEdgeCoverageRaw !== undefined) {
4207
+ const parsed = parseMinEdgeCoverage(minEdgeCoverageRaw);
4208
+ if (parsed === null) {
4209
+ console.error(`waygraph traverse: --min-edge-coverage expects 0.8, 80%, or 80 (got ${JSON.stringify(minEdgeCoverageRaw)})`);
4210
+ process.exit(1);
4211
+ }
4212
+ minEdgeCoverage = parsed;
4213
+ }
4081
4214
  const code = await runTraverse(projectDir, {
4082
4215
  ...(from ? { from } : {}),
4083
4216
  ...(data ? { data } : {}),
@@ -4093,6 +4226,9 @@ FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
4093
4226
  ...(blocksSelect ? { blocksSelect } : {}),
4094
4227
  ...(parallel !== undefined && Number.isFinite(parallel) ? { parallel } : {}),
4095
4228
  ...(session ? { session } : {}),
4229
+ ...(minEdgeCoverage !== undefined ? { minEdgeCoverage } : {}),
4230
+ ...(coverageOut ? { coverageOut: resolve(coverageOut) } : {}),
4231
+ ...(noCoverageReport ? { noCoverageReport: true } : {}),
4096
4232
  });
4097
4233
  process.exit(code);
4098
4234
  }
@@ -133,6 +133,16 @@ export declare function demoPaceIsFast(pace: DemoPace): boolean;
133
133
  export declare function demoPaceIsSlow(pace: DemoPace): boolean;
134
134
  /** Autoplay / Next-gate ms for this pace (base is WAYGRAPH_AUTOPLAY_MS or 1800). */
135
135
  export declare function demoPaceGateMs(pace: DemoPace, baseAutoplayMs: number): number;
136
+ /**
137
+ * Short chip for the demo panel (e.g. `2.5x`, `4500ms`, `slow`).
138
+ * Always printable - numbers speak in the UI, not only in code.
139
+ */
140
+ export declare function formatDemoPaceBadge(pace: DemoPace, baseAutoplayMs?: number): string;
141
+ /**
142
+ * Loud one-line pace description for panel + console
143
+ * (e.g. `pace 2.5x (~4500ms gates)` / `pace 4500ms gates (~2.25x)`).
144
+ */
145
+ export declare function formatDemoPaceLabel(pace: DemoPace, baseAutoplayMs?: number): string;
136
146
  /**
137
147
  * Resolve min dwell ms for any fixture (stub / flow fixture / yap slide),
138
148
  * or `null` when duration is unset (caller keeps legacy timing).
@@ -162,6 +162,51 @@ export function demoPaceGateMs(pace, baseAutoplayMs) {
162
162
  const scale = demoPaceScale(pace);
163
163
  return Math.min(30000, Math.max(80, Math.round(baseAutoplayMs * scale)));
164
164
  }
165
+ /**
166
+ * Short chip for the demo panel (e.g. `2.5x`, `4500ms`, `slow`).
167
+ * Always printable - numbers speak in the UI, not only in code.
168
+ */
169
+ export function formatDemoPaceBadge(pace, baseAutoplayMs = 1800) {
170
+ const p = normalizeDemoPace(pace);
171
+ if (typeof p === "number" && p > 20)
172
+ return `${Math.round(p)}ms`;
173
+ if (typeof p === "number") {
174
+ const s = Number.isInteger(p) ? String(p) : String(Math.round(p * 100) / 100);
175
+ return `${s}x`;
176
+ }
177
+ if (p === "blitz")
178
+ return "blitz";
179
+ if (p === "fast")
180
+ return "fast";
181
+ if (p === "slow")
182
+ return "slow";
183
+ void baseAutoplayMs;
184
+ return "1x";
185
+ }
186
+ /**
187
+ * Loud one-line pace description for panel + console
188
+ * (e.g. `pace 2.5x (~4500ms gates)` / `pace 4500ms gates (~2.25x)`).
189
+ */
190
+ export function formatDemoPaceLabel(pace, baseAutoplayMs = 1800) {
191
+ const p = normalizeDemoPace(pace);
192
+ const gate = demoPaceGateMs(p, baseAutoplayMs);
193
+ const scale = demoPaceScale(p);
194
+ const scaleTxt = Number.isInteger(scale) ? String(scale) : (Math.round(scale * 100) / 100).toString();
195
+ if (typeof p === "number" && p > 20) {
196
+ return `pace ${Math.round(p)}ms gates (~${scaleTxt}x)`;
197
+ }
198
+ if (typeof p === "number") {
199
+ const s = Number.isInteger(p) ? String(p) : String(Math.round(p * 100) / 100);
200
+ return `pace ${s}x (~${gate}ms gates)`;
201
+ }
202
+ if (p === "blitz")
203
+ return `pace blitz (~${gate}ms, skip theater)`;
204
+ if (p === "fast")
205
+ return `pace fast (~${scaleTxt}x, ~${gate}ms gates)`;
206
+ if (p === "slow")
207
+ return `pace slow (~${scaleTxt}x, ~${gate}ms gates)`;
208
+ return `pace normal (1x, ~${gate}ms gates)`;
209
+ }
165
210
  /**
166
211
  * Resolve min dwell ms for any fixture (stub / flow fixture / yap slide),
167
212
  * or `null` when duration is unset (caller keeps legacy timing).
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
4
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
5
  export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withDemoPace, withBlockPace, withHighlightStyle, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
6
- export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
6
+ export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
7
7
  export type { WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, HighlightSize, HighlightWeight, HighlightStyleDefaults, DemoPace, DemoPaceName, } from "./highlights.js";
8
8
  export type { Flow, EngineConfig, ComposedBlock, FastForwardComposedBlock, RunGraphOptions, BlockInfo, NavBlock, NavBlockOptions, NavClickBlock, NavClickBlockOptions, MethodBlock, ActionBlock, PageBlock, PageBlockOptions, PageMethodEntry, EffectBlock, MemEffectBlock, MemNavBlock, } from "./engine.js";
9
9
  export type { ActionPage } from "./types.js";
@@ -13,4 +13,6 @@ export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp,
13
13
  export type { BlocksSelect, BlocksSelectKind } from "./blocks-select.js";
14
14
  export { EdgeLeaseCoordinator } from "./traverse-lease.js";
15
15
  export { runTraverse } from "./traverse-run.js";
16
- export type { TraverseOptions, TraverseSessionMode } from "./traverse-run.js";
16
+ export type { TraverseOptions, TraverseSessionMode, TraverseWorkerResult, } from "./traverse-run.js";
17
+ export { buildCoverageReport, collectGraphEdgeKeys, defaultCoverageOutPath, formatCoverageLine, normalizeHitKey, parseMinEdgeCoverage, writeCoverageReport, } from "./traverse-coverage.js";
18
+ export type { TraverseCoverageReport } from "./traverse-coverage.js";
package/dist/index.js CHANGED
@@ -2,8 +2,9 @@ export { connect, checkpoint } from "./types.js";
2
2
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
3
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
4
  export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withDemoPace, withBlockPace, withHighlightStyle, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
5
- export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
5
+ export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
6
6
  export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./graph.js";
7
7
  export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp, } from "./blocks-select.js";
8
8
  export { EdgeLeaseCoordinator } from "./traverse-lease.js";
9
9
  export { runTraverse } from "./traverse-run.js";
10
+ export { buildCoverageReport, collectGraphEdgeKeys, defaultCoverageOutPath, formatCoverageLine, normalizeHitKey, parseMinEdgeCoverage, writeCoverageReport, } from "./traverse-coverage.js";
@@ -0,0 +1,59 @@
1
+ export interface TraverseCoverageReport {
2
+ version: 1;
3
+ generatedAt: string;
4
+ projectDir: string;
5
+ parallel: number;
6
+ /** Unique edge keys in the discovered graph (block names). */
7
+ edgesTotal: number;
8
+ /** Graph edges successfully walked this suite (after instance normalize). */
9
+ edgesHit: number;
10
+ /** edgesHit / edgesTotal (0 when total is 0). */
11
+ ratio: number;
12
+ /** Optional suite gate that was applied (0-1). */
13
+ minEdgeCoverage?: number;
14
+ /** true when minEdgeCoverage was set and ratio met it. */
15
+ coveragePass?: boolean;
16
+ /** Sorted graph edge keys that were hit. */
17
+ hit: string[];
18
+ /** Sorted graph edge keys never walked. */
19
+ missed: string[];
20
+ /** Raw worker hit keys (may include block::instanceId). */
21
+ rawHit?: string[];
22
+ workers?: Array<{
23
+ traverseId: string;
24
+ exitCode: number;
25
+ steps: number;
26
+ edgesHit: number;
27
+ leaf?: string;
28
+ }>;
29
+ }
30
+ /** Static graph edge key = Block runtime name. */
31
+ export declare function edgeKeyFromGraphEdge(edge: {
32
+ block: string;
33
+ }): string;
34
+ /** Collect unique edge keys from a WaygraphGraph-like edges list. */
35
+ export declare function collectGraphEdgeKeys(edges: ReadonlyArray<{
36
+ block: string;
37
+ }>): string[];
38
+ /**
39
+ * Map a worker hit key onto the static graph key it covers.
40
+ * `add-to-cart::sku-1` -> `add-to-cart` when that block is in the graph.
41
+ */
42
+ export declare function normalizeHitKey(hitKey: string, graphKeys: ReadonlySet<string>): string | null;
43
+ /**
44
+ * Parse --min-edge-coverage: `0.8`, `80%`, `80` (as percent when > 1).
45
+ * Returns 0-1 fraction, or null when unset/invalid.
46
+ */
47
+ export declare function parseMinEdgeCoverage(raw: unknown): number | null;
48
+ export declare function buildCoverageReport(opts: {
49
+ projectDir: string;
50
+ parallel: number;
51
+ allEdgeKeys: readonly string[];
52
+ hitKeys: ReadonlySet<string> | readonly string[];
53
+ minEdgeCoverage?: number | null;
54
+ workers?: TraverseCoverageReport["workers"];
55
+ }): TraverseCoverageReport;
56
+ /** Greppable one-liner for agents/CI. */
57
+ export declare function formatCoverageLine(report: TraverseCoverageReport): string;
58
+ export declare function defaultCoverageOutPath(projectDir: string): string;
59
+ export declare function writeCoverageReport(path: string, report: TraverseCoverageReport): void;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Phase E: traverse edge coverage report + suite gate.
3
+ * Coverage is independent of leaf PASS - a green leaf can still fail CI
4
+ * when --min-edge-coverage is set and ratio is too low.
5
+ *
6
+ * Denominator = unique static graph edge block names (FF inners stay as their
7
+ * own graph edges when discovered; RFC open Q deferred - no covered-via-prefix yet).
8
+ * A hit of `block::instanceId` counts as covering `block`.
9
+ */
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ /** Static graph edge key = Block runtime name. */
13
+ export function edgeKeyFromGraphEdge(edge) {
14
+ return edge.block;
15
+ }
16
+ /** Collect unique edge keys from a WaygraphGraph-like edges list. */
17
+ export function collectGraphEdgeKeys(edges) {
18
+ const set = new Set();
19
+ for (const e of edges)
20
+ set.add(edgeKeyFromGraphEdge(e));
21
+ return [...set].sort();
22
+ }
23
+ /**
24
+ * Map a worker hit key onto the static graph key it covers.
25
+ * `add-to-cart::sku-1` -> `add-to-cart` when that block is in the graph.
26
+ */
27
+ export function normalizeHitKey(hitKey, graphKeys) {
28
+ if (graphKeys.has(hitKey))
29
+ return hitKey;
30
+ const base = hitKey.includes("::") ? hitKey.slice(0, hitKey.indexOf("::")) : hitKey;
31
+ if (graphKeys.has(base))
32
+ return base;
33
+ return null;
34
+ }
35
+ /**
36
+ * Parse --min-edge-coverage: `0.8`, `80%`, `80` (as percent when > 1).
37
+ * Returns 0-1 fraction, or null when unset/invalid.
38
+ */
39
+ export function parseMinEdgeCoverage(raw) {
40
+ if (raw === undefined || raw === null || raw === "")
41
+ return null;
42
+ if (typeof raw === "number" && Number.isFinite(raw)) {
43
+ if (raw < 0)
44
+ return null;
45
+ if (raw <= 1)
46
+ return raw;
47
+ if (raw <= 100)
48
+ return raw / 100;
49
+ return null;
50
+ }
51
+ if (typeof raw !== "string")
52
+ return null;
53
+ const t = raw.trim().toLowerCase();
54
+ if (!t)
55
+ return null;
56
+ if (t.endsWith("%")) {
57
+ const n = Number(t.slice(0, -1));
58
+ if (!Number.isFinite(n) || n < 0 || n > 100)
59
+ return null;
60
+ return n / 100;
61
+ }
62
+ const n = Number(t);
63
+ if (!Number.isFinite(n) || n < 0)
64
+ return null;
65
+ if (n <= 1)
66
+ return n;
67
+ if (n <= 100)
68
+ return n / 100;
69
+ return null;
70
+ }
71
+ export function buildCoverageReport(opts) {
72
+ const all = [...new Set(opts.allEdgeKeys)].sort();
73
+ const graphSet = new Set(all);
74
+ const rawList = Array.isArray(opts.hitKeys) ? [...opts.hitKeys] : [...opts.hitKeys];
75
+ const covered = new Set();
76
+ for (const raw of rawList) {
77
+ const n = normalizeHitKey(raw, graphSet);
78
+ if (n)
79
+ covered.add(n);
80
+ }
81
+ const hit = all.filter((k) => covered.has(k));
82
+ const missed = all.filter((k) => !covered.has(k));
83
+ const edgesTotal = all.length;
84
+ const edgesHit = hit.length;
85
+ const ratio = edgesTotal === 0 ? 0 : edgesHit / edgesTotal;
86
+ const min = opts.minEdgeCoverage != null && Number.isFinite(opts.minEdgeCoverage)
87
+ ? Math.min(1, Math.max(0, opts.minEdgeCoverage))
88
+ : undefined;
89
+ const coveragePass = min === undefined ? undefined : ratio + 1e-12 >= min;
90
+ const coverageExtra = min !== undefined && coveragePass !== undefined
91
+ ? { minEdgeCoverage: min, coveragePass }
92
+ : {};
93
+ return {
94
+ version: 1,
95
+ generatedAt: new Date().toISOString(),
96
+ projectDir: opts.projectDir,
97
+ parallel: opts.parallel,
98
+ edgesTotal,
99
+ edgesHit,
100
+ ratio: Math.round(ratio * 10000) / 10000,
101
+ ...coverageExtra,
102
+ hit,
103
+ missed,
104
+ rawHit: [...new Set(rawList)].sort(),
105
+ ...(opts.workers ? { workers: opts.workers } : {}),
106
+ };
107
+ }
108
+ /** Greppable one-liner for agents/CI. */
109
+ export function formatCoverageLine(report) {
110
+ const pct = (report.ratio * 100).toFixed(1);
111
+ const minPart = report.minEdgeCoverage !== undefined
112
+ ? ` min=${(report.minEdgeCoverage * 100).toFixed(1)}%` +
113
+ (report.coveragePass === false
114
+ ? " FAIL"
115
+ : report.coveragePass === true
116
+ ? " PASS"
117
+ : "")
118
+ : "";
119
+ return `[Coverage edges=${report.edgesHit}/${report.edgesTotal} ratio=${pct}%${minPart}]`;
120
+ }
121
+ export function defaultCoverageOutPath(projectDir) {
122
+ return join(projectDir, ".waygraph-traverse", "coverage.json");
123
+ }
124
+ export function writeCoverageReport(path, report) {
125
+ mkdirSync(dirname(path), { recursive: true });
126
+ writeFileSync(path, JSON.stringify(report, null, 2) + "\n", "utf-8");
127
+ }
@@ -1,4 +1,10 @@
1
1
  export type TraverseSessionMode = "clone" | "inherit";
2
+ export interface TraverseWorkerResult {
3
+ exitCode: number;
4
+ steps: number;
5
+ edgesHit: string[];
6
+ leaf?: string;
7
+ }
2
8
  export interface TraverseOptions {
3
9
  baseURL?: string;
4
10
  startUrl?: string;
@@ -24,8 +30,18 @@ export interface TraverseOptions {
24
30
  parallel?: number;
25
31
  /** Phase D: clone (default) or inherit. Inherit refused when parallel > 1. */
26
32
  session?: TraverseSessionMode;
33
+ /**
34
+ * Phase E: fail suite (exit 2) when hit/total ratio is below this 0-1 fraction.
35
+ * Leaf PASS alone does not satisfy the gate.
36
+ */
37
+ minEdgeCoverage?: number;
38
+ /** Phase E: write coverage JSON here (default `.waygraph-traverse/coverage.json`). */
39
+ coverageOut?: string;
40
+ /** Phase E: skip writing coverage.json (still prints the Coverage line). */
41
+ noCoverageReport?: boolean;
27
42
  }
28
43
  /**
29
- * Serial or parallel graph crawl. Prints greppable PASS/FAIL lines; non-zero on break.
44
+ * Serial or parallel graph crawl. Prints greppable PASS/FAIL + Coverage lines.
45
+ * Exit: 0 leaf ok (+ coverage ok), 1 break/timeout, 2 coverage gate fail (Phase E).
30
46
  */
31
47
  export declare function runTraverse(projectDir: string, options?: TraverseOptions): Promise<number>;
@@ -9,6 +9,7 @@ import { MemPage } from "./mem-page.js";
9
9
  import { buildExploreContext, buildExploreMenu, } from "./auto-explore.js";
10
10
  import { defaultMemValueForKey } from "./auto-explore-run.js";
11
11
  import { EdgeLeaseCoordinator } from "./traverse-lease.js";
12
+ import { buildCoverageReport, collectGraphEdgeKeys, defaultCoverageOutPath, formatCoverageLine, writeCoverageReport, } from "./traverse-coverage.js";
12
13
  function resolveBaseUrl(projectDir) {
13
14
  if (process.env.WAYGRAPH_BASE_URL)
14
15
  return process.env.WAYGRAPH_BASE_URL;
@@ -132,6 +133,7 @@ async function runTraverseWorker(opts) {
132
133
  const startedAt = Date.now();
133
134
  let lastHere = opts.from ?? null;
134
135
  let exitCode = 0;
136
+ let leaf;
135
137
  try {
136
138
  for (;;) {
137
139
  if (Date.now() - startedAt > timeoutMs) {
@@ -171,6 +173,7 @@ async function runTraverseWorker(opts) {
171
173
  });
172
174
  if (candidates.length === 0) {
173
175
  const label = here ?? detected ?? "unknown";
176
+ leaf = label;
174
177
  console.log(`[Reached Leaf Node[${traverseId}] at=${label} steps=${steps}]`);
175
178
  console.error(`waygraph traverse[${traverseId}]: PASS leaf edgesHit=${edgesHit.size} steps=${steps}`);
176
179
  break;
@@ -227,10 +230,16 @@ async function runTraverseWorker(opts) {
227
230
  finally {
228
231
  console.error(`waygraph traverse[${traverseId}]: done exit=${exitCode} steps=${steps} edgesHit=${edgesHit.size}`);
229
232
  }
230
- return exitCode;
233
+ return {
234
+ exitCode,
235
+ steps,
236
+ edgesHit: [...edgesHit],
237
+ ...(leaf ? { leaf } : {}),
238
+ };
231
239
  }
232
240
  /**
233
- * Serial or parallel graph crawl. Prints greppable PASS/FAIL lines; non-zero on break.
241
+ * Serial or parallel graph crawl. Prints greppable PASS/FAIL + Coverage lines.
242
+ * Exit: 0 leaf ok (+ coverage ok), 1 break/timeout, 2 coverage gate fail (Phase E).
234
243
  */
235
244
  export async function runTraverse(projectDir, options = {}) {
236
245
  let parallel = Math.max(1, Math.floor(options.parallel ?? 1));
@@ -250,11 +259,16 @@ export async function runTraverse(projectDir, options = {}) {
250
259
  const headed = options.headed === true || process.env.WAYGRAPH_HEADED === "1";
251
260
  const baseURL = options.baseURL ?? resolveBaseUrl(projectDir) ?? process.env.WAYGRAPH_BASE_URL;
252
261
  const startUrl = options.startUrl ?? baseURL;
262
+ const minEdgeCoverage = options.minEdgeCoverage != null && Number.isFinite(options.minEdgeCoverage)
263
+ ? options.minEdgeCoverage
264
+ : undefined;
265
+ const coverageOut = options.coverageOut ?? defaultCoverageOutPath(projectDir);
253
266
  const { graph, library } = await buildExploreContext(projectDir, options.blocksSelect ? { blocksSelect: options.blocksSelect } : undefined);
254
267
  if (options.blocksSelect) {
255
268
  console.error(`waygraph traverse: --blocks ${options.blocksSelect.raw} -> ` +
256
269
  `${library.byName.size} block(s), ${graph.edges.length} edge(s)`);
257
270
  }
271
+ const allEdgeKeys = collectGraphEdgeKeys(graph.edges);
258
272
  const leases = new EdgeLeaseCoordinator({ projectDir, persist: parallel > 1 });
259
273
  const engine = new Engine({ headless: !headed, slowMo: headed ? 100 : 0 });
260
274
  const { chromium } = await import("@playwright/test");
@@ -268,7 +282,46 @@ export async function runTraverse(projectDir, options = {}) {
268
282
  console.error(`waygraph traverse: ${graph.nodes.length} node(s), ${graph.edges.length} edge(s)` +
269
283
  (baseURL ? ` base=${baseURL}` : "") +
270
284
  ` parallel=${parallel} session=${session}` +
271
- ` maxSteps=${maxSteps} maxVisits/node=${maxVisitsPerNode} maxVisits/edge=${maxVisitsPerEdge}`);
285
+ ` maxSteps=${maxSteps} maxVisits/node=${maxVisitsPerNode} maxVisits/edge=${maxVisitsPerEdge}` +
286
+ (minEdgeCoverage !== undefined
287
+ ? ` minEdgeCoverage=${(minEdgeCoverage * 100).toFixed(1)}%`
288
+ : ""));
289
+ const emitCoverage = (workers) => {
290
+ const hitUnion = new Set();
291
+ for (const w of workers) {
292
+ for (const k of w.result.edgesHit)
293
+ hitUnion.add(k);
294
+ }
295
+ const report = buildCoverageReport({
296
+ projectDir,
297
+ parallel,
298
+ allEdgeKeys,
299
+ hitKeys: hitUnion,
300
+ ...(minEdgeCoverage !== undefined ? { minEdgeCoverage } : {}),
301
+ workers: workers.map(({ traverseId, result }) => ({
302
+ traverseId,
303
+ exitCode: result.exitCode,
304
+ steps: result.steps,
305
+ edgesHit: result.edgesHit.length,
306
+ ...(result.leaf ? { leaf: result.leaf } : {}),
307
+ })),
308
+ });
309
+ console.log(formatCoverageLine(report));
310
+ console.error(`waygraph traverse: coverage hit=${report.edgesHit}/${report.edgesTotal}` +
311
+ (report.missed.length
312
+ ? ` missed=${report.missed.slice(0, 8).join(",")}${report.missed.length > 8 ? "..." : ""}`
313
+ : "") +
314
+ (options.noCoverageReport ? "" : ` report=${coverageOut}`));
315
+ if (!options.noCoverageReport) {
316
+ writeCoverageReport(coverageOut, report);
317
+ }
318
+ if (report.coveragePass === false) {
319
+ console.error(`waygraph traverse: FAIL coverage gate ratio=${(report.ratio * 100).toFixed(1)}%` +
320
+ ` < min=${((report.minEdgeCoverage ?? 0) * 100).toFixed(1)}%`);
321
+ return 2;
322
+ }
323
+ return 0;
324
+ };
272
325
  try {
273
326
  if (parallel <= 1) {
274
327
  const contextOpts = {
@@ -284,8 +337,9 @@ export async function runTraverse(projectDir, options = {}) {
284
337
  await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
285
338
  await page.waitForLoadState("load").catch(() => { });
286
339
  }
287
- const code = await runTraverseWorker({
288
- traverseId: options.traverseId ?? "traverse-1",
340
+ const traverseId = options.traverseId ?? "traverse-1";
341
+ const result = await runTraverseWorker({
342
+ traverseId,
289
343
  workerIndex: 0,
290
344
  parallel: 1,
291
345
  maxSteps,
@@ -305,7 +359,10 @@ export async function runTraverse(projectDir, options = {}) {
305
359
  leases,
306
360
  });
307
361
  await context.close().catch(() => { });
308
- return code;
362
+ const coverageCode = emitCoverage([{ traverseId, result }]);
363
+ if (result.exitCode !== 0)
364
+ return result.exitCode;
365
+ return coverageCode;
309
366
  }
310
367
  // Phase D: bootstrap one context for storageState, then N clones.
311
368
  const bootOpts = {
@@ -325,7 +382,7 @@ export async function runTraverse(projectDir, options = {}) {
325
382
  const memSnap = snapshotMem(library.byName, bootMem);
326
383
  await bootContext.close().catch(() => { });
327
384
  console.error(`waygraph traverse: forked ${parallel} clone workers (storageState + mem snapshot)`);
328
- const codes = await Promise.all(Array.from({ length: parallel }, async (_, i) => {
385
+ const workerMeta = await Promise.all(Array.from({ length: parallel }, async (_, i) => {
329
386
  const traverseId = `traverse-${i + 1}`;
330
387
  const ctxOpts = {
331
388
  viewport: { width: 1280, height: 720 },
@@ -341,7 +398,7 @@ export async function runTraverse(projectDir, options = {}) {
341
398
  await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
342
399
  }
343
400
  try {
344
- return await runTraverseWorker({
401
+ const result = await runTraverseWorker({
345
402
  traverseId,
346
403
  workerIndex: i,
347
404
  parallel,
@@ -361,15 +418,20 @@ export async function runTraverse(projectDir, options = {}) {
361
418
  mem,
362
419
  leases,
363
420
  });
421
+ return { traverseId, result };
364
422
  }
365
423
  finally {
366
424
  await context.close().catch(() => { });
367
425
  }
368
426
  }));
369
- const worst = codes.reduce((a, b) => Math.max(a, b), 0);
370
- const pass = codes.filter((c) => c === 0).length;
427
+ const results = workerMeta.map((w) => w.result);
428
+ const worst = results.reduce((a, b) => Math.max(a, b.exitCode), 0);
429
+ const pass = results.filter((c) => c.exitCode === 0).length;
371
430
  console.error(`waygraph traverse: suite parallel=${parallel} pass=${pass}/${parallel} exit=${worst}`);
372
- return worst;
431
+ const coverageCode = emitCoverage(workerMeta);
432
+ if (worst !== 0)
433
+ return worst;
434
+ return coverageCode;
373
435
  }
374
436
  finally {
375
437
  await browser.close().catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.18",
3
+ "version": "0.12.20",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",