@delurio/cli 0.0.0 → 0.1.1

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/nav.js ADDED
@@ -0,0 +1,566 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { evaluate, identifiers, parseExpr } from './expr.js';
5
+ import { GraphError } from './graph.js';
6
+ const PAGE_KINDS = ['screen', 'placeholder', 'missing', 'redirect', 'external'];
7
+ const HOWS = ['auto', 'menu', 'picker', 'user', 'link', 'button'];
8
+ // an entry of the frame (menu, scope picker, person row) is drawn even around a page that refuses the person
9
+ const FRAME_LEVEL = ['menu', 'picker', 'user'];
10
+ export const frameLevel = (e) => FRAME_LEVEL.includes(e.how);
11
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
12
+ // nav.yaml may extend a generated file (nav.extracted.yaml): its pages, guards and entries come first, and the hand-written
13
+ // file adds to them or overrides single fields of a page. A guard may not be defined twice with different text.
14
+ function mergeDocs(base, hand) {
15
+ const pages = {};
16
+ for (const [id, p] of Object.entries(base.pages ?? {}))
17
+ pages[id] = { ...p, ...(own(hand.pages ?? {}, id) ? hand.pages[id] : {}) };
18
+ for (const [id, p] of Object.entries(hand.pages ?? {})) {
19
+ if (own(pages, id))
20
+ continue;
21
+ if (p.route === undefined)
22
+ throw new GraphError(`page ${id} is not in the extracted file and gives no route (a misspelt override?)`);
23
+ pages[id] = p;
24
+ }
25
+ const guards = { ...(base.guards ?? {}) };
26
+ for (const [name, src] of Object.entries(hand.guards ?? {})) {
27
+ if (own(guards, name) && guards[name] !== src)
28
+ throw new GraphError(`guard ${name} is written in both the extracted file and nav.yaml, with different text`);
29
+ guards[name] = src;
30
+ }
31
+ return { ...hand, pages, guards, entries: [...(base.entries ?? []), ...(hand.entries ?? [])], source: { ...(base.source ?? {}), ...(hand.source ?? {}) } };
32
+ }
33
+ // `read` takes a path relative to `dir`; the revision diff passes one that reads a git revision instead of the disk
34
+ export function loadNav(dir, locale, read = (file) => readFileSync(join(dir, file), 'utf8')) {
35
+ const raw = parse(read('nav.yaml'));
36
+ const doc = raw.extends === undefined ? raw : mergeDocs(parse(read(raw.extends)), raw);
37
+ const warnings = [];
38
+ if (doc.kind !== 'nav')
39
+ throw new GraphError(`${join(dir, 'nav.yaml')} is not a navigation graph (kind: nav is missing)`);
40
+ const catalogFile = locale === undefined ? doc.catalog : doc.catalog.replace(`${doc.locale}.json`, `${locale}.json`);
41
+ const catalog = JSON.parse(read(catalogFile));
42
+ const guards = doc.guards ?? {};
43
+ const facts = doc.facts ?? {};
44
+ const sets = doc.sets ?? {};
45
+ const known = (name) => own(facts, name) || own(guards, name);
46
+ const checkGuard = (src, where) => {
47
+ let e;
48
+ try {
49
+ e = parseExpr(src);
50
+ }
51
+ catch (err) {
52
+ throw new GraphError(`${where}: ${err.message}`);
53
+ }
54
+ for (const name of identifiers(e))
55
+ if (!known(name))
56
+ throw new GraphError(`${where}: "${name}" is neither a fact nor a named guard`);
57
+ };
58
+ for (const [name, src] of Object.entries(guards))
59
+ checkGuard(src, `guard ${name}`);
60
+ const looksLikeKey = (s) => /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$/.test(s);
61
+ const resolveLabel = (s, where) => {
62
+ if (own(catalog, s))
63
+ return { text: catalog[s], key: s };
64
+ if (looksLikeKey(s))
65
+ warnings.push(`${where}: "${s}" looks like a catalog key but the catalog has no such key`);
66
+ return { text: s, key: null };
67
+ };
68
+ const frames = doc.frames ?? {};
69
+ if (Object.keys(frames).length === 0)
70
+ throw new GraphError('a navigation graph declares its frames (frames:)');
71
+ for (const [name, f] of Object.entries(frames)) {
72
+ if (f.shows !== undefined && !own(facts, f.shows.fact))
73
+ throw new GraphError(`frame ${name} shows the menu named by ${f.shows.fact}, which is not a fact`);
74
+ }
75
+ const pages = {};
76
+ for (const [id, p] of Object.entries(doc.pages)) {
77
+ if (!PAGE_KINDS.includes(p.kind))
78
+ throw new GraphError(`page ${id}: unknown kind ${p.kind}`);
79
+ if (!own(frames, p.frame))
80
+ throw new GraphError(`page ${id}: unknown frame ${p.frame}`);
81
+ if (p.opensFor !== undefined)
82
+ checkGuard(p.opensFor, `page ${id} opensFor`);
83
+ const title = p.title === undefined ? { text: id, key: null } : resolveLabel(p.title, `page ${id}`);
84
+ pages[id] = {
85
+ id,
86
+ route: p.route,
87
+ title: title.text,
88
+ titleKey: title.key,
89
+ kind: p.kind,
90
+ frame: p.frame,
91
+ source: p.source ?? null,
92
+ opensFor: p.opensFor ?? null,
93
+ pageCodes: p.pageCodes ?? null,
94
+ evidence: p.evidence ?? null,
95
+ refusal: p.refusal ?? null,
96
+ };
97
+ }
98
+ if (pages.start === undefined)
99
+ throw new GraphError('a navigation graph needs a page called start');
100
+ const entries = [];
101
+ const specs = doc.entries ?? [];
102
+ specs.forEach((s, spec) => {
103
+ const where = `entry ${spec + 1} (${String(s.to)})`;
104
+ if (!HOWS.includes(s.how))
105
+ throw new GraphError(`${where}: unknown how ${s.how}`);
106
+ if (pages[s.to] === undefined)
107
+ throw new GraphError(`${where}: goes to ${s.to}, which is not a page`);
108
+ if (s.when !== undefined)
109
+ checkGuard(s.when, `${where} when`);
110
+ const label = s.label === undefined ? { text: '', key: null } : resolveLabel(s.label, where);
111
+ if (label.text === '' && s.how !== 'auto')
112
+ throw new GraphError(`${where}: needs a label`);
113
+ for (const token of Array.isArray(s.from) ? s.from : [s.from]) {
114
+ const sources = [];
115
+ let ofFrame = null;
116
+ if (token.startsWith('frame:')) {
117
+ const frame = token.slice('frame:'.length);
118
+ if (!own(frames, frame))
119
+ throw new GraphError(`${where}: unknown frame in ${token}`);
120
+ ofFrame = frame;
121
+ for (const p of Object.values(pages)) {
122
+ if (p.frame === frame)
123
+ sources.push({ page: p.id, implicit: null });
124
+ // a page whose frame shows the menu of another frame is drawn with this frame's menu when the person's fact names it
125
+ const shows = frames[p.frame].shows;
126
+ if (shows !== undefined && p.frame !== frame && frames[frame].menu !== undefined)
127
+ sources.push({ page: p.id, implicit: `${shows.fact} == '${frame}'` });
128
+ }
129
+ if (sources.length === 0)
130
+ throw new GraphError(`${where}: no page is in ${token}`);
131
+ }
132
+ else {
133
+ if (pages[token] === undefined)
134
+ throw new GraphError(`${where}: starts at ${token}, which is not a page`);
135
+ sources.push({ page: token, implicit: null });
136
+ }
137
+ for (const src of sources) {
138
+ if (src.page === s.to && frameLevelHow(s.how))
139
+ continue; // the page you are on is not a way to itself
140
+ const parts = [src.implicit, s.when ?? null].filter((x) => x !== null);
141
+ entries.push({
142
+ id: s.id ?? `${src.page}>${s.to}#${spec + 1}`,
143
+ spec,
144
+ from: src.page,
145
+ ofFrame,
146
+ to: s.to,
147
+ how: s.how,
148
+ label: label.text,
149
+ labelKey: label.key,
150
+ when: parts.length === 0 ? null : parts.length === 1 ? parts[0] : parts.map((x) => `(${x})`).join(' and '),
151
+ clicks: s.clicks ?? (s.how === 'auto' ? 0 : 1),
152
+ data: s.data === true,
153
+ back: s.back === true,
154
+ order: entries.length,
155
+ });
156
+ }
157
+ }
158
+ });
159
+ const personas = Object.entries(doc.personas).map(([id, p]) => {
160
+ const f = {};
161
+ for (const [name, spec] of Object.entries(facts)) {
162
+ if (!own(p.facts, name))
163
+ throw new GraphError(`persona ${id} gives no value for ${name}`);
164
+ let v = p.facts[name];
165
+ if (spec.of !== undefined) {
166
+ if (typeof v === 'string') {
167
+ if (!own(sets, v))
168
+ throw new GraphError(`persona ${id}: ${v} is not a named set`);
169
+ v = sets[v];
170
+ }
171
+ if (!Array.isArray(v))
172
+ throw new GraphError(`persona ${id}: ${name} must be a list`);
173
+ for (const item of v)
174
+ if (!spec.of.includes(String(item)))
175
+ throw new GraphError(`persona ${id}: ${String(item)} is not one of the values of ${name}`);
176
+ }
177
+ else if (spec.values !== undefined && !spec.values.includes(v)) {
178
+ throw new GraphError(`persona ${id}: ${String(v)} is not a value of ${name}`);
179
+ }
180
+ f[name] = v;
181
+ }
182
+ for (const name of Object.keys(p.facts))
183
+ if (!own(facts, name))
184
+ throw new GraphError(`persona ${id} gives ${name}, which is not a declared fact`);
185
+ return { id, title: p.title, say: p.say, facts: f };
186
+ });
187
+ const notModeled = doc.notModeled ?? [];
188
+ notModeled.forEach((x, i) => {
189
+ if (typeof x !== 'string')
190
+ throw new GraphError(`notModeled item ${i + 1} is not text (a colon followed by a space turns a line into a map: quote it)`);
191
+ });
192
+ const goals = doc.goals ?? [];
193
+ for (const goal of goals) {
194
+ if (pages[goal.page] === undefined)
195
+ throw new GraphError(`goal ${goal.id}: ${goal.page} is not a page`);
196
+ for (const who of goal.for)
197
+ if (!personas.some((p) => p.id === who))
198
+ throw new GraphError(`goal ${goal.id}: ${who} is not a persona`);
199
+ }
200
+ return {
201
+ g: {
202
+ dir,
203
+ adapter: doc.extract?.adapter ?? null,
204
+ frames,
205
+ menuFrames: Object.entries(frames).filter(([, f]) => f.menu !== undefined).map(([name]) => name),
206
+ id: doc.id,
207
+ title: doc.title,
208
+ status: doc.status,
209
+ summary: (doc.summary ?? '').trim(),
210
+ locale: locale ?? doc.locale,
211
+ source: doc.source ?? {},
212
+ facts,
213
+ guards,
214
+ pages,
215
+ entries,
216
+ specs,
217
+ personas,
218
+ goals,
219
+ notModeled,
220
+ catalog,
221
+ },
222
+ warnings,
223
+ };
224
+ }
225
+ function frameLevelHow(how) {
226
+ return FRAME_LEVEL.includes(how);
227
+ }
228
+ function lookupFor(g, p, depth) {
229
+ return (name) => {
230
+ if (own(p.facts, name))
231
+ return p.facts[name];
232
+ const guard = g.guards[name];
233
+ if (guard === undefined)
234
+ throw new GraphError(`unknown name "${name}"`);
235
+ if (depth > 8)
236
+ throw new GraphError(`guard ${name} nests too deep`);
237
+ return evaluate(parseExpr(guard), lookupFor(g, p, depth + 1));
238
+ };
239
+ }
240
+ export function holds(g, p, expr) {
241
+ return expr === null ? true : Boolean(evaluate(parseExpr(expr), lookupFor(g, p, 0)));
242
+ }
243
+ export const refusedFor = (g, p, page) => {
244
+ const guard = g.pages[page].opensFor;
245
+ return guard !== null && !holds(g, p, guard);
246
+ };
247
+ // Fewest clicks from "start"; ties go to the entry written first. Around a page that refuses the person only the frame's own entries work.
248
+ export function walk(g, p) {
249
+ const best = new Map();
250
+ const done = new Set();
251
+ const index = (id) => Object.keys(g.pages).indexOf(id);
252
+ best.set('start', { page: 'start', clicks: 0, via: null, prev: null, usesData: false });
253
+ for (;;) {
254
+ let pick = null;
255
+ for (const r of best.values()) {
256
+ if (done.has(r.page))
257
+ continue;
258
+ if (pick === null || r.clicks < pick.clicks || (r.clicks === pick.clicks && index(r.page) < index(pick.page)))
259
+ pick = r;
260
+ }
261
+ if (pick === null)
262
+ break;
263
+ done.add(pick.page);
264
+ const refused = refusedFor(g, p, pick.page);
265
+ for (const e of g.entries) {
266
+ if (e.from !== pick.page)
267
+ continue;
268
+ if (refused && !frameLevel(e))
269
+ continue;
270
+ if (!holds(g, p, e.when))
271
+ continue;
272
+ const clicks = pick.clicks + e.clicks;
273
+ const held = best.get(e.to);
274
+ if (held === undefined || clicks < held.clicks)
275
+ best.set(e.to, { page: e.to, clicks, via: e, prev: pick.page, usesData: pick.usesData || e.data });
276
+ }
277
+ }
278
+ return best;
279
+ }
280
+ export function pathTo(reach, page) {
281
+ const out = [];
282
+ let cur = reach.get(page);
283
+ while (cur !== undefined && cur.via !== null) {
284
+ out.unshift(cur.via);
285
+ cur = cur.prev === null ? undefined : reach.get(cur.prev);
286
+ }
287
+ return out;
288
+ }
289
+ export function viewOf(g, p) {
290
+ const reach = walk(g, p);
291
+ const standing = {};
292
+ for (const id of Object.keys(g.pages)) {
293
+ if (!reach.has(id))
294
+ standing[id] = 'unreachable';
295
+ else if (refusedFor(g, p, id))
296
+ standing[id] = 'refused';
297
+ else
298
+ standing[id] = g.pages[id].kind === 'placeholder' ? 'placeholder' : g.pages[id].kind === 'missing' ? 'missing' : 'ok';
299
+ }
300
+ const auto = g.entries.find((e) => e.from === 'start' && e.how === 'auto' && holds(g, p, e.when));
301
+ // a start page that is a screen (not a redirect) is where the person lands when no rule sends them on
302
+ const landing = auto !== undefined ? auto.to : g.pages.start.kind === 'redirect' ? null : 'start';
303
+ return { persona: p, reach, standing, landing };
304
+ }
305
+ export const frameReached = (g, v, frame) => Object.values(g.pages).some((pg) => pg.frame === frame && v.standing[pg.id] !== 'unreachable');
306
+ export function menuRows(g, views) {
307
+ const seen = new Set();
308
+ const rows = [];
309
+ for (const e of g.entries) {
310
+ if (e.how !== 'menu' || seen.has(e.spec))
311
+ continue;
312
+ seen.add(e.spec);
313
+ const frame = e.ofFrame ?? g.pages[e.from].frame;
314
+ const frameKnown = views.map((v) => frameReached(g, v, frame));
315
+ rows.push({
316
+ spec: e.spec,
317
+ frame,
318
+ entry: e,
319
+ frameKnown,
320
+ cells: views.map((v, i) => {
321
+ if (!frameKnown[i])
322
+ return { shown: false, standing: null };
323
+ const shown = holds(g, v.persona, g.specs[e.spec].when ?? null);
324
+ return { shown, standing: shown ? standingAfterMenu(g, v, e.to) : null };
325
+ }),
326
+ });
327
+ }
328
+ return rows;
329
+ }
330
+ function standingAfterMenu(g, v, page) {
331
+ if (refusedFor(g, v.persona, page))
332
+ return 'refused';
333
+ return g.pages[page].kind === 'placeholder' ? 'placeholder' : g.pages[page].kind === 'missing' ? 'missing' : 'ok';
334
+ }
335
+ const lower = (s) => s.toLocaleLowerCase('tr');
336
+ const word = (g, key) => g.catalog[key] ?? key;
337
+ const quote = (s) => `“${s}”`;
338
+ const joinWords = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`);
339
+ // the words on the way in and on arrival name the same thing: one contains the other, or they share a stem
340
+ // (Turkish suffixes: hesap, hesabım)
341
+ function similar(a, b) {
342
+ if (a.includes(b) || b.includes(a))
343
+ return true;
344
+ let i = 0;
345
+ while (i < a.length && i < b.length && a[i] === b[i])
346
+ i += 1;
347
+ return i >= 4 && i >= 0.6 * Math.min(a.length, b.length);
348
+ }
349
+ export function checkGoals(g, views) {
350
+ const out = [];
351
+ for (const goal of g.goals) {
352
+ for (const who of goal.for) {
353
+ const v = views.find((x) => x.persona.id === who);
354
+ if (v === undefined)
355
+ continue;
356
+ const standing = v.standing[goal.page];
357
+ const clicks = v.reach.get(goal.page)?.clicks ?? null;
358
+ const outcome = standing === 'unreachable' ? 'cannot reach' : standing === 'refused' ? 'refused' : standing === 'placeholder' ? 'not built' : standing === 'missing' ? 'does not exist' : (clicks ?? 0) > goal.within ? 'too many clicks' : 'ok';
359
+ out.push({ goal, persona: v.persona, outcome, clicks });
360
+ }
361
+ }
362
+ return out;
363
+ }
364
+ export function findings(g, views, goals) {
365
+ const out = [];
366
+ const rows = menuRows(g, views);
367
+ const personaTitle = (i) => views[i].persona.title;
368
+ const who = (xs) => joinWords(xs.map(quote));
369
+ const menuName = (frame) => (g.frames[frame].menu ?? frame).toLowerCase();
370
+ // 1. the first page a person sees is not built
371
+ const landing = views.filter((v) => v.landing !== null && g.pages[v.landing].kind === 'placeholder');
372
+ if (landing.length > 0) {
373
+ const targets = [...new Set(landing.map((v) => v.landing))];
374
+ out.push({
375
+ level: 'problem',
376
+ code: 'landing-not-built',
377
+ say: `${who(landing.map((v) => v.persona.title))} ${landing.length === 1 ? 'lands' : 'land'} on a page that says it is not ready.`,
378
+ detail: targets.map((t) => {
379
+ const people = landing.filter((v) => v.landing === t).map((v) => v.persona.title);
380
+ return `“/” sends ${who(people)} to ${quote(g.pages[t].title)} (${g.pages[t].route}), which answers “${word(g, 'Shell.placeholder')}”`;
381
+ }),
382
+ });
383
+ }
384
+ // 2. the menu offers an entry and the page behind it refuses
385
+ const gated = [...new Set(rows.filter((r) => /\bheld\b/.test(g.specs[r.spec].when ?? '')).map((r) => r.entry.label))];
386
+ const refusedLines = [];
387
+ views.forEach((v, i) => {
388
+ for (const frame of g.menuFrames) {
389
+ const inFrame = rows.filter((r) => r.frame === frame && r.cells[i].shown);
390
+ const of = (s) => inFrame.filter((r) => r.cells[i].standing === s);
391
+ if (of('refused').length === 0)
392
+ continue;
393
+ const parts = [`${of('ok').length} open`, `${of('refused').length} refuse (${of('refused').map((r) => quote(r.entry.label)).join(', ')})`];
394
+ if (of('placeholder').length > 0)
395
+ parts.push(`${of('placeholder').length} are not built`);
396
+ if (of('missing').length > 0)
397
+ parts.push(`${of('missing').length} lead to a page that does not exist`);
398
+ refusedLines.push(`${personaTitle(i)}: the ${menuName(frame)} has ${inFrame.length} ${inFrame.length === 1 ? 'entry' : 'entries'}; ${parts.join(', ')}.`);
399
+ }
400
+ });
401
+ if (refusedLines.length > 0) {
402
+ out.push({
403
+ level: 'problem',
404
+ code: 'menu-refused',
405
+ say: `The menu offers entries whose page then refuses the person. ${gated.length === 0 ? 'No entry is drawn by permission' : `Only ${who(gated)} ${gated.length === 1 ? 'is' : 'are'} drawn by permission`}; every other entry is drawn for everyone and the page decides.`,
406
+ detail: refusedLines,
407
+ });
408
+ }
409
+ // 2b. a link or button drawn on a page the person can open leads to a page that refuses them
410
+ const deadLinks = new Map();
411
+ views.forEach((v) => {
412
+ for (const e of g.entries) {
413
+ if (e.how !== 'link' && e.how !== 'button')
414
+ continue;
415
+ if (v.standing[e.from] !== 'ok' && v.standing[e.from] !== 'placeholder')
416
+ continue;
417
+ if (!holds(g, v.persona, e.when) || !refusedFor(g, v.persona, e.to))
418
+ continue;
419
+ const to = g.pages[e.to];
420
+ deadLinks.set(`${v.persona.id}|${e.from}|${e.to}|${e.label}`, `${v.persona.title}: ${quote(e.label)} on ${quote(g.pages[e.from].title)} leads to ${quote(to.title)}, which ${to.refusal ?? 'refuses them'}.`);
421
+ }
422
+ });
423
+ if (deadLinks.size > 0) {
424
+ out.push({ level: 'problem', code: 'link-refused', say: 'A link or button is drawn for a person whose destination then refuses them.', detail: [...deadLinks.values()] });
425
+ }
426
+ // 3. a menu entry opens a page that is not built
427
+ const unbuilt = new Map();
428
+ rows.forEach((r) => {
429
+ if (g.pages[r.entry.to].kind !== 'placeholder')
430
+ return;
431
+ const hit = unbuilt.get(r.entry.to) ?? { labels: new Set(), who: new Set() };
432
+ r.cells.forEach((c, i) => {
433
+ if (c.shown)
434
+ hit.who.add(personaTitle(i));
435
+ });
436
+ hit.labels.add(r.entry.label);
437
+ unbuilt.set(r.entry.to, hit);
438
+ });
439
+ if (unbuilt.size > 0) {
440
+ out.push({
441
+ level: 'problem',
442
+ code: 'menu-not-built',
443
+ say: `${unbuilt.size} pages that the menus offer say “${word(g, 'Shell.placeholder')}”`,
444
+ detail: [...unbuilt.entries()].map(([page, hit]) => `${[...hit.labels].map(quote).join(' / ')} (${g.pages[page].route}) is in the menu of ${who([...hit.who])}.`),
445
+ });
446
+ }
447
+ // 3b. a menu entry leads to a page that does not exist
448
+ const gone = new Map();
449
+ rows.forEach((r) => {
450
+ if (g.pages[r.entry.to].kind !== 'missing')
451
+ return;
452
+ const hit = gone.get(r.entry.to) ?? { labels: new Set(), who: new Set() };
453
+ r.cells.forEach((c, i) => {
454
+ if (c.shown)
455
+ hit.who.add(personaTitle(i));
456
+ });
457
+ hit.labels.add(r.entry.label);
458
+ gone.set(r.entry.to, hit);
459
+ });
460
+ if (gone.size > 0) {
461
+ out.push({
462
+ level: 'problem',
463
+ code: 'menu-missing',
464
+ say: `${gone.size} ${gone.size === 1 ? 'entry' : 'entries'} in the menus lead to a page that does not exist, so the server answers not found.`,
465
+ detail: [...gone.entries()].map(([page, hit]) => `${[...hit.labels].map(quote).join(' / ')} (${g.pages[page].route}) is in the menu of ${who([...hit.who])}.`),
466
+ });
467
+ }
468
+ // 4. goals that cannot be done
469
+ const failed = goals.filter((c) => c.outcome !== 'ok');
470
+ if (failed.length > 0) {
471
+ out.push({
472
+ level: 'problem',
473
+ code: 'goals',
474
+ say: `${failed.length} of ${goals.length} things people come to do fail.`,
475
+ detail: failed.map((c) => `${c.persona.title}, ${lower(c.goal.say)}: ${c.outcome === 'too many clicks' ? `${c.clicks} clicks, the limit is ${c.goal.within}` : c.outcome === 'not built' ? 'the page is not built' : c.outcome === 'does not exist' ? 'the page does not exist' : c.outcome === 'refused' ? (g.pages[c.goal.page].refusal ?? 'the page refuses them') : 'there is no way there'}.`),
476
+ });
477
+ }
478
+ // 5. pages nobody can reach
479
+ const lost = Object.values(g.pages).filter((p) => p.kind !== 'external' && p.kind !== 'redirect' && views.every((v) => v.standing[p.id] === 'unreachable'));
480
+ if (lost.length > 0) {
481
+ out.push({ level: 'problem', code: 'unreachable', say: 'Some pages cannot be reached by any kind of user.', detail: lost.map((p) => `${p.title} (${p.route})`) });
482
+ }
483
+ // 6. two entries of one menu open the same page
484
+ const dup = [];
485
+ views.forEach((v, i) => {
486
+ for (const frame of g.menuFrames) {
487
+ const shown = rows.filter((r) => r.frame === frame && r.cells[i].shown);
488
+ const byTarget = new Map();
489
+ for (const r of shown)
490
+ byTarget.set(r.entry.to, [...(byTarget.get(r.entry.to) ?? []), r.entry.label]);
491
+ for (const [page, labels] of byTarget)
492
+ if (labels.length > 1)
493
+ dup.push(`${personaTitle(i)}: ${joinWords(labels.map(quote))} both open ${g.pages[page].route}.`);
494
+ }
495
+ });
496
+ if (dup.length > 0)
497
+ out.push({ level: 'note', code: 'menu-twins', say: 'Two entries in one menu open the same page.', detail: dup });
498
+ // 7. the words on the way in are not the words on arrival
499
+ const mismatch = new Map();
500
+ for (const e of g.entries) {
501
+ const target = g.pages[e.to];
502
+ if (e.labelKey === null || e.back || e.how === 'auto' || e.how === 'picker' || target.titleKey === null)
503
+ continue;
504
+ const a = lower(e.label);
505
+ const b = lower(target.title);
506
+ if (similar(a, b))
507
+ continue;
508
+ const where = e.how === 'menu' ? `in the ${menuName(e.ofFrame ?? g.pages[e.from].frame)}` : `${e.how} on ${g.pages[e.from].title}`;
509
+ mismatch.set(`${e.label}|${e.to}|${e.how === 'menu'}`, `${quote(e.label)} (${where}) opens a page titled ${quote(target.title)}.`);
510
+ }
511
+ if (mismatch.size > 0)
512
+ out.push({ level: 'note', code: 'words-differ', say: 'A link or button is named differently from the page it opens.', detail: [...mismatch.values()].sort() });
513
+ // 8. different pages that carry the same title
514
+ const titles = new Map();
515
+ for (const p of Object.values(g.pages)) {
516
+ if (p.kind === 'redirect' || p.kind === 'external' || p.frame === 'none')
517
+ continue;
518
+ titles.set(lower(p.title), [...(titles.get(lower(p.title)) ?? []), p]);
519
+ }
520
+ const same = [...titles.values()].filter((ps) => ps.length > 1);
521
+ if (same.length > 0) {
522
+ out.push({
523
+ level: 'note',
524
+ code: 'same-title',
525
+ say: 'Different pages share a title, so two tabs or two history entries cannot be told apart.',
526
+ detail: same.map((ps) => `${quote(ps[0].title)}: ${ps.map((p) => p.route).join(' and ')}`),
527
+ });
528
+ }
529
+ return out;
530
+ }
531
+ // the permission codes a guard asks `'code' in held` about, named guards followed
532
+ export function guardCodes(g, src, depth = 0) {
533
+ const out = new Set();
534
+ const visit = (e) => {
535
+ switch (e.k) {
536
+ case 'cmp':
537
+ if (e.op === 'in' && e.l.k === 'lit' && typeof e.l.v === 'string' && e.r.k === 'id' && e.r.name === 'held')
538
+ out.add(e.l.v);
539
+ visit(e.l);
540
+ visit(e.r);
541
+ break;
542
+ case 'and':
543
+ case 'or':
544
+ visit(e.l);
545
+ visit(e.r);
546
+ break;
547
+ case 'not':
548
+ visit(e.e);
549
+ break;
550
+ case 'id': {
551
+ const guard = g.guards[e.name];
552
+ if (guard !== undefined && depth < 8)
553
+ for (const c of guardCodes(g, guard, depth + 1))
554
+ out.add(c);
555
+ break;
556
+ }
557
+ case 'list':
558
+ e.items.forEach(visit);
559
+ break;
560
+ case 'lit':
561
+ break;
562
+ }
563
+ };
564
+ visit(parseExpr(src));
565
+ return out;
566
+ }