waygraph 0.15.42 → 0.15.44

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 (60) hide show
  1. package/dist/analysis/coverage-gap.d.ts +11 -0
  2. package/dist/analysis/coverage-gap.js +119 -0
  3. package/dist/analysis/graph.d.ts +92 -0
  4. package/dist/analysis/graph.js +467 -0
  5. package/dist/analysis/map-check.d.ts +18 -0
  6. package/dist/analysis/map-check.js +140 -0
  7. package/dist/analysis/practices-check.d.ts +14 -0
  8. package/dist/analysis/practices-check.js +195 -0
  9. package/dist/auto-explore.d.ts +1 -1
  10. package/dist/auto-explore.js +1 -1
  11. package/dist/auto-session/session.js +2 -2
  12. package/dist/cli/util.d.ts +8 -1
  13. package/dist/cli/util.js +9 -2
  14. package/dist/commands/check.js +2 -2
  15. package/dist/commands/map.js +1 -1
  16. package/dist/commands/session.js +2 -2
  17. package/dist/commands/traverse.js +2 -2
  18. package/dist/engine/branch-regression.js +2 -1
  19. package/dist/engine/compose.js +3 -2
  20. package/dist/engine/core.js +5 -4
  21. package/dist/engine/engine-class.js +13 -12
  22. package/dist/engine/flow.js +4 -3
  23. package/dist/engine/run-graph.js +4 -3
  24. package/dist/errors.d.ts +122 -0
  25. package/dist/errors.js +125 -0
  26. package/dist/highlights/shorthand.d.ts +24 -0
  27. package/dist/highlights/shorthand.js +30 -0
  28. package/dist/index.d.ts +9 -7
  29. package/dist/index.js +5 -4
  30. package/dist/mem-page.js +2 -1
  31. package/dist/pilot.d.ts +1 -1
  32. package/dist/pilot.js +1 -1
  33. package/dist/practices-check.js +7 -5
  34. package/dist/traverse/coverage.d.ts +59 -0
  35. package/dist/traverse/coverage.js +127 -0
  36. package/dist/traverse/lease.d.ts +18 -0
  37. package/dist/traverse/lease.js +62 -0
  38. package/dist/traverse/run.d.ts +47 -0
  39. package/dist/traverse/run.js +439 -0
  40. package/examples/saucedemo/.waygraph-auto/050e5b55.log +0 -0
  41. package/examples/saucedemo/.waygraph-auto/096fea05.log +20 -0
  42. package/examples/saucedemo/.waygraph-auto/123d1cad.log +5 -0
  43. package/examples/saucedemo/.waygraph-auto/12ed8e28.log +0 -0
  44. package/examples/saucedemo/.waygraph-auto/1cb6f44c.log +5 -0
  45. package/examples/saucedemo/.waygraph-auto/1fdb2100.log +0 -0
  46. package/examples/saucedemo/.waygraph-auto/573abedd.log +0 -0
  47. package/examples/saucedemo/.waygraph-auto/9a26134c.log +20 -0
  48. package/examples/saucedemo/.waygraph-auto/aa226dd0.log +22 -0
  49. package/examples/saucedemo/.waygraph-auto/ac5dde3d.log +0 -0
  50. package/examples/saucedemo/.waygraph-auto/cae91469.log +0 -0
  51. package/examples/saucedemo/.waygraph-auto/d846cd78.log +0 -0
  52. package/examples/saucedemo/.waygraph-auto/eb38d92e.log +22 -0
  53. package/examples/saucedemo/.waygraph-auto/ee86203e.log +0 -0
  54. package/package.json +7 -4
  55. package/templates/scaffold/scripts/README.md +13 -0
  56. package/templates/scaffold/src/flows/README.md +18 -0
  57. package/templates/scaffold/src/map/README.md +31 -0
  58. package/templates/scaffold/src/states/README.md +15 -0
  59. package/templates/scaffold/tests/README.md +10 -0
  60. package/templates/skills/waygraph-convention.skill.md +6 -4
