yichus 0.3.2 → 0.3.3

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.
@@ -38,15 +38,16 @@ export const VOCABULARY = {
38
38
  modelRing: 'violet ring: the entry reaches the model <- entries[].model',
39
39
  reachedBy: 'under each resource in the rack: how many routes reach it, the resource drawn at that share of the routes (never smaller than its name) <- entries[].db, model, net, beside.resources',
40
40
  netRing: 'orange ring: the entry reaches the network <- entries[].net',
41
- guard: 'red diamond on the path where the check happens, its name above, failure stem below <- entries[].guards, wrappers[].guards, entries[].passed[].guards, entries[].beside.guards',
41
+ guard: 'red diamond on the path where the route can refuse, the check named above it (the program\u2019s definition it calls, "<table> row found", "<table> row\u2019s <field>" for a branch on a field of a read row, or "a test in <definition>" for a comparison or library answer that definition branches on), failure stem below; a branch no path refuses on is not drawn here <- entries[].guards (structure.scenarios[]: branches where one arm ends only in refusals), wrappers[].guards, entries[].passed[].guards, entries[].beside.guards',
42
+ policy: 'under the db, each table with its access policy as the access check proves it: own = each caller reads and writes only their own row; no row rule = the access check holds no rule over its rows, so who reads or writes which row is decided only by the code drawn on the routes; guarded = rows pass the named guard; role … only = only callers with that role reach its rows; unchecked = the access check gave this table no verdict; a policy ending \u201cbroken\u201d is one the code breaks, and \u201cnot proven\u201d one the check could not settle; counted when the tables are too many to list <- resources[kind=db].policies',
42
43
  hull: 'translucent region in the hull band: paths merge into it, run one shared stretch, and split; everything inside it runs inside the wrapper; its right wall, drawn in the hull ink, is where the wrapper returns <- wrappers[]',
43
44
  socket: 'broken-rim ring: a function the caller hands in runs here; its centre fills like a station <- entries[].passed[]',
44
45
  token: 'small grey word under a path: the value the caller fixes for the wrapper <- entries[].policy',
45
46
  afterDot: 'dot in the after band: a call the entry makes after its wrapper returns <- entries[].beside.calls',
46
47
  builder: 'plus-disc under a station with the call that builds the handle there <- uses[how=local].builtBy',
47
48
  unknown: 'a ? in a circle on the rail, with "partly traced" beside it and a sentence on hover: the engine traced the handle only on some paths <- uses[how=mixed]',
48
- fork: 'three prongs and a type: a choice keyed on a plain String or Int; under the mark that makes it, else on the quiet line <- keyed[]',
49
- bundle: 'one cable with how many routes it carries ("4 routes"): entries with the same wrapper and identical marks; its title names every route; opening it draws each one <- bundles[]',
49
+ fork: 'three prongs and a type: a choice keyed on a plain String or Int; under the mark that makes it (under a list of checks, named by the check it is inside: "can_see on String"), else on the quiet line <- keyed[]',
50
+ bundle: 'one cable with how many routes it carries ("4 routes"): entries with the same wrapper and identical marks; its title names every route: by the one part of the path that differs, among at most six ("/{tags, tasks}/sync"), or each by name when there are at most four; else a pattern with *; opening it draws each one <- bundles[]',
50
51
  anchor: 'where the rail starts: platform when no definition of the program can make the handle; ? when the trace stops and the program could <- uses[].anchor',
51
52
  step: 'loop arc with a type on the quiet line: functions of the package take that type and return it; gathered, a count in words ("8 steps"), as are choices, db uses and generic parts <- steps[]',
52
53
  loop: 'loop arc with package names: packages whose types lead back to themselves <- phases.cycles',
@@ -67,6 +68,7 @@ export const VOCABULARY = {
67
68
  replyLoop: 'one solid arc from a lane\'s foot back into the program, with one loop node naming what repeats the step within one call, and "each call of … runs it" naming the routes that run the rounds <- programs[].engines[].replies, loop, starters',
68
69
  replaced: 'a check named "(in place of …)": in the compared base record the same route had that other check at the same place, and it is on that route no longer; says nothing about whether the two do the same <- compare.replaced[]',
69
70
  },
71
+ names: 'every check, call and table name is a label the code\'s author (often an AI) chose; the picture proves where a check runs, what it is given and what happens when it fails, never what it decides: a reader who doubts the author reads that rule itself.',
70
72
  rule: 'every guard, wrapper, socket, fork, handle, station, step and resource the engine records is drawn at its fixed place, in every view; where nothing is drawn, the engine checked and found none.',
71
73
  };
72
74
 
