waygraph 0.15.10 → 0.15.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +10 -8
  2. package/dist/auto-explore.d.ts +7 -6
  3. package/dist/auto-explore.js +43 -32
  4. package/dist/auto-session-ipc.d.ts +13 -0
  5. package/dist/auto-session-ipc.js +119 -7
  6. package/dist/auto-session.d.ts +16 -13
  7. package/dist/auto-session.js +72 -53
  8. package/dist/block-inject.d.ts +15 -0
  9. package/dist/block-inject.js +91 -0
  10. package/dist/browser.d.ts +15 -0
  11. package/dist/browser.js +29 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +323 -47
  14. package/dist/coverage-gap.d.ts +11 -0
  15. package/dist/coverage-gap.js +119 -0
  16. package/dist/graph.d.ts +1 -0
  17. package/dist/graph.js +99 -91
  18. package/dist/pilot-overlay.d.ts +7 -1
  19. package/dist/pilot-overlay.js +59 -41
  20. package/dist/pilot.d.ts +7 -2
  21. package/dist/pilot.js +34 -7
  22. package/dist/practices-check.d.ts +14 -0
  23. package/dist/practices-check.js +115 -0
  24. package/examples/saucedemo/.waygraph-auto/0b937f26.log +4 -0
  25. package/examples/saucedemo/.waygraph-auto/2b608ed3.log +0 -0
  26. package/examples/saucedemo/.waygraph-auto/2c13567b.log +0 -0
  27. package/examples/saucedemo/.waygraph-auto/2d9622a5.log +4 -0
  28. package/examples/saucedemo/.waygraph-auto/305f6e5b.log +4 -0
  29. package/examples/saucedemo/.waygraph-auto/418befbf.log +4 -0
  30. package/examples/saucedemo/.waygraph-auto/483bd763.log +4 -0
  31. package/examples/saucedemo/.waygraph-auto/48e3596a.log +0 -0
  32. package/examples/saucedemo/.waygraph-auto/4adfbe91.json +8 -0
  33. package/examples/saucedemo/.waygraph-auto/4adfbe91.log +0 -0
  34. package/examples/saucedemo/.waygraph-auto/4f0e33e7.log +4 -0
  35. package/examples/saucedemo/.waygraph-auto/5bd8fedf.log +4 -0
  36. package/examples/saucedemo/.waygraph-auto/5dde8331.log +4 -0
  37. package/examples/saucedemo/.waygraph-auto/68903903.log +0 -0
  38. package/examples/saucedemo/.waygraph-auto/78bbf43c.log +0 -0
  39. package/examples/saucedemo/.waygraph-auto/7efa646f.log +4 -0
  40. package/examples/saucedemo/.waygraph-auto/9cecb0c8.log +4 -0
  41. package/examples/saucedemo/.waygraph-auto/aae57d51.log +0 -0
  42. package/examples/saucedemo/.waygraph-auto/b61a08d8.log +0 -0
  43. package/examples/saucedemo/.waygraph-auto/c946f66c.log +0 -0
  44. package/examples/saucedemo/.waygraph-auto/cd0d5b50.log +4 -0
  45. package/examples/saucedemo/.waygraph-auto/cd5b203c.log +0 -0
  46. package/examples/saucedemo/.waygraph-auto/ea2df316.log +4 -0
  47. package/examples/saucedemo/.waygraph-auto/ee60ed24.log +0 -0
  48. package/examples/saucedemo/.waygraph-auto/f2b6cb1b.log +0 -0
  49. package/examples/saucedemo/.waygraph-auto/f392cdc8.log +0 -0
  50. package/package.json +2 -2
  51. package/templates/scaffold/package.json +1 -1
  52. package/templates/skills/waygraph-convention.skill.md +11 -1
  53. package/templates/skills/waygraph-pilot.skill.md +104 -10
package/dist/graph.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, dirname, relative, resolve } from "node:path";
3
3
  import { matchBlocksSelect, } from "./blocks-select.js";
4
+ import { exploreRoots, injectTaggedFile } from "./block-inject.js";
4
5
  /**
5
6
  * Renders a `WaygraphGraph` as Mermaid `stateDiagram-v2` text - paste
6
7
  * straight into a `.md` file's ```mermaid fence or a live editor. `"*"`
@@ -343,110 +344,117 @@ function extractDefineBlockGenerics(src) {
343
344
  * specifically because it also EXECUTES Blocks, not just imports them.
344
345
  */