@@ -0,0 +1,467 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, dirname, relative, resolve } from "node:path";
3
+ import { matchBlocksSelect, } from "../blocks-select.js";
4
+ import { exploreRoots, injectTaggedFile } from "../block-inject.js";
5
+ /**
6
+ * Renders a `WaygraphGraph` as Mermaid `stateDiagram-v2` text - paste
7
+ * straight into a `.md` file's ```mermaid fence or a live editor. `"*"`
8
+ * renders as Mermaid's own `[*]` start marker, matching how a NavBlock's
9
+ * `from` already means "reachable from anywhere."
10
+ * @example console.log(toMermaid(graph))
11
+ */
12
+ export function toMermaid(graph) {
13
+ const lines = ["stateDiagram-v2"];
14
+ for (const edge of graph.edges) {
15
+ const from = edge.from === "*" ? "[*]" : edge.from;
16
+ lines.push(` ${from} --> ${edge.to} : ${edge.block}`);
17
+ }
18
+ return lines.join("\n");
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // Discovery - deliberately self-contained (no import from cli.ts): cli.ts
22
+ // runs its own main() unconditionally at module load, so it can't safely be
23
+ // imported from a test (or from here) without also running the whole CLI.
24
+ // These are small, stable duplicates of cli.ts's own discoverBlocks/
25
+ // isBlockLike/isNavBlockMarked/importModule, not a shared abstraction -
26
+ // simpler and lower-risk than a circular import between the two modules.
27
+ // ---------------------------------------------------------------------------
28
+ function walkDir(dir, pattern) {
29
+ const results = [];
30
+ let entries;
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ return results;
36
+ }
37
+ for (const entry of entries) {
38
+ const full = join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git")
41
+ continue;
42
+ results.push(...walkDir(full, pattern));
43
+ }
44
+ else if (pattern.test(entry.name)) {
45
+ results.push(full);
46
+ }
47
+ }
48
+ return results;
49
+ }
50
+ function discoverBlocks(projectDir) {
51
+ return walkDir(projectDir, /\.block\.ts$/);
52
+ }
53
+ function discoverFlows(projectDir) {
54
+ return walkDir(projectDir, /\.flow\.ts$/);
55
+ }
56
+ /** Block export ids listed inside any `defineFlow([...])` array in flow files. */
57
+ function extractFlowBlockRefs(flowSrc) {
58
+ const refs = [];
59
+ const re = /defineFlow(?:<[\s\S]*?>)?\s*\(\s*\[([\s\S]*?)\]\s*\)/g;
60
+ let m;
61
+ while ((m = re.exec(flowSrc)) !== null) {
62
+ for (const part of m[1].split(",")) {
63
+ const trimmed = part.trim().replace(/\/\/.*$/, "").trim();
64
+ const id = trimmed.split(/\s+/)[0];
65
+ if (!id || id === "start" || id === "end")
66
+ continue;
67
+ refs.push(id);
68
+ }
69
+ }
70
+ return refs;
71
+ }
72
+ function exportNameToBlockName(exportName) {
73
+ const base = exportName.replace(/Block$/, "");
74
+ return base
75
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
76
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
77
+ .toLowerCase();
78
+ }
79
+ /**
80
+ * Blocks present on disk but never wired into a `.flow.ts` defineFlow array.
81
+ * Required before `chain auto` can build a path from the discovered graph -
82
+ * orphan Blocks are intentionally excluded from auto shorthand. Arrival-only
83
+ * Page hubs and Blocks listed on a Page's `methods` are not orphans.
84
+ */
85
+ export async function findOrphanBlocks(projectDir) {
86
+ const blockFiles = discoverBlocks(projectDir);
87
+ const flowFiles = discoverFlows(projectDir);
88
+ const referenced = new Set();
89
+ for (const flowFile of flowFiles) {
90
+ for (const ref of extractFlowBlockRefs(readFileSync(flowFile, "utf-8"))) {
91
+ referenced.add(ref);
92
+ }
93
+ }
94
+ const pageMethodNames = new Set();
95
+ for (const file of blockFiles) {
96
+ let mod;
97
+ try {
98
+ mod = await importModule(file);
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ for (const exported of Object.values(mod)) {
104
+ if (!exported || typeof exported !== "object")
105
+ continue;
106
+ if (exported.__waygraphKind !== "page")
107
+ continue;
108
+ const names = exported.__waygraphMethods;
109
+ if (Array.isArray(names)) {
110
+ for (const n of names)
111
+ pageMethodNames.add(n);
112
+ }
113
+ }
114
+ }
115
+ const orphans = [];
116
+ for (const file of blockFiles) {
117
+ const relFile = relative(projectDir, file);
118
+ const src = readFileSync(file, "utf-8");
119
+ const exportRe = /export\s+const\s+([A-Za-z_$][\w]*)\s*=/g;
120
+ let m;
121
+ while ((m = exportRe.exec(src)) !== null) {
122
+ const exportName = m[1];
123
+ if (referenced.has(exportName))
124
+ continue;
125
+ if (!/defineBlock|defineMethodBlock|defineActionBlock|definePageBlock|defineEffectBlock|defineMemEffectBlock|defineNavBlock|defineNavClickBlock|defineMemNavBlock/.test(src))
126
+ continue;
127
+ // Arrival-only Page hubs are never orphans (even if import fails without tsx).
128
+ const assignSlice = src.slice(m.index, m.index + exportName.length + 80);
129
+ if (/=\s*definePageBlock\b/.test(assignSlice))
130
+ continue;
131
+ let block = exportNameToBlockName(exportName);
132
+ try {
133
+ const mod = await importModule(file);
134
+ const exported = mod[exportName];
135
+ if (isBlockLike(exported))
136
+ block = exported.name;
137
+ if (exported &&
138
+ typeof exported === "object" &&
139
+ exported.__waygraphKind === "page") {
140
+ continue;
141
+ }
142
+ if (pageMethodNames.has(block))
143
+ continue;
144
+ }
145
+ catch {
146
+ // import may fail in partial projects - keep derived name
147
+ }
148
+ orphans.push({ exportName, block, file: relFile });
149
+ }
150
+ }
151
+ return orphans.sort((a, b) => a.file.localeCompare(b.file) || a.exportName.localeCompare(b.exportName));
152
+ }
153
+ /**
154
+ * Shortest Block-name path between two Checkpoint tags using `discoverGraph`
155
+ * edges. NavBlocks (`from: "*"`) count from any state.
156
+ */
157
+ export function findBlockPath(graph, fromTag, toTag) {
158
+ if (fromTag === toTag)
159
+ return [];
160
+ const queue = [{ tag: fromTag, path: [] }];
161
+ const visited = new Set([fromTag]);
162
+ while (queue.length > 0) {
163
+ const { tag, path } = queue.shift();
164
+ for (const edge of graph.edges) {
165
+ if (edge.from !== tag && edge.from !== "*")
166
+ continue;
167
+ const nextPath = [...path, edge.block];
168
+ if (edge.to === toTag)
169
+ return nextPath;
170
+ if (!visited.has(edge.to)) {
171
+ visited.add(edge.to);
172
+ queue.push({ tag: edge.to, path: nextPath });
173
+ }
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+ /**
179
+ * Same BFS as `findBlockPath`, but returns each hop's specific edge (block
180
+ * name + the Checkpoint it targets), not just the block name - needed by any
181
+ * caller that must verify a step actually landed where the route intended,
182
+ * not just re-guess it afterward. `findBlockPath` itself is unchanged and
183
+ * still used wherever only the block-name sequence matters.
184
+ */
185
+ export function findBlockPathDetailed(graph, fromTag, toTag) {
186
+ if (fromTag === toTag)
187
+ return [];
188
+ const queue = [{ tag: fromTag, path: [] }];
189
+ const visited = new Set([fromTag]);
190
+ while (queue.length > 0) {
191
+ const { tag, path } = queue.shift();
192
+ for (const edge of graph.edges) {
193
+ if (edge.from !== tag && edge.from !== "*")
194
+ continue;
195
+ const nextPath = [...path, { block: edge.block, to: edge.to }];
196
+ if (edge.to === toTag)
197
+ return nextPath;
198
+ if (!visited.has(edge.to)) {
199
+ visited.add(edge.to);
200
+ queue.push({ tag: edge.to, path: nextPath });
201
+ }
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+ /**
207
+ * Same real, confirmed reload-caching bug `auto-explore.ts`'s own copy of
208
+ * this function fixes (see its own comment) - `discoverGraph` (used for
209
+ * `this.graph`/path-finding) and `loadBlockLibrary` (used for `this.library`/
210
+ * the live menu) are two independent importers, each with their own
211
+ * duplicate `importModule`, so each needed the same mtime-based cache-bust
212
+ * fix separately - fixing only one left the other still serving a session's
213
+ * `reloadLibrary()` a stale (or, for a brand-new file only ever discovered
214
+ * through THIS path, silently absent) graph.
215
+ */
216
+ async function importModule(filePath) {
217
+ const resolved = resolve(filePath);
218
+ const url = new URL(`file://${resolved}`);
219
+ const mtimeMs = statSync(resolved).mtimeMs;
220
+ url.searchParams.set("t", String(mtimeMs));
221
+ return (await import(url.href));
222
+ }
223
+ function isBlockLike(val) {
224
+ if (val === null || typeof val !== "object")
225
+ return false;
226
+ const obj = val;
227
+ if (typeof obj.name !== "string")
228
+ return false;
229
+ const instruction = obj.instruction;
230
+ return instruction !== null && typeof instruction === "object" && typeof instruction?.act === "function";
231
+ }
232
+ /** Set by `defineNavBlock` (non-enumerable) - see src/engine.ts. */
233
+ function isNavBlockMarked(val) {
234
+ const kind = val.__waygraphKind;
235
+ return kind === "nav" || kind === "page";
236
+ }
237
+ /** Set by `defineAssertBlock` (non-enumerable) - see src/engine.ts. */
238
+ function isAssertBlockMarked(val) {
239
+ return val.__waygraphKind === "assert";
240
+ }
241
+ /**
242
+ * Resolves `typeName` to every literal Checkpoint tag it can mean, within
243
+ * one `*.states.ts` file's own source text - `type X = Checkpoint<"Tag">`
244
+ * directly, or `type X = A | B | Checkpoint<"C">` (a union of other type
245
+ * names and/or inline Checkpoint literals) recursively. A cycle or unknown
246
+ * identifier just contributes no tags, rather than throwing - callers treat
247
+ * an empty result as "couldn't resolve," not a crash.
248
+ */
249
+ function resolveCheckpointTags(statesSrc, typeName, seen = new Set()) {
250
+ if (seen.has(typeName))
251
+ return [];
252
+ seen.add(typeName);
253
+ const direct = new RegExp(`type\\s+${typeName}\\s*=\\s*Checkpoint<"([^"]+)">`).exec(statesSrc);
254
+ if (direct)
255
+ return [direct[1]];
256
+ const aliased = new RegExp(`type\\s+${typeName}\\s*=\\s*([^;]+);`).exec(statesSrc);
257
+ if (!aliased)
258
+ return [];
259
+ const tags = [];
260
+ for (const member of aliased[1].split("|").map((s) => s.trim()).filter(Boolean)) {
261
+ const inline = /^Checkpoint<"([^"]+)">$/.exec(member);
262
+ if (inline) {
263
+ tags.push(inline[1]);
264
+ }
265
+ else if (/^[A-Za-z_$][\w]*$/.test(member)) {
266
+ tags.push(...resolveCheckpointTags(statesSrc, member, seen));
267
+ }
268
+ }
269
+ return tags;
270
+ }
271
+ /**
272
+ * Finds which file `blockFilePath`'s own `import type { ... }` statements
273
+ * say `typeName` comes from, resolved to a real path on disk (`.js` ->
274
+ * `.ts`, matching this project's own NodeNext import convention). Only
275
+ * follows relative imports - `typeName` imported from a package (e.g.
276
+ * `waygraph` itself, for `Checkpoint`) has no project file to read.
277
+ */
278
+ function findTypeSourceFile(blockFilePath, typeName) {
279
+ const src = readFileSync(blockFilePath, "utf-8");
280
+ const importRe = /import\s+type\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
281
+ let m;
282
+ while ((m = importRe.exec(src)) !== null) {
283
+ const names = m[1].split(",").map((s) => s.trim());
284
+ if (!names.includes(typeName))
285
+ continue;
286
+ const importPath = m[2];
287
+ if (!importPath.startsWith("."))
288
+ continue;
289
+ const resolved = join(dirname(blockFilePath), importPath.replace(/\.js$/, ".ts"));
290
+ if (existsSync(resolved))
291
+ return resolved;
292
+ }
293
+ return null;
294
+ }
295
+ /** `findTypeSourceFile` + `resolveCheckpointTags`, chained - `[]` on any failure along the way. */
296
+ function resolveTypeToTags(blockFilePath, typeName) {
297
+ const statesFile = findTypeSourceFile(blockFilePath, typeName);
298
+ if (!statesFile)
299
+ return [];
300
+ return resolveCheckpointTags(readFileSync(statesFile, "utf-8"), typeName);
301
+ }
302
+ /**
303
+ * Resolves one `defineBlock<In, Out>` generic argument, as captured by
304
+ * `extractDefineBlockGenerics` - either a plain type identifier (goes
305
+ * through `resolveTypeToTags`), an inline `Checkpoint<"Tag">` literal, or
306
+ * the documented wildcard forms `Checkpoint<string>`/`Checkpoint<any>` (a
307
+ * Block reused across several entry points, e.g. this project's own
308
+ * `AddToCartBlock` - reachable from anywhere, same as a NavBlock's own `"*"`).
309
+ */
310
+ function resolveGenericArg(blockFilePath, arg) {
311
+ if (arg === "Checkpoint<string>" || arg === "Checkpoint<any>")
312
+ return ["*"];
313
+ const inline = /^Checkpoint<"([^"]+)">$/.exec(arg);
314
+ if (inline)
315
+ return [inline[1]];
316
+ return resolveTypeToTags(blockFilePath, arg);
317
+ }
318
+ /** Every `defineBlock` / `defineEffectBlock` / `defineMemEffectBlock` `<In, Out>` call site. */
319
+ function extractDefineBlockGenerics(src) {
320
+ // Each argument is either a plain identifier or an inline `Checkpoint<...>`
321
+ // (its own angle brackets, so the outer defineX<...> pair alone won't
322
+ // balance correctly against a naive greedy match) - captured as a single
323
+ // alternation so both shapes come out of the same two capture groups.
324
+ // Effect helpers are TypeScript salt over defineBlock - same In/Out edges.
325
+ const arg = String.raw `(Checkpoint<[^>]*>|[A-Za-z_$][\w]*)`;
326
+ const re = new RegExp(String.raw `define(?:(?:Mem)?Effect|Method|Action)?Block<\s*${arg}\s*,\s*${arg}\s*>`, "g");
327
+ // Matches: defineBlock | defineMethodBlock | defineActionBlock | defineEffectBlock |
328
+ // defineMemEffectBlock (Nav / NavClick / Page use runtime markers, not this regex.)
329
+ const calls = [];
330
+ let m;
331
+ while ((m = re.exec(src)) !== null) {
332
+ calls.push({ in: m[1], out: m[2] });
333
+ }
334
+ return calls;
335
+ }
336
+ /**
337
+ * Walks every `*.block.ts` file under `projectDir` and builds the app's own
338
+ * state graph. Shares the same in-process-import limitation `waygraph
339
+ * check` already has: a target project whose own `waygraph`/`playwright`
340
+ * are genuinely separate installs (not symlinked into this same
341
+ * node_modules resolution) can crash Playwright's own "second copy" guard
342
+ * on import - acceptable for an introspection command the same way it
343
+ * already is for `check`, unlike `chain`, which spawns a child process
344
+ * specifically because it also EXECUTES Blocks, not just imports them.
345
+ */
346
+ export async function discoverGraph(projectDir, opts) {
347
+ const select = opts?.blocksSelect;
348
+ const nodeTags = new Set();
349
+ const edges = [];
350
+ const skipped = [];
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
+ }));
359
+ }
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);
365
+ }
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
+ }
372
+ continue;
373
+ }
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;
391
+ }
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;
409
+ }
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" });
428
+ }
429
+ continue;
430
+ }
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;
439
+ }
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
+ }
458
+ }
459
+ }
460
+ }
461
+ }
462
+ return {
463
+ nodes: [...nodeTags].sort().map((checkpoint) => ({ checkpoint })),
464
+ edges,
465
+ skipped,
466
+ };
467
+ }
@@ -0,0 +1,18 @@
1
+ export interface MapViolation {
2
+ file: string;
3
+ block: string;
4
+ reason: string;
5
+ }
6
+ export interface MapNode {
7
+ block: string;
8
+ checkpoint: string | undefined;
9
+ file: string;
10
+ kind: "nav" | "page" | "other";
11
+ }
12
+ export interface MapCheckResult {
13
+ mapRoot: string;
14
+ hasMapDir: boolean;
15
+ nodes: MapNode[];
16
+ violations: MapViolation[];
17
+ }
18
+ export declare function checkMap(projectDir: string): Promise<MapCheckResult>;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `waygraph map` - enforcement for the Waygraph Map folder convention.
3
+ * Real, direct user correction: this is not just a naming preference, it's
4
+ * a forced structure. Requires `src/map/` to exist, and for every Nav/Page
5
+ * Block whose `url` is a plain string, the folder path (relative to
6
+ * `src/map/`, with `(group)` segments excluded - those are intentionally
7
+ * invisible in the real URL, same as Next.js route groups) must verbatim
8
+ * match the URL's own real pathname segments, in order. Catches exactly
9
+ * the class of bug a live session found: `(auth)/signin/` grouping a page
10
+ * whose real URL is `/signin`, not `/auth/signin` - a fabricated grouping
11
+ * that never matched the real site.
12
+ *
13
+ * Self-contained (no import from cli.ts), same reasoning as graph.ts's own
14
+ * header comment: cli.ts runs main() unconditionally at module load.
15
+ */
16
+ import { existsSync, readdirSync, statSync } from "node:fs";
17
+ import { join, dirname, relative, sep } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ function walkBlockFiles(dir) {
20
+ const results = [];
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(dir, { withFileTypes: true });
24
+ }
25
+ catch {
26
+ return results;
27
+ }
28
+ for (const entry of entries) {
29
+ const full = join(dir, entry.name);
30
+ if (entry.isDirectory()) {
31
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git")
32
+ continue;
33
+ results.push(...walkBlockFiles(full));
34
+ }
35
+ else if (/\.block\.ts$/.test(entry.name)) {
36
+ results.push(full);
37
+ }
38
+ }
39
+ return results;
40
+ }
41
+ function isBlockLike(val) {
42
+ if (val === null || typeof val !== "object")
43
+ return false;
44
+ const obj = val;
45
+ if (typeof obj.name !== "string")
46
+ return false;
47
+ const instruction = obj.instruction;
48
+ return instruction !== null && typeof instruction === "object" && typeof instruction?.act === "function";
49
+ }
50
+ async function importModule(filePath) {
51
+ const url = pathToFileURL(filePath);
52
+ const mtimeMs = statSync(filePath).mtimeMs;
53
+ url.searchParams.set("t", String(mtimeMs));
54
+ return (await import(url.href));
55
+ }
56
+ /**
57
+ * `(group)` segments never appear in the real URL (Next.js route-group
58
+ * convention this whole system is explicitly modeled on) - excluded from
59
+ * the comparison, not just cosmetically ignored by discoverGraph.
60
+ *
61
+ * A leading-underscore segment (`_methods/`, same as Next.js's own private-
62
+ * folder convention) is the fixed machinery belonging to ONE endpoint - not
63
+ * a route segment of its own - excluded from the comparison for the same
64
+ * reason `(group)` is: real direct request answering "so i have clear
65
+ * distinction of endpoints vs block folders" - a bare (non-underscore,
66
+ * non-parenthesized) folder name always means a real child Checkpoint/URL
67
+ * segment, no exceptions, so `_methods/foo.block.ts` never gets mistaken
68
+ * for a sibling endpoint the way a bare `methods/` folder structurally
69
+ * could be.
70
+ */
71
+ function realSegments(relPath) {
72
+ return dirname(relPath)
73
+ .split(sep)
74
+ .filter((s) => s !== "." && s !== "" && !/^\(.*\)$/.test(s) && !s.startsWith("_"));
75
+ }
76
+ export async function checkMap(projectDir) {
77
+ const mapRoot = join(projectDir, "src", "map");
78
+ if (!existsSync(mapRoot)) {
79
+ return { mapRoot, hasMapDir: false, nodes: [], violations: [] };
80
+ }
81
+ const files = walkBlockFiles(mapRoot);
82
+ const nodes = [];
83
+ const violations = [];
84
+ for (const file of files) {
85
+ const relFile = relative(projectDir, file);
86
+ let mod;
87
+ try {
88
+ mod = await importModule(file);
89
+ }
90
+ catch (err) {
91
+ violations.push({
92
+ file: relFile,
93
+ block: "(import failed)",
94
+ reason: err instanceof Error ? err.message.split("\n")[0] : String(err),
95
+ });
96
+ continue;
97
+ }
98
+ for (const exported of Object.values(mod)) {
99
+ if (!isBlockLike(exported))
100
+ continue;
101
+ const waygraphKind = exported.__waygraphKind;
102
+ const kind = waygraphKind === "nav" ? "nav" : waygraphKind === "page" ? "page" : "other";
103
+ const navUrl = exported.__waygraphNavUrl;
104
+ let checkpoint;
105
+ const instruction = exported.instruction;
106
+ if (instruction?.resolve && typeof instruction.resolve === "function") {
107
+ try {
108
+ const out = (await instruction.resolve(undefined));
109
+ checkpoint = out?.__state;
110
+ }
111
+ catch {
112
+ /* resolve() needing real input (a Method Block) - not position-defining, skip */
113
+ }
114
+ }
115
+ nodes.push({ block: exported.name, checkpoint, file: relFile, kind });
116
+ if (typeof navUrl !== "string")
117
+ continue; // only statically-urled Nav/Page Blocks are checkable
118
+ let pathname;
119
+ try {
120
+ pathname = new URL(navUrl).pathname;
121
+ }
122
+ catch {
123
+ continue; // a relative/templated URL - not verbatim-checkable
124
+ }
125
+ const urlSegments = pathname.split("/").filter(Boolean);
126
+ const folderSegments = realSegments(relative(mapRoot, file));
127
+ const matches = urlSegments.length === folderSegments.length && urlSegments.every((s, i) => s === folderSegments[i]);
128
+ if (!matches) {
129
+ violations.push({
130
+ file: relFile,
131
+ block: exported.name,
132
+ reason: `folder path "${folderSegments.join("/")}" doesn't verbatim-match the real URL path ` +
133
+ `"${urlSegments.join("/")}" (${navUrl}) - (group) segments are excluded from this comparison, ` +
134
+ "everything else must match exactly, same as Next.js file-based routing",
135
+ });
136
+ }
137
+ }
138
+ }
139
+ return { mapRoot, hasMapDir: true, nodes, violations };
140
+ }
@@ -0,0 +1,14 @@
1
+ export type PracticeKind = "wildcard-checkpoint" | "assert-no-type-arg" | "multi-input" | "combined-action" | "empty-verify" | "overcomplex-selector" | "inline-selector-should-use-sel-file";
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[];