@@ -153,9 +155,67 @@ export function programsOf(st, layers = null) {
153
155
 
154
156
  const outsideResourcesOf = steps => uniq((steps || []).map(e => e.resource).filter(r => r === 'model' || r === 'net'));
155
157
 
158
+ // The branches of an entry's what-happens tree where a path can refuse:
159
+ // one arm ends only in refusals and another does not (structure.scenarios[],
160
+ // present when the structure lens is asked with requirements). The
161
+ // platform's own sign-in or role check is the lock's, and its transaction's
162
+ // busy or unconfirmed answers are the transaction's, not a branch here.
163
+ const leavesUnder = t => (t.kind === 'leaf' ? [t] : t.arms.flatMap(a => leavesUnder(a.next)));
164
+ function refusingForks(tree) {
165
+ const out = [];
166
+ const go = t => {
167
+ if (!t || t.kind !== 'node') return;
168
+ const refused = t.arms.map(a => leavesUnder(a.next).every(l => l.outcome.kind === 'refused'));
169
+ if (t.concern?.layer !== 'platform' && refused.some(Boolean) && !refused.every(Boolean)) out.push(t);
170
+ t.arms.forEach(a => go(a.next));
171
+ };
172
+ go(tree);
173
+ return out;
174
+ }
175
+ // a refusing branch by what it tests: the program's definition it calls, or the read whose row it needs;
176
+ // a branch on a library's answer (a comparison, a platform helper) is a test the calling definition
177
+ // writes, so it is named by that definition, the rule a reader would open
178
+ const forkId = (n, own) => (n.from.kind === 'call' && own.has(n.from.callee) ? n.from.callee : n.id);
179
+ // what a check's label is made of, so each code name in it can be named: the definition it names, or the table and field it reads
180
+ const forkParts = (n, own) => (n.from.kind === 'call' && own.has(n.from.callee) ? { definition: n.from.callee }
181
+ : n.from.kind === 'read' && n.from.table ? { table: n.from.table, ...(n.from.path?.length ? { field: n.from.path.join('.') } : {}) }
182
+ : n.from.site ? { definition: n.from.site } : {});
183
+ // the same check met on several paths: a definition by its id, any other test by what it asks where
184
+ const forkKey = own => n => (n.from.kind === 'call' && own.has(n.from.callee) ? n.from.callee : `${forkLabel(n, own)} ${n.from.site}`);
185
+ // a branch on a read row itself asks whether it was found; on a field of it, what that field holds
186
+ const forkLabel = (n, own) => (n.from.kind === 'call' ? (own.has(n.from.callee) ? bare(n.from.callee) : `a test in ${bare(n.from.site)}`)
187
+ : n.from.kind === 'read' && n.from.table ? (n.from.path?.length ? `${n.from.table} row\u2019s ${n.from.path.join('.')}` : `${n.from.table} row found`) : n.from.site ? `a test in ${bare(n.from.site)}` : 'a computed branch');
188
+ // a table's access policy as the access check states it (report.resources[].policy),
189
+ // and whether the check proved the code keeps it: a rule the code breaks says so, and
190
+ // one it could not settle (or no route reaches) is not proven
191
+ // a table the access check gave no verdict is 'unchecked', as the key says
192
+ const policyWord = r => {
193
+ const rule = policyRule(r);
194
+ if (rule === 'unchecked') return rule;
195
+ return r.status === 'violated' ? `${rule}, broken` : r.proven ? rule : `${rule}, not proven`;
196
+ };
197
+ const policyRule = r => {
198
+ const roles = () => r.roles.join(', '), guards = () => r.guards.join(', ');
199
+ switch (r.policyKind) {
200
+ case 'OwnerScoped': return 'own';
201
+ case 'Public': return 'no row rule';
202
+ case 'Guarded': return `guarded by ${guards()}`;
203
+ // with no guard, only the roles reach its rows
204
+ case 'GuardedOrRoles': return r.guards.length ? `guarded by ${guards()} or role ${roles()}` : `role ${roles()} only`;
205
+ case 'PublicReadRoleWrite': return `anyone reads; role ${roles()} writes`;
206
+ default: return 'unchecked';
207
+ }
208
+ };
209
+
156
210
  export function buildSystemMap(engine, { program }) {
157
211
  for (const [k, v] of Object.entries(engine)) if (!v.ok) throw new Error(`${k}: ${v.error}`);
158
212
  const report = engine.report, st = engine.structure, abs = engine.abstractions.abstractions, layers = engine.layers.layers;
213
+ // a tree the engine stopped before finishing (truncated, or a construct it does not follow) says nothing
214
+ // about where its entry refuses; such an entry keeps the checks its definitions hold
215
+ const whole = s => s.tree && !s.truncated && !s.unsupported;
216
+ const trees = new Map((st.scenarios || []).filter(whole).map(s => [s.entry, refusingForks(s.tree)]));
217
+ // the program's own definitions, as the layers lens lists them; anything else a fork calls is a library's
218
+ const ownDefs = new Set(layers.packages.flatMap(p => p.definitions.map(d => `${p.name}::${d.name}`)));
159
219
  const routes = (report.surface.routes || []).slice().sort((a, b) => a.path.localeCompare(b.path));
160
220
  const engEntry = new Map(st.entries.map(e => [e.entry, e]));
161
221
  const shapes = new Map(abs.routeShapes.map(r => [r.path, r]));
@@ -164,11 +224,22 @@ export function buildSystemMap(engine, { program }) {
164
224
  const besideOf = inst => ({ calls: inst.besideCalls.map(bare), callIds: inst.besideCalls, guards: inst.besideGuards.map(bare), guardIds: inst.besideGuards,
165
225
  effects: effectsOf(inst.besideEffectSteps), resources: outsideResourcesOf(inst.besideEffectSteps), from: 'abstractions.instantiations[].beside*' });
166
226
 
227
+ // a generator wraps a route only when, by its type, it can: it runs effects, or IO is in its signature, or it
228
+ // checks or dispatches and returns whatever its caller's code returns (a type variable, or the type a function
229
+ // handed to it returns); a pure helper that returns a List or an Int shapes values, and cannot refuse or wrap the route
230
+ const resultOf = sig => (sig.includes('->') ? sig.slice(sig.lastIndexOf('->') + 2).trim() : null);
231
+ const returnsCallers = g => {
232
+ const result = resultOf(g.signature);
233
+ return result !== null && (/^[a-z]\w*$/.test(result)
234
+ || (g.parameterGroups || []).flat().some(sl => sl.role === 'function' && sl.returnType === result));
235
+ };
236
+ const wraps = g => !!g && (/\bIO\[/.test(g.signature) || g.envelope.effects.length > 0
237
+ || (returnsCallers(g) && (g.envelope.guards.length > 0 || g.envelope.dispatchesOn.length > 0)));
167
238
  const entries = routes.map(r => {
168
239
  const key = `route:${r.path}`;
169
240
  const ee = engEntry.get(key);
170
241
  const rs = shapes.get(r.path);
171
- const wrapper = rs.shapedByGenerator;
242
+ const wrapper = wraps(gens.get(rs.shapedByGenerator)) ? rs.shapedByGenerator : null;
172
243
  const inst = wrapper ? insts.find(i => i.callerId === rs.handlerId && i.generatorId === wrapper) : null;
173
244
  const passed = (inst ? inst.slotBindings : []).filter(sb => ['function', 'effect'].includes(sb.slotRole)).map(sb => ({
174
245
  slot: sb.slotName, id: sb.targetId || null, label: sb.targetId ? bare(sb.targetId) : null,
@@ -193,7 +264,13 @@ export function buildSystemMap(engine, { program }) {
193
264
  reachKinds: ee.reaches,
194
265
  db: ee.resources.includes('db'), model: ee.resources.includes('model'), net: ee.resources.includes('net'),
195
266
  writes: reach.some(e => isWrite(e.op)),
196
- guards: rs.handlerGuards.map(bare), guardIds: rs.handlerGuards,
267
+ // every branch of the entry's own where a path refuses (a wrapper's are
268
+ // its own, and a function handed to it keeps its own slot)
269
+ ...(trees.has(key)
270
+ // one mark per check, though the tree meets it on several paths
271
+ ? (forks => ({ guards: forks.map(n => forkLabel(n, ownDefs)), guardIds: forks.map(n => forkId(n, ownDefs)), guardParts: forks.map(n => forkParts(n, ownDefs)) }))(uniqBy(trees.get(key).filter(n => n.concern?.layer !== 'cross'
272
+ && !(inst ? inst.slotBindings : []).some(sb => (sb.guards || []).includes(forkId(n, ownDefs)))), forkKey(ownDefs)))
273
+ : { guards: rs.handlerGuards.map(bare), guardIds: rs.handlerGuards }),
197
274
  wrapper, via: rs.viaSpecialization ? bare(rs.viaSpecialization) : null,
198
275
  policy: (inst ? inst.slotBindings : []).filter(s => s.slotRole === 'semantic-object').map(s => s.value).filter(Boolean),
199
276
  passed, beside: inst ? besideOf(inst) : null, reach,
@@ -261,7 +338,9 @@ export function buildSystemMap(engine, { program }) {
261
338
 
262
339
  const present = uniq(st.ioKinds.flatMap(b => b.resources));
263
340
  const tables = uniq(entries.flatMap(e => [...e.reach, ...(e.beside ? e.beside.effects : [])].map(r => r.table))).sort();
264
- const resources = ['db', 'model', 'net'].filter(k => present.includes(k)).map(k => ({ kind: k, ...(k === 'db' ? { tables } : {}), from: 'structure.ioKinds[].resources' }));
341
+ const policies = engine.access ? Object.fromEntries(engine.access.report.resources.filter(r => tables.includes(r.resource)).map(r => [r.resource, policyWord(r)])) : null;
342
+ const resources = ['db', 'model', 'net'].filter(k => present.includes(k)).map(k => ({ kind: k,
343
+ ...(k === 'db' ? { tables, ...(policies ? { policies, policyFrom: 'access.report.resources[].policyKind, guards, roles' } : {}) } : {}), from: 'structure.ioKinds[].resources' }));
265
344
  const ghostResources = ['db', 'model', 'net'].filter(k => !present.includes(k)).map(k => ({ kind: k, from: 'structure.ioKinds[] checked: no site of this kind' }));
266
345
 
267
346
  const transports = uniq(st.entries.map(e => e.transport));
@@ -355,10 +434,17 @@ export function bundlesOf(entries, uses, keyed) {
355
434
  // The paths' shared segments, * where they differ, /** for differing depths.
356
435
  export function pathPattern(paths) {
357
436
  if (paths.length === 1) return paths[0];
437
+ const sorted = [...paths].sort();
358
438
  const segs = paths.map(p => p.split('/'));
359
439
  const n = Math.min(...segs.map(s => s.length));
360
440
  const out = [];
361
- for (let i = 0; i < n; i++) { const v = uniq(segs.map(s => s[i])); out.push(v.length === 1 ? v[0] : '*'); }
441
+ const vary = [];
442
+ for (let i = 0; i < n; i++) { const v = uniq(segs.map(s => s[i])); out.push(v.length === 1 ? v[0] : '*'); if (v.length > 1) vary.push([i, v]); }
443
+ // one part of the path differs among a few: the title names every route by it, "/{tasks,tags}/sync"
444
+ if (segs.every(s => s.length === n) && vary.length === 1 && vary[0][1].length <= 6) { const [[i, v]] = vary; out[i] = `{${[...v].sort().join(', ')}}`; }
445
+ if (segs.every(s => s.length === n) && vary.length <= 1) return out.join('/');
446
+ // a few routes that differ in more than one part: each by name
447
+ if (paths.length <= 4) return sorted.join(', ');
362
448
  if (segs.every(s => s.length === n)) return out.join('/');
363
449
  let k = 0; while (k < n && out[k] !== '*') k++;
364
450
  return out.slice(0, Math.max(k, 1)).join('/') + '/**';
@@ -163,6 +163,8 @@ export const MARK_KINDS = [...new Set([...Object.values(MARK_KIND), 'resource',
163
163
  'reachedBy', 'port', 'plug', 'package', 'chain', 'composes', 'combination', 'generic', 'loop', 'wall',
164
164
  // the program band: a pure program, its requests, and the engines that perform them
165
165
  'program', 'decision', 'request', 'variant', 'gap', 'wire', 'lane', 'laneFork', 'laneResource', 'replyLoop', 'starter', 'programEffect', 'replaced',
166
+ // the line under a service map saying its names are the author's labels
167
+ 'names',
166
168
  // the what-happens view's (what-happens.js) one new mark: the end of a path
167
169
  'outcome'])].sort();
168
170
 
@@ -215,7 +217,12 @@ function rack(map, x, y, phone, room, notes = {}, scale = {}) {
215
217
  // a present resource drawn at its share of the routes that reach it (1 when nothing is counted)
216
218
  const w = w0 * (scale[k] ? scale[k].w : 1), h = h0 * (scale[k] ? scale[k].h : 1);
217
219
  if (present) {
218
- const tl = k === 'db' ? (present.tables || []) : [];
220
+ // each table with its access policy; too many to list, the policies counted
221
+ const pol = k === 'db' ? present.policies : null;
222
+ const named = k === 'db' ? (present.tables || []) : [];
223
+ const word = n => pol[n] || 'unchecked';
224
+ const tl = !pol ? (named.length < 12 ? named : []) : named.length < 12 ? named.map(n => `${n} · ${word(n)}`)
225
+ : Object.entries(named.reduce((m, n) => ({ ...m, [word(n)]: (m[word(n)] || 0) + 1 }), {})).map(([w, c]) => `${c} ${c === 1 ? 'table' : 'tables'} · ${w}`);
219
226
  out.push(tg([`resource:${k}`], shape('resource', resourceGlyph(k, x - w / 2, yy, w, h, phone, false, tl, room))));
220
227
  pos.set(k, { x: x - w / 2, cx: x, y0: yy, y1: yy + h, bottom: yy + h });
221
228
  const tablesH = tl.length && tl.length < 12 ? tableLines(tl, room, phone).length * (phone ? 11 : 13) + (phone ? 6 : 8) : 0;
@@ -253,13 +260,16 @@ function wallPath(x0, x1, yTop, yBot, openings, gap, r = 14) {
253
260
  }
254
261
 
255
262
 
256
- // verb and table for a station label: "read · write notes"
263
+ // each table under the verbs used on it: "read moderators, threads · read/write posts"
257
264
  const reachLabel = list => {
258
- const vs = [...new Set(list.map(e => e.op === 'read' ? 'read' : e.op === 'delete' ? 'delete' : 'write'))];
259
265
  const order = ['read', 'write', 'delete'];
260
- vs.sort((a, b) => order.indexOf(a) - order.indexOf(b));
261
- const ts = [...new Set(list.map(e => e.table))];
262
- return `${vs.join(' · ')} ${ts.join(', ')}`;
266
+ const verb = e => (e.op === 'read' ? 'read' : e.op === 'delete' ? 'delete' : 'write');
267
+ const groups = new Map();
268
+ for (const tbl of [...new Set(list.map(e => e.table))]) {
269
+ const vs = [...new Set(list.filter(e => e.table === tbl).map(verb))].sort((a, b) => order.indexOf(a) - order.indexOf(b)).join('/');
270
+ groups.set(vs, [...(groups.get(vs) || []), tbl]);
271
+ }
272
+ return [...groups].map(([vs, ts]) => `${vs} ${ts.join(', ')}`).join(' · ');
263
273
  };
264
274
  const writesIn = list => list.some(e => e.op !== 'read');
265
275
  // the room for the words under an after band from `from` to the reply bar at
@@ -423,6 +433,8 @@ function renderService(map, W, opts) {
423
433
  builtAt.get(k).tags.push(`use:${u.i}`);
424
434
  }
425
435
  const keyedTypes = ids => [...new Set([...new Set(ids)].filter(id => keyedById.has(id)).flatMap(id => keyedById.get(id).types))];
436
+ // under a list of checks, each fork names the check it is inside: "can_see on String"
437
+ const ownedTypes = ids => [...new Set(ids)].filter(id => keyedById.has(id)).map(id => `${keyedById.get(id).label} on ${keyedById.get(id).types.join(',')}`);
426
438
  // line k (from 1) under a path at y
427
439
  const line = (y, k) => y + (phone ? 14 : 16) + (k - 1) * lead;
428
440
  // a wrapper's own station: its tables under its forks, clear of its check's failure stem; then its builder
@@ -460,7 +472,8 @@ function renderService(map, W, opts) {
460
472
  let pathLines = wrapped(name, labEnd - lx, { size: fs }), tailOwn = false;
461
473
  if (tailW && tw(pathLines[pathLines.length - 1], fs) + tailW > labEnd - lx) {
462
474
  const re = wrapped(pathLines[pathLines.length - 1], labEnd - lx - tailW, { size: fs });
463
- if (re[re.length - 1].endsWith('…')) tailOwn = true; else pathLines = [...pathLines.slice(0, -1), ...re];
475
+ // the count goes on a line of its own sooner than cut a name in two at its _ or -
476
+ if (re[re.length - 1].endsWith('…') || re.slice(0, -1).some(l => /[_-]$/.test(l))) tailOwn = true; else pathLines = [...pathLines.slice(0, -1), ...re];
464
477
  }
465
478
  const lastW = tailOwn ? -gap : tw(pathLines[pathLines.length - 1], fs);
466
479
  const countX = lx + lastW + gap;
@@ -486,7 +499,7 @@ function renderService(map, W, opts) {
486
499
  const left = [];
487
500
  const guardLines = !e.wrapper && e.guards.length ? wrapped(e._chk.h.text, gx - 7 - x2, { size: fsS }) : [];
488
501
  guardLines.forEach(() => left.push('guard'));
489
- const guardFork = !e.wrapper && !dense ? forkLines(keyedTypes(e.members.flatMap(m => m.guardIds)), gx + 6 - 3 - x2) : [];
502
+ const guardFork = !e.wrapper && !dense ? forkLines(ownedTypes(e.members.flatMap(m => m.guardIds)), gx + 6 - 3 - x2) : [];
490
503
  guardFork.forEach(() => left.push('guardFork'));
491
504
  const tokens = !dense && e.policy.length ? wrapped(e.policy.join(', '), leftEnd - x2, { size: fsS }) : [];
492
505
  tokens.forEach(() => left.push('token'));
@@ -519,7 +532,7 @@ function renderService(map, W, opts) {
519
532
  built = builtAt.get(`s:${i}`);
520
533
  if (built) inner.push('builder');
521
534
  if (guardBelow) xpgGuard = wrapped(gname, hx1 - 6 - xPG - 6, { size: fsS });
522
- xpgFork = !(p && p.guards.length) ? [] : forkLines(keyedTypes(e.members.flatMap(m => m.passed[0]?.guardIds || [])), hx1 - 6 - (xPG + 4) - 11);
535
+ xpgFork = !(p && p.guards.length) ? [] : forkLines(ownedTypes(e.members.flatMap(m => m.passed[0]?.guardIds || [])), hx1 - 6 - (xPG + 4) - 11);
523
536
  const xpg = xpgGuard.length + xpgFork.length;
524
537
  const b = e.beside;
525
538
  if (!dense && (b.calls.length || b.effects.length)) {
@@ -581,15 +594,17 @@ function renderService(map, W, opts) {
581
594
  // a fork under a mark, its types (when drawn) on line k ending by `end`: the choice is made by the definition drawn there
582
595
  // a row's labels hang from its route: bound to it, so they read as its own
583
596
  const rowB = (e, svg) => (e ? bound(`route:${e.key}`, svg) : svg);
584
- const forkAt = (ids, x, y, k, end, own = 'canvas', anchorEnd = false, row = null) => {
597
+ // a fork under checks names each check with its type, so two checks on one type stay two
598
+ const forkAt = (ids, x, y, k, end, own = 'canvas', anchorEnd = false, row = null, checks = anchorEnd) => {
585
599
  const ks = [...new Set(ids)].filter(id => keyedById.has(id));
586
600
  if (!ks.length) return;
587
601
  ks.forEach(id => forkPlaced.add(id));
588
- const types = keyedTypes(ks).join(',');
602
+ const typeList = checks ? ownedTypes(ks) : keyedTypes(ks);
603
+ const types = forkJoin(typeList);
589
604
  const fy = dense ? y + (phone ? 12 : 15) : line(y, k) - 3.5;
590
605
  // types with no room even for a letter are the fork's tooltip; else they take the lines they need
591
606
  const room = anchorEnd ? x - 3 - end : end - x - 11;
592
- const lines = forkLines(keyedTypes(ks), room).filter(Boolean);
607
+ const lines = forkLines(typeList, room).filter(Boolean);
593
608
  const label = dense ? '' : !lines.length ? `<title>${esc(types)}</title>`
594
609
  : Tw(anchorEnd ? x - 3 : x + 11, fy + 3.5, lines, { size: fsS, fill: C.muted, ...(anchorEnd ? { anchor: 'end' } : {}) }, types, lead);
595
610
  over.push(tg(ks.flatMap(id => [`fork:${id}`, `forkRow:${id}`]), mark.fork(x, fy, phone ? 0.6 : 0.7) + owned(own, rowB(row, label))));
@@ -729,7 +744,7 @@ function renderService(map, W, opts) {
729
744
  const gkeys = each(e, m => (m.passed[0]?.guardIds || []).map(g => `guard:${m.key}:passed:${g}`));
730
745
  guardAt(gkeys, dense || below ? [] : e._chk.p.names, xPG, y, (hx1 - xPG) * 2 - 8, 0.9, hull, e, svg => checkLabel(e._chk.p, svg));
731
746
  if (below) out.push(owned(hull, rowB(e, checkLabel(e._chk.p, Tw(xPG + 6, line(y, 1), xpgGuard, { size: fsS, fill: C.guard }, e._chk.p.text, lead)))));
732
- if (xpgFork.length || dense) forkAt(e.members.flatMap(m => m.passed[0]?.guardIds || []), xPG + 4, y, xpgGuard.length + 1, hx1 - 6, hull, false, e);
747
+ if (xpgFork.length || dense) forkAt(e.members.flatMap(m => m.passed[0]?.guardIds || []), xPG + 4, y, xpgGuard.length + 1, hx1 - 6, hull, false, e, true);
733
748
  }
734
749
  // past the hull: the after band, then the reply bar
735
750
  out.push(`<path${anchorAttr(rowId)} d="M${f1(xSock + rS)} ${y} H${f1(xr)}" stroke="${C.ink2}" stroke-width="${f1(cableW(e))}"/>`);
@@ -873,7 +888,17 @@ function renderService(map, W, opts) {
873
888
  }
874
889
  }
875
890
 
876
- const H = Math.max(yBot, R.bottom) + (phone ? 12 : 20);
891
+ // once, under the picture: the names on it are the author's labels (the record's vocabulary.names)
892
+ let yEnd = Math.max(yBot, R.bottom);
893
+ const namesNote = map.vocabulary?.names;
894
+ if (namesNote && !opts.only && rowsIn.some(e => e.guards.length)) {
895
+ const lines = wrapped(namesNote, W - 2 * pad, { size: fsS, mono: false });
896
+ const gap = fsS + 3;
897
+ yEnd += phone ? 14 : 18;
898
+ out.push(shape('names', Tw(pad, yEnd, lines, { size: fsS, fill: C.muted, mono: false }, namesNote, gap)));
899
+ yEnd += (lines.length - 1) * gap;
900
+ }
901
+ const H = yEnd + (phone ? 12 : 20);
877
902
  return outerFrame(W, H, wall.fill + under.join('') + shape('wall', wall.line) + out.join('') + over.join(''), { ...opts, frameTop }, map.program, opts.crumb);
878
903
  }
879
904
 
@@ -949,10 +974,8 @@ export function programBand(programs, x0, x1, y0, phone, rows = null) {
949
974
  // the counts on one line, or two when the program box is narrow
950
975
  // its size in words a reader needs no key for: the package's own definitions, and how many more it uses from elsewhere
951
976
  const own = `${p.definitions} definitions in ${p.pkgLabel}`;
952
- // which packages they come from, the most used first: three named, the rest counted
953
- // (each name stays with its count when the line wraps)
954
- const from = p.elsewhere.slice(0, 3).map(x => `${x.label}\u00a0${x.n}`).join(', ') + (p.elsewhere.length > 3 ? `, ${p.elsewhere.length - 3}\u00a0more` : '');
955
- const more = p.usesElsewhere ? `uses ${p.usesElsewhere} more from ${p.elsewhere.length} package${p.elsewhere.length === 1 ? '' : 's'}: ${from}` : '';
977
+ // (which packages, ranked by how often, is an import list, not structure: the map draws what they do)
978
+ const more = p.usesElsewhere ? `uses ${p.usesElsewhere} more from ${p.elsewhere.length} package${p.elsewhere.length === 1 ? '' : 's'}` : '';
956
979
  const defsTxt = more ? `${own} · ${more}` : own;
957
980
  const headRoom = cw * (phone ? 0.56 : 0.5) - (phone ? 26 : 34);
958
981
  const defsLines = tw(defsTxt, fsS, false) <= headRoom ? [defsTxt] : [own, more].filter(Boolean).flatMap(l => wrapAt(l, headRoom, fsS, false));
@@ -1233,8 +1256,10 @@ function wallPathGaps(x0, x1, yTop, yBot, openings, r = 14) {
1233
1256
  // long for a line clipped where the line ends. Only a type broken mid-word
1234
1257
  // across lines (a line that ends inside a type) with the rest clipped puts
1235
1258
  // the types on one line, clipped.
1259
+ // a named fork ("can_see on String") is a phrase, so phrases are set apart by a space too
1260
+ const forkJoin = types => types.join(types.some(t => t.includes(' ')) ? ', ' : ',');
1236
1261
  export function forkTypeLines(types, room, size) {
1237
- const s = types.join(',');
1262
+ const s = forkJoin(types);
1238
1263
  if (!types.length) return [];
1239
1264
  if (room < tw(`${s[0]}…`, size)) return [''];
1240
1265
  const ls = wrapped(s, room, { size });
@@ -1346,8 +1371,19 @@ function placeOn(pkgs, lsx, phone, f, M) {
1346
1371
  // from its tick. One line, however many parts. null when even this would not fit.
1347
1372
  // a gathered set of choices says what they choose on in its own words; anything else is its label
1348
1373
  const setLabel = it => it.kind === 'forkset' ? `${it.label} on ${it.fork.join(', ')}` : it.label;
1374
+ // The names rise at 50 degrees; when one would be cut at the wall, they rise
1375
+ // more steeply, so a name is cut only when it would be even at the steepest.
1349
1376
  function angledQuiet(pkgs, lsx, lex, y0, phone, fsS) {
1350
- const ang = 50, sin = Math.sin(ang * Math.PI / 180), cos = Math.cos(ang * Math.PI / 180);
1377
+ let first = null;
1378
+ for (const ang of [50, 55, 60, 65, 70, 75, 80]) {
1379
+ const A = angledQuietAt(ang, pkgs, lsx, lex, y0, phone, fsS);
1380
+ if (A && !A.clipped) return A;
1381
+ first = first || A;
1382
+ }
1383
+ return first;
1384
+ }
1385
+ function angledQuietAt(ang, pkgs, lsx, lex, y0, phone, fsS) {
1386
+ const sin = Math.sin(ang * Math.PI / 180), cos = Math.cos(ang * Math.PI / 180);
1351
1387
  const pkgName = p => p.split('/').pop();
1352
1388
  // each name as its spans: [text, fill, attributes, how it is measured: mono, weight, italic]
1353
1389
  const upSpans = it => [...(it.gen ? [[`∀${it.gen} `, C.hullInk, ` font-family="${SANS}" font-weight="700"`, false, 700]] : []),
@@ -1414,7 +1450,8 @@ function angledQuiet(pkgs, lsx, lex, y0, phone, fsS) {
1414
1450
  over.push(tg(it.keys, gg));
1415
1451
  });
1416
1452
  }
1417
- return { items: pkgs.length, under, out, over, bottom: ry + downLen * sin + (phone ? 4 : 6) };
1453
+ const clipped = [...ups.values(), ...downs.values()].some(f => f.full !== null);
1454
+ return { items: pkgs.length, under, out, over, bottom: ry + downLen * sin + (phone ? 4 : 6), clipped };
1418
1455
  }
1419
1456
 
1420
1457
  export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0, bands = []) {
@@ -1429,7 +1466,7 @@ export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0, bands = []) {
1429
1466
  bands,
1430
1467
  // as drawn: a gathered count and a step set in bold, ∀ in bold sans
1431
1468
  width: (it, f) => Math.max((it.gen ? tw(`∀${it.gen} `, f, false, 700) : 0) + tw(setLabel(it), f, true, it.kind === 'part' ? 400 : 700), tw(sub(it), Math.max(MIN_LABEL, f - 0.5)) + (sub(it) && it.fork ? (phone ? 9 : 12) : 0)) + (it.kind === 'forkset' ? (phone ? 16 : 22) : (phone ? 7 : 10)) + (it.kind === 'stepset' ? (phone ? 9 : 11) : 0),
1432
- header: (g, f) => tw(g.pkg ? pkgName(g.pkg) : `${g.gathered} packages`, f, false, 700) + (g.exports.length ? (phone ? 9 : 12) + tw(String(g.exports.length), f, true, 700) : 0) + (phone ? 8 : 12),
1469
+ header: (g, f) => tw(g.pkg ? pkgName(g.pkg) : `${g.gathered} ${g.gathered === 1 ? 'package' : 'packages'}`, f, false, 700) + (g.exports.length ? (phone ? 9 : 12) + tw(String(g.exports.length), f, true, 700) : 0) + (phone ? 8 : 12),
1433
1470
  steps: (g, f) => g.steps.length ? Math.max(...g.steps.map(s => tw(s.label, f, true, 700))) + (phone ? 10 : 13) + (phone ? 6 : 8) : 0,
1434
1471
  };
1435
1472
  let L = placeOn(pkgs, lsx, phone, fsS, M);
@@ -1470,7 +1507,7 @@ export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0, bands = []) {
1470
1507
  const hy = ry - (phone ? 27 : 33) - tier * (phone ? 10 : 12);
1471
1508
  under.push(`<path d="M${f1(tick)} ${f1(hy + 3)} V${f1(ry + 6)}" stroke="${C.faint}" stroke-width="1"${tier ? '' : ' stroke-dasharray="0"'}/>`);
1472
1509
  if (g.pkg) out.push(T(tick, hy, pkgName(g.pkg), { size: fsS, weight: 700, fill: C.faint, mono: false }));
1473
- else if (g.gathered) out.push(T(tick, hy, `${g.gathered} packages`, { size: fsS, weight: 700, fill: C.faint, mono: false }));
1510
+ else if (g.gathered) out.push(T(tick, hy, `${g.gathered} ${g.gathered === 1 ? 'package' : 'packages'}`, { size: fsS, weight: 700, fill: C.faint, mono: false }));
1474
1511
  if (g.exports.length) {
1475
1512
  const ex = tick + tw(pkgName(g.pkg), fsS, false, 700) + (phone ? 7 : 9);
1476
1513
  over.push(tg(g.exports.map(id => `entry:${id}`), mark.inHead(ex, hy - fsS * 0.35, phone ? 0.5 : 0.6) + T(ex + (phone ? 2 : 3), hy, String(g.exports.length), { size: fsS, weight: 700, fill: C.ink2 })));
@@ -20,19 +20,22 @@ export const VOCABULARY = {
20
20
  version: MARKS_VERSION,
21
21
  added: ['outcome'],
22
22
  marks: {
23
- entryHead: 'arrowhead where a request comes in; the rack above the tree has one per described entry, then one dashed line counting the entries no scenario describes, which opens to draw each of them struck <- entries[], ghosts[]',
23
+ entryHead: 'arrowhead where a request comes in; the rack above the tree has one per described entry, then one dashed line counting the entries no scenario describes, which opens to draw each of them struck (when no entry is described, that one line is all the rack draws, and it only counts them: "8 entries"; a scenario is a requirement someone wrote, so its absence says nothing about tests); under the head, "same steps as …" names the entries whose trees take the same steps on their own tables and checks <- entries[], ghosts[], entries[].sameShape',
24
24
  guard: 'red diamond: the declared sign-in or role check, the first branch of every entry that has one; its failure stem ends in a refusal <- tree node with from.kind=guard',
25
- fork: 'three prongs: a branch the code takes on the request, the caller, a value read from a table, or a call result; the arm each line takes is written where it leaves <- tree nodes',
26
- station: 'ring on a line: an effect on that path, its verb and table beside it; filled = it writes <- arms[].effects, entry.effects',
25
+ fork: 'three prongs: a branch the code takes on the request, the caller, a value read from a table, or a call result, with "← request", "← caller" or "← <table> as read" saying where what it tests comes from; the arm each line takes is written where it leaves <- tree nodes, nodes[].from.sources',
26
+ docs: 'under the names line, in italic, the comment the author wrote above each named check, marked unchecked: what the author says it decides, which the engine does not confirm <- docs (structure.docs)',
27
+ rows: 'under the tree, for each table its stations touch (and those of the entries named in "same steps as"), what one row holds, by its checked type: "posts holds List[Post(id: String, …, edited: Bool)]"; a field that is a record gives only its type\'s name, a choice of bare cases its cases ("visibility: Open | Members") <- rows (structure.rows)',
28
+ station: 'ring on a line: an effect on that path, its verb and table beside it; filled = it writes; mint = the platform hands out a fresh row id. Under it, in grey, the row it touches (the caller\'s row; the row named by a request field; one fixed row; a new row; every row, and the guard that keeps them) and, for a write, whether the row is replaced whole or updated from what it read, then where what it writes comes from, field by field (← caller, ← request.<field>, ← a literal, ← new id, ← a call and what it was computed from) <- arms[].effects[].key, value, kept (access report.findings[operation=keep_visible].provenBy)',
27
29
  modelRing: 'violet ring: the path reaches the model <- effects[resource=model]',
28
30
  netRing: 'orange ring: the path reaches the network <- effects[resource=net]',
29
31
  outcome: 'the end of a path: a cap with what the request gets back (a constructor, or a refusal with its status and code); red when a claim fails there or a check reports a witness that ends there, green when every obligation that failed there at the base is checked here and holds, hollow and dashed (unchecked) when a claim there is inconclusive or an obligation that failed at the base is no longer checked, grey otherwise <- leaves',
30
32
  unchecked: 'a hollow dashed cap and a ? tick: the engine cannot say whether the claim holds on this path (a step it could not open, a table it could not name, an answer it could not decide), or what failed here at the base is not checked here; never counted as holding <- claims[verdict=inconclusive], leaves[].gaps, diff.unresolved',
31
33
  struck: 'a struck ghost: something checked and found absent (an entry nobody described, a write a claim says a path must not make) <- ghosts[], claims holding with not',
32
34
  bundle: 'a cable with a count: a subtree too large for this view; opening it draws that part with a breadcrumb <- derived from the tree',
33
- compared: 'a line under the rack, always there: "compared with <base> · N fixed · M still failing", or "no base compared" in faint text <- diff',
35
+ compared: 'a line under the rack when a base was compared: "compared with <base> · N fixed · M still failing"; with no base, no line <- diff',
34
36
  hull: 'a shaded box on the line: a step that is not the handler\'s own work, folded (a wrapper\'s guard or short-circuit, the platform\'s declared check or the transaction it runs the plan in, work beside the wrapper, a wrapper\'s work after the handler); it keeps what the step answers instead of running the handler: the effects it makes before its test above the arms, then one row per arm read as a side stub (the arm, the effects that arm makes, its struck writes, what the request gets, ticks), then "continue"; opening it draws the step\'s own tree; drawn inline, the same hull is a light band behind the step and its exits, with its role and where it comes from above its test, so either way it reads as not the handler\'s own work <- tree nodes[concern.role], entries[].steps',
35
37
  },
38
+ names: 'every check, call and table name is a label the code\'s author (often an AI) chose; the picture proves where a check runs, what it is given and what happens when it fails, never what it decides: a reader who doubts the author reads that rule itself.',
36
39
  rule: 'every entry, branch, arm, effect and outcome the engine computes is drawn, a folded step\'s inside its box and an undescribed entry behind the rack\'s one line; the author\'s words appear only on ids the engine computed, in italic, with the code name beside them.',
37
40
  concerns: 'where each branch and effect comes from in value flow (concern.layer, effects[].layer): core = the handler\'s own work, a function it hands its wrapper or its body when it has none; cross = the wrapper its route shape names, shared with other entries; beside = the handler\'s code outside the wrapper call; platform = the route\'s declared authority, or the transaction the platform runs a plan in (its arms that give up or cannot confirm skip the handler\'s work). Roles come from which arms reach the handler, never from names.',
38
41
  };
@@ -104,6 +107,14 @@ export function buildWhatHappens(engine, { program, base = null, baseLabel = 'ba
104
107
  const claims = st.claims || { scenarios: [], undescribed: (st.scenarios || []).map(s => s.entry) };
105
108
  const byEntry = new Map(claims.scenarios.filter(s => s.status === 'matched').map(s => [s.entry, s]));
106
109
  const routes = new Map((st.entries || []).map(e => [e.entry, e]));
110
+ // a query's rows the access check proves are kept only where a guard
111
+ // holds (report.findings[operation=keep_visible, kind=GuardProven])
112
+ const kept = new Map((engine.access?.report?.findings || []).filter(f => f.operation === 'keep_visible' && f.kind === 'GuardProven' && f.status === 'holds')
113
+ .map(f => [`${f.package}::${f.binding}\u0000${f.resource}`, `${(f.provenBy || []).join(' and ')} passes for the caller and the row`]));
114
+ const withKept = es => es.map(e => {
115
+ const by = e.kind === 'DbQuery' && e.tables.length === 1 ? kept.get(`${e.site}\u0000${e.tables[0]}`) : null;
116
+ return by ? { ...e, kept: by } : e;
117
+ });
107
118
  const entries = st.scenarios.map(s => {
108
119
  const meta = routes.get(s.entry) || {};
109
120
  const sc = byEntry.get(s.entry);
@@ -112,17 +123,31 @@ export function buildWhatHappens(engine, { program, base = null, baseLabel = 'ba
112
123
  const copy = t => {
113
124
  if (!t) return null;
114
125
  if (t.kind === 'leaf') return { ...t, words: names.get(t.id) || null, claims: [], witnesses: [], status: 'grey' };
115
- return { ...t, words: names.get(t.id) || null, arms: t.arms.map(a => ({ ...a, words: names.get(a.id) || null, next: copy(a.next) })) };
126
+ return { ...t, words: names.get(t.id) || null, arms: t.arms.map(a => ({ ...a, effects: withKept(a.effects), words: names.get(a.id) || null, next: copy(a.next) })) };
116
127
  };
117
128
  return {
118
129
  entry: s.entry, handler: s.handler, label: s.entry.replace(/^route:/, ''),
119
130
  authority: meta.authority || null, transport: meta.transport || null,
120
131
  title: sc?.title || null, described: !!sc,
121
- effects: s.effects, tree: copy(s.tree), leafCount: Number(s.leafCount), truncated: s.truncated, unsupported: s.unsupported || null,
132
+ effects: withKept(s.effects), tree: copy(s.tree), leafCount: Number(s.leafCount), truncated: s.truncated, unsupported: s.unsupported || null,
122
133
  wrapper: s.wrapper || null, steps: s.steps || [],
123
134
  from: 'structure.scenarios[]',
124
135
  };
125
136
  });
137
+ // entries whose trees take the same steps in the same order and end the
138
+ // same ways, whatever tables and checks they name, share a shape
139
+ const shapeOf = e => {
140
+ const eff = x => `${x.op}${x.key ? `@${x.key.kind}` : ''}${x.value ? `<${x.value.kind}` : ''}${x.kept ? '|kept' : ''}`;
141
+ const go = t => (!t ? '' : t.kind === 'leaf' ? `(${t.outcome.kind}:${(t.outcome.statuses || (t.outcome.status ? [t.outcome.status] : [])).join('/')})`
142
+ : `[${t.from.kind}${t.arms.map(a => `{${a.effects.map(eff).join(',')}${go(a.next)}}`).join('')}]`);
143
+ return `${(e.effects || []).map(eff).join(',')}${go(e.tree)}`;
144
+ };
145
+ const byShape = new Map();
146
+ for (const e of entries) byShape.set(shapeOf(e), [...(byShape.get(shapeOf(e)) || []), e.entry]);
147
+ for (const e of entries) {
148
+ const same = byShape.get(shapeOf(e)).filter(x => x !== e.entry);
149
+ if (same.length) e.sameShape = { entries: same, from: 'derived: entries[] trees equal in node kinds, arms, effect verbs, row keys and value kinds, and outcomes' };
150
+ }
126
151
  const entryOf = new Map(entries.map(e => [e.entry, e]));
127
152
  const leafById = new Map(entries.flatMap(e => leavesOf(e.tree).map(l => [l.id, l])));
128
153
 
@@ -194,6 +219,11 @@ export function buildWhatHappens(engine, { program, base = null, baseLabel = 'ba
194
219
  return {
195
220
  schema: SCHEMA, marks: MARKS_VERSION, vocabulary: VOCABULARY, program,
196
221
  entries, ghosts, claims: checked, witnesses, notes, refused, mismatches, diff,
222
+ // the comment above each definition, as its author wrote it (structure.docs); never checked
223
+ docs: (callees => Object.fromEntries(Object.entries(st.docs || {}).filter(([id]) => callees.has(id))))(
224
+ new Set(entries.flatMap(e => { const ids = []; walk(e.tree, n => { if (n.kind === 'node' && n.from?.kind === 'call') ids.push(n.from.callee); }); return ids; }))),
225
+ // what each table holds, by its checked row type (structure.rows)
226
+ rows: Object.fromEntries(Object.entries(st.rows || {}).map(([table, schema]) => [table, rowShape(schema)])),
197
227
  counts: { entries: entries.length, described: entries.length - ghosts.length, leaves: leafById.size,
198
228
  steps: entries.reduce((n, e) => n + e.steps.length, 0), wrapped: entries.filter(e => e.wrapper && e.wrapper.wraps).length,
199
229
  red: [...leafById.values()].filter(l => l.status === 'red').length, green: [...leafById.values()].filter(l => l.status === 'green').length,
@@ -203,6 +233,20 @@ export function buildWhatHappens(engine, { program, base = null, baseLabel = 'ba
203
233
  }
204
234
 
205
235
  // Small helpers the renderer and tests share.
236
+ // A table's row type in a line: List[Post(id: String, edited: Bool)]; a
237
+ // field that is itself a record, or a choice whose cases carry fields,
238
+ // gives only its type's name; a choice of bare cases gives its cases.
239
+ const typeName = s => (s.type ? s.type.split('::').pop() : null);
240
+ const fieldType = s => ({ string: 'String', int: 'Int', float64: 'Float64', bool: 'Bool', unit: 'Unit', json: 'Json' })[s.kind]
241
+ || (s.kind === 'list' ? `List[${fieldType(s.element)}]` : s.kind === 'unsupported' ? s.rendered
242
+ // a choice of bare cases is its cases: Open | Members
243
+ : s.kind === 'union' && s.variants.length && s.variants.every(v => !v.fields.length) ? s.variants.map(v => v.tag).join(' | ') : typeName(s));
244
+ export function rowShape(s) {
245
+ if (s.kind === 'list') return `List[${rowShape(s.element)}]`;
246
+ if (s.kind === 'struct') return `${typeName(s)}(${s.fields.map(f => `${f.name}: ${fieldType(f)}`).join(', ')})`;
247
+ if (s.kind === 'union') return s.variants.map(v => (v.fields.length ? `${v.tag}(${v.fields.map(f => `${f.name}: ${fieldType(f)}`).join(', ')})` : v.tag)).join(' | ');
248
+ return fieldType(s);
249
+ }
206
250
  export const isWriteOp = op => !['read', 'runs', 'effect', 'random', 'trace', 'capture', 'init', 'mint id'].includes(op) && op !== 'call model' && op !== 'fetch article';
207
251
  export function stationLabel(e) {
208
252
  const verb = e.op === 'mint id' ? 'mint' : e.op;
@@ -210,6 +254,46 @@ export function stationLabel(e) {
210
254
  if (e.resource === 'model' || e.resource === 'net') return verb;
211
255
  return `${verb} · ${bare(e.primitive)}`;
212
256
  }
257
+ // Where a value came from (effects[].key, effects[].value), in a few words:
258
+ // the caller, a request field, a fixed literal, what a read or a new id
259
+ // gave, or a call and what it was computed from.
260
+ export const fromWord = w => (w === 'caller' ? 'caller' : w === 'request' ? 'request' : w.startsWith('mint id:') ? 'new id'
261
+ : w.startsWith('read:') ? `${w.slice(5)} as read` : w);
262
+ export function sourceText(f) {
263
+ switch (f.kind) {
264
+ case 'caller': return 'caller';
265
+ case 'request': return ['request', ...(f.path || [])].join('.');
266
+ case 'literal': return f.text;
267
+ case 'read': return f.op === 'mint id' ? 'new id' : `${f.table || bare(f.primitive)} as read${f.path?.length ? `.${f.path.join('.')}` : ''}`;
268
+ case 'call': return `${bare(f.callee)}(${(f.from || []).map(fromWord).join(', ')})${f.path?.length ? `.${f.path.join('.')}` : ''}`;
269
+ case 'built': return f.fields ? `${f.ctor}(${f.fields.map(x => `${x.name} ← ${sourceText(x.from)}`).join(', ')})` : `${f.ctor}(${(f.from || []).map(fromWord).join(', ')})`;
270
+ case 'derived': return (f.from || []).map(fromWord).join(', ') || 'computed';
271
+ default: return 'computed';
272
+ }
273
+ }
274
+ // The row a station touches and, for a write, whether the row it writes
275
+ // carries what it read from that table (an update) or not (a replacement).
276
+ export function rowText(e) {
277
+ if (!e.key) return e.value ? 'adds a row' : null;
278
+ const k = e.key;
279
+ return k.kind === 'caller' ? "the caller's row" : k.kind === 'literal' ? `one fixed row ${k.text}`
280
+ : k.kind === 'read' && k.op === 'mint id' ? 'a new row' : `the row named by ${sourceText(k)}`;
281
+ }
282
+ const readsOwn = (v, table) => {
283
+ if (!v) return false;
284
+ if (v.kind === 'read') return v.table === table && v.op !== 'mint id';
285
+ if (v.fields) return v.fields.some(x => readsOwn(x.from, table));
286
+ return (v.from || []).includes(`read:${table}`);
287
+ };
288
+ export function stationDetail(e) {
289
+ if (e.kind === 'DbQuery') return `every row${e.kept ? `, kept only where ${e.kept}` : ''}`;
290
+ const row = rowText(e);
291
+ if (!row) return null;
292
+ if (!e.value) return row;
293
+ const fresh = !e.key || e.key.kind === 'read' && e.key.op === 'mint id';
294
+ const mode = fresh ? '' : readsOwn(e.value, e.tables[0]) ? ' · updated from what it read' : ' · replaced whole';
295
+ return `${row}${mode} ← ${sourceText(e.value)}`;
296
+ }
213
297
  // an unknown answer a station gave back (outcome.from), or a field of it,
214
298
  // said in the station's words; any other answer keeps its key
215
299
  const said = f => {