345
346
  export async function discoverGraph(projectDir, opts) {
346
- let files = discoverBlocks(projectDir);
347
347
  const select = opts?.blocksSelect;
348
- // Glob/bare: drop files early. Regex needs block names — filter per export below.
349
- if (select && select.kind !== "regex") {
350
- files = files.filter((file) => matchBlocksSelect(select, {
351
- relFile: relative(projectDir, file).replace(/\\/g, "/"),
352
- }));
353
- }
354
348
  const nodeTags = new Set();
355
349
  const edges = [];
356
350
  const skipped = [];
357
- for (const file of files) {
358
- const relFile = relative(projectDir, file);
359
- let mod;
360
- try {
361
- mod = await importModule(file);
351
+ const edgeSeen = new Set();
352
+ for (const root of exploreRoots(projectDir, opts?.inject)) {
353
+ let files = discoverBlocks(root);
354
+ // Glob/bare: drop files early. Regex needs block names — filter per export below.
355
+ if (select && select.kind !== "regex") {
356
+ files = files.filter((file) => matchBlocksSelect(select, {
357
+ relFile: relative(root, file).replace(/\\/g, "/"),
358
+ }));
362
359
  }
363
- catch (err) {
364
- const msg = err instanceof Error ? err.message : String(err);
365
- // Dual @playwright/test installs fail here - surface instead of empty graph.
366
- if (/second time|playwright/i.test(msg)) {
367
- console.error(`waygraph graph: skip ${relFile}: ${msg.split("\n")[0]}`);
360
+ for (const file of files) {
361
+ const relFile = injectTaggedFile(projectDir, root, relative(root, file).replace(/\\/g, "/"));
362
+ let mod;
363
+ try {
364
+ mod = await importModule(file);
368
365
  }
369
- continue;
370
- }
371
- const src = readFileSync(file, "utf-8");
372
- // Assumes source order matches export-object iteration order - true for
373
- // this codebase's own convention of one Block per file; a file with
374
- // several regular Blocks in an unusual order is a known edge case, not
375
- // handled specially here.
376
- const calls = extractDefineBlockGenerics(src);
377
- let callIndex = 0;
378
- for (const exported of Object.values(mod)) {
379
- if (!isBlockLike(exported))
380
- continue;
381
- const blockName = exported.name;
382
- if (select &&
383
- !matchBlocksSelect(select, {
384
- relFile: relFile.replace(/\\/g, "/"),
385
- blockName,
386
- })) {
366
+ catch (err) {
367
+ const msg = err instanceof Error ? err.message : String(err);
368
+ // Dual @playwright/test installs fail here - surface instead of empty graph.
369
+ if (/second time|playwright/i.test(msg)) {
370
+ console.error(`waygraph graph: skip ${relFile}: ${msg.split("\n")[0]}`);
371
+ }
387
372
  continue;
388
373
  }
389
- if (isAssertBlockMarked(exported)) {
390
- try {
391
- // Same input-independent resolve() shape Nav/Page Blocks already
392
- // get this shortcut for - defineAssertBlock's own resolve() is
393
- // always `() => checkpoint(options.checkpoint)`, so no <In, Out>
394
- // generic needs to appear in source text for this to be found.
395
- const resolveFn = exported.instruction.resolve;
396
- const resolved = await resolveFn(undefined);
397
- const to = resolved.__state;
398
- nodeTags.add(to);
399
- // Self-loop by construction (defineAssertBlock is self-loop-only).
400
- edges.push({ block: blockName, file: relFile, from: to, to, kind: "action" });
374
+ const src = readFileSync(file, "utf-8");
375
+ // Assumes source order matches export-object iteration order - true for
376
+ // this codebase's own convention of one Block per file; a file with
377
+ // several regular Blocks in an unusual order is a known edge case, not
378
+ // handled specially here.
379
+ const calls = extractDefineBlockGenerics(src);
380
+ let callIndex = 0;
381
+ for (const exported of Object.values(mod)) {
382
+ if (!isBlockLike(exported))
383
+ continue;
384
+ const blockName = exported.name;
385
+ if (select &&
386
+ !matchBlocksSelect(select, {
387
+ relFile: relFile.replace(/\\/g, "/"),
388
+ blockName,
389
+ })) {
390
+ continue;
401
391
  }
402
- catch {
403
- skipped.push({ block: blockName, file: relFile, reason: "assertBlock resolve() threw" });
392
+ if (isAssertBlockMarked(exported)) {
393
+ try {
394
+ // Same input-independent resolve() shape Nav/Page Blocks already
395
+ // get this shortcut for - defineAssertBlock's own resolve() is
396
+ // always `() => checkpoint(options.checkpoint)`, so no <In, Out>
397
+ // generic needs to appear in source text for this to be found.
398
+ const resolveFn = exported.instruction.resolve;
399
+ const resolved = await resolveFn(undefined);
400
+ const to = resolved.__state;
401
+ nodeTags.add(to);
402
+ // Self-loop by construction (defineAssertBlock is self-loop-only).
403
+ edges.push({ block: blockName, file: relFile, from: to, to, kind: "action" });
404
+ }
405
+ catch {
406
+ skipped.push({ block: blockName, file: relFile, reason: "assertBlock resolve() threw" });
407
+ }
408
+ continue;
404
409
  }
405
- continue;
406
- }
407
- if (isNavBlockMarked(exported)) {
408
- try {
409
- // NavBlock / PageBlock resolve() ignores input - always checkpoint(tag).
410
- const resolveFn = exported.instruction.resolve;
411
- const resolved = await resolveFn(undefined);
412
- const to = resolved.__state;
413
- nodeTags.add(to);
414
- // Arrival-only Page hubs register the Checkpoint node but do not emit
415
- // a Start-here edge (no deep link). Methods own the action edges.
416
- const isPage = exported.__waygraphKind === "page";
417
- const deepLink = !isPage ||
418
- exported.__waygraphPageDeepLink === true;
419
- if (deepLink) {
420
- edges.push({ block: blockName, file: relFile, from: "*", to, kind: "nav" });
410
+ if (isNavBlockMarked(exported)) {
411
+ try {
412
+ // NavBlock / PageBlock resolve() ignores input - always checkpoint(tag).
413
+ const resolveFn = exported.instruction.resolve;
414
+ const resolved = await resolveFn(undefined);
415
+ const to = resolved.__state;
416
+ nodeTags.add(to);
417
+ // Arrival-only Page hubs register the Checkpoint node but do not emit
418
+ // a Start-here edge (no deep link). Methods own the action edges.
419
+ const isPage = exported.__waygraphKind === "page";
420
+ const deepLink = !isPage ||
421
+ exported.__waygraphPageDeepLink === true;
422
+ if (deepLink) {
423
+ edges.push({ block: blockName, file: relFile, from: "*", to, kind: "nav" });
424
+ }
425
+ }
426
+ catch {
427
+ skipped.push({ block: blockName, file: relFile, reason: "NavBlock/PageBlock resolve() threw" });
421
428
  }
429
+ continue;
422
430
  }
423
- catch {
424
- skipped.push({ block: blockName, file: relFile, reason: "NavBlock/PageBlock resolve() threw" });
431
+ const call = calls[callIndex++];
432
+ if (!call) {
433
+ skipped.push({
434
+ block: blockName,
435
+ file: relFile,
436
+ reason: "no defineBlock/defineMethodBlock/defineEffectBlock<In, Out> generic call found",
437
+ });
438
+ continue;
425
439
  }
426
- continue;
427
- }
428
- const call = calls[callIndex++];
429
- if (!call) {
430
- skipped.push({
431
- block: blockName,
432
- file: relFile,
433
- reason: "no defineBlock/defineMethodBlock/defineEffectBlock<In, Out> generic call found",
434
- });
435
- continue;
436
- }
437
- const fromTags = resolveGenericArg(file, call.in);
438
- const toTags = resolveGenericArg(file, call.out);
439
- if (fromTags.length === 0 || toTags.length === 0) {
440
- const badName = fromTags.length === 0 ? call.in : call.out;
441
- skipped.push({ block: blockName, file: relFile, reason: `couldn't resolve "${badName}" to a Checkpoint tag` });
442
- continue;
443
- }
444
- for (const from of fromTags) {
445
- for (const to of toTags) {
446
- if (from !== "*")
447
- nodeTags.add(from);
448
- nodeTags.add(to);
449
- edges.push({ block: blockName, file: relFile, from, to, kind: "action" });
440
+ const fromTags = resolveGenericArg(file, call.in);
441
+ const toTags = resolveGenericArg(file, call.out);
442
+ if (fromTags.length === 0 || toTags.length === 0) {
443
+ const badName = fromTags.length === 0 ? call.in : call.out;
444
+ skipped.push({ block: blockName, file: relFile, reason: `couldn't resolve "${badName}" to a Checkpoint tag` });
445
+ continue;
446
+ }
447
+ for (const from of fromTags) {
448
+ for (const to of toTags) {
449
+ if (from !== "*")
450
+ nodeTags.add(from);
451
+ nodeTags.add(to);
452
+ const key = `${blockName}\0${from}\0${to}`;
453
+ if (edgeSeen.has(key))
454
+ continue;
455
+ edgeSeen.add(key);
456
+ edges.push({ block: blockName, file: relFile, from, to, kind: "action" });
457
+ }
450
458
  }
451
459
  }
452
460
  }
@@ -16,9 +16,15 @@
16
16
  * dispatch as a side effect of loading a library module - unsafe. This
17
17
  * ships its own small, purpose-built badge + panel instead.
18
18
  */
19
- import type { Page } from "@playwright/test";
19
+ import type { BrowserContext, Page } from "@playwright/test";
20
20
  import type { SessionSnapshot } from "./auto-session.js";
21
21
  import { type HighlightTone, type DevicePreset, type DeviceState, type TodoDockUiOpts } from "./highlights.js";
22
+ /**
23
+ * Re-install the overlay shell on every navigation (about:blank, goto, etc.)
24
+ * so the badge/panel persist outside the app's own DOM — not lost when the
25
+ * page is blank or replaced.
26
+ */
27
+ export declare function installPersistentPilotOverlay(context: BrowserContext): Promise<void>;
22
28
  /**
23
29
  * Bottom-left transient toast, separate from the persistent bottom-right
24
30
  * badge - real, direct user request: something that visibly flags "a
@@ -129,53 +129,67 @@ const OVERLAY_CSS = `
129
129
  #wg-pilot-fx-device[data-preset=tablet]{border-color:#22C55E;}
130
130
  #wg-pilot-fx-device[data-preset=desktop]{border-color:#9CA3AF;}
131
131
  ` + WAYGRAPH_RING_CSS;
132
- /** Idempotent - safe to call before every update, matching narrate mode's own precedent. */
133
- async function ensureInstalled(page) {
134
- await page.addStyleTag({ content: OVERLAY_CSS }).catch(() => { });
135
- await page.evaluate(() => {
136
- if (document.getElementById("wg-pilot-overlay"))
137
- return;
138
- const root = document.createElement("div");
139
- root.id = "wg-pilot-overlay";
140
- // Real, direct user request: a way to see the WHOLE project's graph
141
- // (every Checkpoint/Block, not just what's reachable from the current
142
- // page) directly in the overlay, "like a node tree" - toggled inside
143
- // the same panel rather than a second popup, so there's one place to
144
- // look, not two.
145
- root.innerHTML =
146
- '<div id="wg-pilot-badge"></div>' +
147
- '<div id="wg-pilot-panel">' +
148
- '<div id="wg-pilot-panel-toggle"><button id="wg-pilot-view-current" class="wg-pilot-tab-active">Here</button>' +
149
- '<button id="wg-pilot-view-graph">All nodes</button></div>' +
150
- '<div id="wg-pilot-panel-current"></div>' +
151
- '<div id="wg-pilot-panel-graph" style="display:none"></div>' +
152
- "</div>";
153
- document.documentElement.appendChild(root);
154
- document.getElementById("wg-pilot-badge").addEventListener("click", () => {
155
- document.getElementById("wg-pilot-panel").classList.toggle("wg-pilot-open");
156
- });
157
- document.getElementById("wg-pilot-view-current").addEventListener("click", () => {
158
- document.getElementById("wg-pilot-view-current").classList.add("wg-pilot-tab-active");
159
- document.getElementById("wg-pilot-view-graph").classList.remove("wg-pilot-tab-active");
160
- document.getElementById("wg-pilot-panel-current").style.display = "";
161
- document.getElementById("wg-pilot-panel-graph").style.display = "none";
162
- });
163
- document.getElementById("wg-pilot-view-graph").addEventListener("click", () => {
164
- document.getElementById("wg-pilot-view-graph").classList.add("wg-pilot-tab-active");
165
- document.getElementById("wg-pilot-view-current").classList.remove("wg-pilot-tab-active");
166
- document.getElementById("wg-pilot-panel-graph").style.display = "";
167
- document.getElementById("wg-pilot-panel-current").style.display = "none";
168
- });
132
+ /** Browser-side shell install — runs on every document via context.addInitScript. */
133
+ function installPilotOverlayShell(css) {
134
+ if (document.getElementById("wg-pilot-overlay"))
135
+ return;
136
+ const style = document.createElement("style");
137
+ style.id = "wg-pilot-overlay-css";
138
+ style.textContent = css;
139
+ document.documentElement.appendChild(style);
140
+ const root = document.createElement("div");
141
+ root.id = "wg-pilot-overlay";
142
+ root.innerHTML =
143
+ '<div id="wg-pilot-badge">Waygraph Pilot</div>' +
144
+ '<div id="wg-pilot-panel">' +
145
+ '<div id="wg-pilot-panel-toggle"><button id="wg-pilot-view-current" class="wg-pilot-tab-active">Here</button>' +
146
+ '<button id="wg-pilot-view-graph">All nodes</button></div>' +
147
+ '<div id="wg-pilot-panel-current"></div>' +
148
+ '<div id="wg-pilot-panel-graph" style="display:none"></div>' +
149
+ "</div>";
150
+ document.documentElement.appendChild(root);
151
+ document.getElementById("wg-pilot-badge").addEventListener("click", () => {
152
+ document.getElementById("wg-pilot-panel").classList.toggle("wg-pilot-open");
153
+ });
154
+ document.getElementById("wg-pilot-view-current").addEventListener("click", () => {
155
+ document.getElementById("wg-pilot-view-current").classList.add("wg-pilot-tab-active");
156
+ document.getElementById("wg-pilot-view-graph").classList.remove("wg-pilot-tab-active");
157
+ document.getElementById("wg-pilot-panel-current").style.display = "";
158
+ document.getElementById("wg-pilot-panel-graph").style.display = "none";
159
+ });
160
+ document.getElementById("wg-pilot-view-graph").addEventListener("click", () => {
161
+ document.getElementById("wg-pilot-view-graph").classList.add("wg-pilot-tab-active");
162
+ document.getElementById("wg-pilot-view-current").classList.remove("wg-pilot-tab-active");
163
+ document.getElementById("wg-pilot-panel-graph").style.display = "";
164
+ document.getElementById("wg-pilot-panel-current").style.display = "none";
165
+ });
166
+ if (!document.getElementById("wg-pilot-activity")) {
169
167
  const toast = document.createElement("div");
170
168
  toast.id = "wg-pilot-activity";
171
169
  document.documentElement.appendChild(toast);
170
+ }
171
+ if (!document.getElementById("wg-ring")) {
172
172
  const ring = document.createElement("div");
173
173
  ring.id = "wg-ring";
174
+ document.documentElement.appendChild(ring);
175
+ }
176
+ if (!document.getElementById("wg-ring-label")) {
174
177
  const ringLabel = document.createElement("div");
175
178
  ringLabel.id = "wg-ring-label";
176
- document.documentElement.appendChild(ring);
177
179
  document.documentElement.appendChild(ringLabel);
178
- }).catch(() => { });
180
+ }
181
+ }
182
+ /**
183
+ * Re-install the overlay shell on every navigation (about:blank, goto, etc.)
184
+ * so the badge/panel persist outside the app's own DOM — not lost when the
185
+ * page is blank or replaced.
186
+ */
187
+ export async function installPersistentPilotOverlay(context) {
188
+ await context.addInitScript(installPilotOverlayShell, OVERLAY_CSS);
189
+ }
190
+ /** Idempotent - safe to call before every update, matching narrate mode's own precedent. */
191
+ async function ensureInstalled(page) {
192
+ await page.evaluate(installPilotOverlayShell, OVERLAY_CSS).catch(() => { });
179
193
  }
180
194
  /**
181
195
  * Bottom-left transient toast, separate from the persistent bottom-right
@@ -630,10 +644,14 @@ export async function updatePilotOverlay(page, info) {
630
644
  if (!badge || !current || !graphPanel)
631
645
  return;
632
646
  const idPart = sessionId ? `session ${sessionId}` : "no session id";
633
- badge.textContent = `Waygraph Pilot - ${idPart} - ${snapshot.here ?? "(unknown)"}`;
647
+ const onBlank = location.href === "about:blank" || location.protocol === "about:";
648
+ const hereLabel = snapshot.here ?? (onBlank ? "(blank page)" : "(unknown)");
649
+ badge.textContent = `Waygraph Pilot - ${idPart} - ${hereLabel}`;
634
650
  const totalEdges = snapshot.sections.reduce((n, s) => n + s.edges.length, 0);
635
651
  if (totalEdges === 0) {
636
- current.innerHTML = '<div class="wg-pilot-empty">No moves from here right now.</div>';
652
+ current.innerHTML = onBlank
653
+ ? '<div class="wg-pilot-empty">Blank page — no Block moves yet. Use auto goto or a nav Block when ready.</div>'
654
+ : '<div class="wg-pilot-empty">No Block moves from here right now.</div>';
637
655
  }
638
656
  else {
639
657
  current.innerHTML = snapshot.sections
package/dist/pilot.d.ts CHANGED
@@ -29,7 +29,9 @@
29
29
  * new execution primitive.
30
30
  */
31
31
  import { type WaygraphGraph } from "./graph.js";
32
- import type { AutoSessionInit, SessionSnapshot } from "./auto-session.js";
32
+ import { listBrowserSessions, type SessionMeta } from "./auto-session-ipc.js";
33
+ import { type BrowserStartInit } from "./browser.js";
34
+ import type { SessionSnapshot } from "./auto-session.js";
33
35
  export interface PilotStartResult {
34
36
  sessionId: string;
35
37
  socketPath: string;
@@ -46,4 +48,7 @@ export interface PilotStartResult {
46
48
  * one round trip instead of separately knowing to chain `auto --cli
47
49
  * --detach` and `waygraph graph` itself.
48
50
  */
49
- export declare function pilotStart(init: AutoSessionInit): Promise<PilotStartResult>;
51
+ export declare function pilotStart(init: BrowserStartInit): Promise<PilotStartResult>;
52
+ /** Reattach to an existing browser session — does not spawn a new browser. */
53
+ export declare function pilotAttach(projectDir: string, sessionId: string, inject?: string[]): Promise<PilotStartResult>;
54
+ export { listBrowserSessions, type SessionMeta };
package/dist/pilot.js CHANGED
@@ -29,7 +29,8 @@
29
29
  * new execution primitive.
30
30
  */
31
31
  import { discoverGraph } from "./graph.js";
32
- import { spawnDetachedSession, requestSession } from "./auto-session-ipc.js";
32
+ import { requestSession, listBrowserSessions, resolveSessionMeta, } from "./auto-session-ipc.js";
33
+ import { browserStart } from "./browser.js";
33
34
  /**
34
35
  * Starts a detached session and reads back its own graph + starting
35
36
  * snapshot in one call, so an agent bootstraps with everything it needs
@@ -38,16 +39,42 @@ import { spawnDetachedSession, requestSession } from "./auto-session-ipc.js";
38
39
  * --detach` and `waygraph graph` itself.
39
40
  */
40
41
  export async function pilotStart(init) {
41
- const [meta, graph] = await Promise.all([
42
- spawnDetachedSession(init),
43
- discoverGraph(init.projectDir),
44
- ]);
45
- const status = await requestSession(init.projectDir, meta.sessionId, { op: "status" });
42
+ const { resolveInjectRoots } = await import("./block-inject.js");
43
+ const exploreInject = init.inject ??
44
+ (init.injectTokens?.length ? resolveInjectRoots(init.injectTokens, init.projectDir) : undefined);
45
+ const started = await browserStart({
46
+ ...init,
47
+ ...(exploreInject?.length ? { inject: exploreInject } : {}),
48
+ // Pilot navigates to baseURL on start (unlike `browser`, which defaults to about:blank).
49
+ skipInitialNavigation: init.skipInitialNavigation ?? false,
50
+ });
51
+ const graph = await discoverGraph(init.projectDir, exploreInject?.length ? { inject: exploreInject } : undefined);
52
+ const status = await requestSession(init.projectDir, started.sessionId, { op: "status" });
53
+ return {
54
+ sessionId: started.sessionId,
55
+ socketPath: started.socketPath,
56
+ headless: started.headless,
57
+ graph,
58
+ snapshot: status.ok ? status.snapshot : null,
59
+ };
60
+ }
61
+ /** Reattach to an existing browser session — does not spawn a new browser. */
62
+ export async function pilotAttach(projectDir, sessionId, inject) {
63
+ const meta = resolveSessionMeta(sessionId, projectDir);
64
+ if (!meta) {
65
+ throw new Error(`waygraph pilot attach: no such session "${sessionId}" — run \`waygraph pilot sessions\` from the project dir`);
66
+ }
67
+ const graph = await discoverGraph(meta.projectDir, inject?.length ? { inject } : undefined);
68
+ const status = await requestSession(meta.projectDir, sessionId, { op: "status" });
69
+ if (!status.ok) {
70
+ throw new Error(`waygraph pilot attach: session "${sessionId}" not reachable (${status.error})`);
71
+ }
46
72
  return {
47
73
  sessionId: meta.sessionId,
48
74
  socketPath: meta.socketPath,
49
75
  headless: meta.headless,
50
76
  graph,
51
- snapshot: status.ok ? status.snapshot : null,
77
+ snapshot: status.snapshot,
52
78
  };
53
79
  }
80
+ export { listBrowserSessions };
@@ -0,0 +1,14 @@
1
+ export type PracticeKind = "wildcard-checkpoint" | "assert-no-type-arg" | "multi-input" | "combined-action";
2
+ export interface PracticeWarning {
3
+ kind: PracticeKind;
4
+ file: string;
5
+ /** Short hint for humans/agents. */
6
+ detail: string;
7
+ }
8
+ export interface PracticeCheckOptions {
9
+ /** Skip all practice scanning (CLI `--no-practices`). */
10
+ disabled?: boolean;
11
+ }
12
+ export declare function collectPracticeWarnings(projectDir: string, walkDir: (dir: string, re: RegExp) => string[], opts?: PracticeCheckOptions): PracticeWarning[];
13
+ export declare function practiceKindLabel(kind: PracticeKind): string;
14
+ export declare function formatPracticeWarnings(projectDir: string, warnings: PracticeWarning[]): string[];
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `waygraph check` / `waygraph typecheck` practice warnings — style/typing issues
3
+ * that compile but erode the typed graph. Warnings only (never fail exit code)
4
+ * unless paired with `tsc` errors in `waygraph typecheck`.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { relative } from "node:path";
8
+ /** define*Block<…Checkpoint<string>…> — prefer project Checkpoint types. */
9
+ const WILDCARD_CHECKPOINT = /\bdefine(?:Method|Effect|Page|Nav|MemNav|NavClick|Action)Block\s*<[^>]*\bCheckpoint\s*<\s*string\s*>/;
10
+ /** defineAssertBlock({ … }) with no explicit Out type — defaults to wildcard. */
11
+ const ASSERT_NO_TYPE_ARG = /\bdefineAssertBlock\s*\(\s*\{/;
12
+ const SCAN_GLOB = /\.(block|flow)\.ts$/;
13
+ const METHOD_OR_EFFECT_FILE = /\.(method|effect)\.block\.ts$/;
14
+ const METHOD_OR_EFFECT_DEFINE = /\bdefine(?:Method|Effect)Block\b/;
15
+ /** Whole-file opt-out: `// waygraph-ignore-practices` */
16
+ const IGNORE_ALL = /waygraph-ignore-practices/;
17
+ /** Per-kind opt-out: `// waygraph-ignore: multi-input, combined-action` */
18
+ const IGNORE_KINDS = /waygraph-ignore:\s*([^\n*]+)/;
19
+ function walkBlockAndFlowFiles(projectDir, walkDir) {
20
+ return walkDir(projectDir, SCAN_GLOB);
21
+ }
22
+ function ignoredKindsForFile(src) {
23
+ if (IGNORE_ALL.test(src))
24
+ return "all";
25
+ const m = IGNORE_KINDS.exec(src);
26
+ if (!m)
27
+ return new Set();
28
+ const kinds = new Set();
29
+ for (const token of m[1].split(/[,;\s]+/)) {
30
+ const t = token.trim();
31
+ if (t)
32
+ kinds.add(t);
33
+ }
34
+ return kinds;
35
+ }
36
+ function isIgnored(kind, ignore) {
37
+ return ignore === "all" || ignore.has(kind);
38
+ }
39
+ function countMatches(src, re) {
40
+ re.lastIndex = 0;
41
+ return (src.match(re) ?? []).length;
42
+ }
43
+ export function collectPracticeWarnings(projectDir, walkDir, opts) {
44
+ if (opts?.disabled)
45
+ return [];
46
+ const warnings = [];
47
+ for (const file of walkBlockAndFlowFiles(projectDir, walkDir)) {
48
+ const rel = relative(projectDir, file).replace(/\\/g, "/");
49
+ const src = readFileSync(file, "utf-8");
50
+ const ignore = ignoredKindsForFile(src);
51
+ if (!isIgnored("wildcard-checkpoint", ignore) && WILDCARD_CHECKPOINT.test(src)) {
52
+ warnings.push({
53
+ kind: "wildcard-checkpoint",
54
+ file: rel,
55
+ detail: 'uses Checkpoint<string> in a Block helper generic — prefer a typed Checkpoint from src/states/ (e.g. defineMethodBlock<LoginPage, LoginPage>)',
56
+ });
57
+ }
58
+ if (!isIgnored("assert-no-type-arg", ignore) && ASSERT_NO_TYPE_ARG.test(src)) {
59
+ warnings.push({
60
+ kind: "assert-no-type-arg",
61
+ file: rel,
62
+ detail: "defineAssertBlock({…}) without an explicit type argument — add defineAssertBlock<YourCheckpoint>({…}) so defineFlow typing stays honest",
63
+ });
64
+ }
65
+ const isMethodOrEffect = METHOD_OR_EFFECT_FILE.test(rel) || METHOD_OR_EFFECT_DEFINE.test(src);
66
+ if (isMethodOrEffect) {
67
+ const fills = countMatches(src, /\.fill\s*\(/g);
68
+ const clicks = countMatches(src, /\.click\s*\(/g);
69
+ if (!isIgnored("multi-input", ignore) && fills >= 2) {
70
+ warnings.push({
71
+ kind: "multi-input",
72
+ file: rel,
73
+ detail: `Method/Effect act() has ${fills} .fill() calls — one distinct input per Block (split into separate defineMethodBlock files, e.g. fill-username + fill-password + submit-login)`,
74
+ });
75
+ }
76
+ if (!isIgnored("combined-action", ignore) && fills >= 1 && clicks >= 1) {
77
+ warnings.push({
78
+ kind: "combined-action",
79
+ file: rel,
80
+ detail: "Method/Effect act() mixes .fill() and .click() — fill and submit must be separate Blocks (see examples/saucedemo login split)",
81
+ });
82
+ }
83
+ }
84
+ }
85
+ return warnings;
86
+ }
87
+ export function practiceKindLabel(kind) {
88
+ switch (kind) {
89
+ case "wildcard-checkpoint":
90
+ return "wildcard Checkpoint<string>";
91
+ case "assert-no-type-arg":
92
+ return "assert block missing type arg";
93
+ case "multi-input":
94
+ return "multiple inputs in one Block";
95
+ case "combined-action":
96
+ return "fill and click in one Block";
97
+ default: {
98
+ const _exhaustive = kind;
99
+ return _exhaustive;
100
+ }
101
+ }
102
+ }
103
+ export function formatPracticeWarnings(projectDir, warnings) {
104
+ const lines = [];
105
+ for (const w of warnings) {
106
+ lines.push(`waygraph practices: ${w.file} (${practiceKindLabel(w.kind)}) — ${w.detail}`);
107
+ }
108
+ if (warnings.length === 0) {
109
+ lines.push(`waygraph practices: no bad-practice patterns under ${projectDir}`);
110
+ }
111
+ else {
112
+ lines.push(`waygraph practices: ${warnings.length} bad-practice warning${warnings.length === 1 ? "" : "s"}`);
113
+ }
114
+ return lines;
115
+ }
@@ -0,0 +1,4 @@
1
+ (node:1438382) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
2
+ (Use `node --trace-warnings ...` to show where the warning was created)
3
+ (node:1438382) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
4
+ (Use `node --trace-warnings ...` to show where the warning was created)
File without changes
File without changes
@@ -0,0 +1,4 @@
1
+ (node:1432340) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
2
+ (Use `node --trace-warnings ...` to show where the warning was created)
3
+ (node:1432340) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
4
+ (Use `node --trace-warnings ...` to show where the warning was created)
@@ -0,0 +1,4 @@
1
+ (node:1419780) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
2
+ (Use `node --trace-warnings ...` to show where the warning was created)
3
+ (node:1419780) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
4
+ (Use `node --trace-warnings ...` to show where the warning was created)
@@ -0,0 +1,4 @@
1
+ (node:1429820) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
2
+ (Use `node --trace-warnings ...` to show where the warning was created)
3
+ (node:1429820) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
4
+ (Use `node --trace-warnings ...` to show where the warning was created)
@@ -0,0 +1,4 @@
1
+ (node:1432363) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
2
+ (Use `node --trace-warnings ...` to show where the warning was created)
3
+ (node:1432363) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
4
+ (Use `node --trace-warnings ...` to show where the warning was created)
File without changes
@@ -0,0 +1,8 @@
1
+ {
2
+ "sessionId": "4adfbe91",
3
+ "socketPath": "/tmp/waygraph-auto/4adfbe91.sock",
4
+ "pid": 1479053,
5
+ "projectDir": "/home/dan/Desktop/Projects/waygraph/examples/saucedemo",
6
+ "startedAt": "2026-09-22T02:04:35.751Z",
7
+ "headless": false
8
+ }
File without changes
@@ -0,0 +1,4 @@
1
+ (node:1419814) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
2
+ (Use `node --trace-warnings ...` to show where the warning was created)
3
+ (node:1419814) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.
4
+ (Use `node --trace-warnings ...` to show where the warning was created)