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.
- package/README.md +5 -2
- package/dist/cli.js.map +1 -1
- package/dist/main.js +191 -31
- package/dist/main.js.map +1 -1
- package/dist/studio-page/system-map-record.js +93 -7
- package/dist/studio-page/system-map.js +60 -23
- package/dist/studio-page/what-happens-record.js +90 -6
- package/dist/studio-page/what-happens.js +72 -13
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
953
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 => {
|