waygraph 0.15.43 → 0.15.45
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/dist/analysis/coverage-gap.d.ts +11 -0
- package/dist/analysis/coverage-gap.js +119 -0
- package/dist/analysis/graph.d.ts +92 -0
- package/dist/analysis/graph.js +467 -0
- package/dist/analysis/map-check.d.ts +18 -0
- package/dist/analysis/map-check.js +140 -0
- package/dist/analysis/practices-check.d.ts +14 -0
- package/dist/analysis/practices-check.js +195 -0
- package/dist/auto-explore.d.ts +1 -1
- package/dist/auto-explore.js +1 -1
- package/dist/auto-session/helpers.d.ts +2 -2
- package/dist/auto-session/session.js +4 -4
- package/dist/cli/usage.d.ts +1 -1
- package/dist/cli/util.d.ts +8 -1
- package/dist/cli/util.js +9 -2
- package/dist/commands/check.js +2 -2
- package/dist/commands/map.js +1 -1
- package/dist/commands/session.js +7 -7
- package/dist/commands/traverse.js +2 -2
- package/dist/engine/branch-regression.js +2 -1
- package/dist/engine/compose.js +3 -2
- package/dist/engine/core.js +5 -4
- package/dist/engine/engine-class.js +13 -12
- package/dist/engine/flow.js +4 -3
- package/dist/engine/run-graph.js +4 -3
- package/dist/errors.d.ts +122 -0
- package/dist/errors.js +125 -0
- package/dist/highlights/shorthand.d.ts +24 -0
- package/dist/highlights/shorthand.js +30 -0
- package/dist/index.d.ts +13 -11
- package/dist/index.js +7 -6
- package/dist/mem-page.js +2 -1
- package/dist/pilot.d.ts +1 -1
- package/dist/pilot.js +1 -1
- package/dist/session/auto-explore-run.d.ts +37 -0
- package/dist/session/auto-explore-run.js +670 -0
- package/dist/session/auto-explore.d.ts +57 -0
- package/dist/session/auto-explore.js +376 -0
- package/dist/session/auto-session-ipc.d.ts +150 -0
- package/dist/session/auto-session-ipc.js +499 -0
- package/dist/session/block-inject.d.ts +21 -0
- package/dist/session/block-inject.js +97 -0
- package/dist/session/browser.d.ts +15 -0
- package/dist/session/browser.js +29 -0
- package/dist/session/overlay-beacon.d.ts +44 -0
- package/dist/session/overlay-beacon.js +85 -0
- package/dist/session/pilot.d.ts +54 -0
- package/dist/session/pilot.js +80 -0
- package/dist/traverse/coverage.d.ts +59 -0
- package/dist/traverse/coverage.js +127 -0
- package/dist/traverse/lease.d.ts +18 -0
- package/dist/traverse/lease.js +62 -0
- package/dist/traverse/run.d.ts +47 -0
- package/dist/traverse/run.js +439 -0
- package/examples/saucedemo/.waygraph-auto/00b0c55c.log +22 -0
- package/examples/saucedemo/.waygraph-auto/00e5d037.log +5 -0
- package/examples/saucedemo/.waygraph-auto/015be8f6.log +6 -0
- package/examples/saucedemo/.waygraph-auto/026e188e.log +0 -0
- package/examples/saucedemo/.waygraph-auto/050e5b55.log +0 -0
- package/examples/saucedemo/.waygraph-auto/07845ed1.log +0 -0
- package/examples/saucedemo/.waygraph-auto/096fea05.log +20 -0
- package/examples/saucedemo/.waygraph-auto/0c19e232.log +5 -0
- package/examples/saucedemo/.waygraph-auto/1122c13c.log +0 -0
- package/examples/saucedemo/.waygraph-auto/123d1cad.log +5 -0
- package/examples/saucedemo/.waygraph-auto/12ed8e28.log +0 -0
- package/examples/saucedemo/.waygraph-auto/1cb6f44c.log +5 -0
- package/examples/saucedemo/.waygraph-auto/1fdb2100.log +0 -0
- package/examples/saucedemo/.waygraph-auto/2b9b56fc.log +20 -0
- package/examples/saucedemo/.waygraph-auto/30a5c848.log +20 -0
- package/examples/saucedemo/.waygraph-auto/33a50dc0.log +0 -0
- package/examples/saucedemo/.waygraph-auto/3a208bb8.log +20 -0
- package/examples/saucedemo/.waygraph-auto/3e3c0f3b.log +0 -0
- package/examples/saucedemo/.waygraph-auto/573abedd.log +0 -0
- package/examples/saucedemo/.waygraph-auto/756fd326.log +20 -0
- package/examples/saucedemo/.waygraph-auto/78043767.log +20 -0
- package/examples/saucedemo/.waygraph-auto/976394cd.log +20 -0
- package/examples/saucedemo/.waygraph-auto/9a26134c.log +20 -0
- package/examples/saucedemo/.waygraph-auto/9d827954.log +20 -0
- package/examples/saucedemo/.waygraph-auto/a4f8d355.log +20 -0
- package/examples/saucedemo/.waygraph-auto/aa226dd0.log +22 -0
- package/examples/saucedemo/.waygraph-auto/ac5dde3d.log +0 -0
- package/examples/saucedemo/.waygraph-auto/af2fae12.log +0 -0
- package/examples/saucedemo/.waygraph-auto/b0752c49.log +0 -0
- package/examples/saucedemo/.waygraph-auto/b14811d7.log +6 -0
- package/examples/saucedemo/.waygraph-auto/c9d76e32.log +0 -0
- package/examples/saucedemo/.waygraph-auto/cae91469.log +0 -0
- package/examples/saucedemo/.waygraph-auto/cc989184.log +20 -0
- package/examples/saucedemo/.waygraph-auto/d846cd78.log +0 -0
- package/examples/saucedemo/.waygraph-auto/e5127c03.log +22 -0
- package/examples/saucedemo/.waygraph-auto/eb38d92e.log +22 -0
- package/examples/saucedemo/.waygraph-auto/ee86203e.log +0 -0
- package/package.json +1 -1
- package/templates/agents/waygraph-author.agent.md +24 -51
- package/templates/agents/waygraph-planner.agent.md +12 -5
- package/templates/scaffold/scripts/README.md +13 -0
- package/templates/scaffold/src/flows/README.md +18 -0
- package/templates/scaffold/src/map/README.md +31 -0
- package/templates/scaffold/src/states/README.md +15 -0
- package/templates/scaffold/tests/README.md +10 -0
|
@@ -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 "../session/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[];
|