yichus 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +45 -16
  2. package/dist/cli.js +17 -2123
  3. package/dist/cli.js.map +1 -1
  4. package/dist/main.js +3762 -0
  5. package/dist/main.js.map +1 -0
  6. package/dist/studio-page/dom.js +22 -0
  7. package/dist/studio-page/label-fit.js +7 -0
  8. package/dist/studio-page/layer-diagram.js +458 -0
  9. package/dist/studio-page/layer-groups.js +129 -0
  10. package/dist/studio-page/source-link.js +17 -0
  11. package/dist/studio-page/studio-views.js +256 -0
  12. package/dist/studio-page/system-map-record.js +124 -10
  13. package/dist/studio-page/system-map.js +476 -87
  14. package/dist/studio-page/what-happens-record.js +224 -0
  15. package/dist/studio-page/what-happens.js +771 -0
  16. package/dist/studio-reference/balance-listed-laws.bosatsu +29 -0
  17. package/dist/studio-reference/balance.bosatsu +7 -0
  18. package/dist/studio-reference/calculator.md +20 -0
  19. package/dist/studio-reference/consent-laws.bosatsu +37 -0
  20. package/dist/studio-reference/consent.bosatsu +15 -0
  21. package/dist/studio-reference/deploy-platforms.md +79 -0
  22. package/dist/studio-reference/laws.md +62 -0
  23. package/dist/studio-reference/portable-programs.md +58 -0
  24. package/dist/studio-reference/porting.md +34 -0
  25. package/dist/studio-reference/protocol-cases.md +11 -0
  26. package/dist/studio-reference/{protocol-specification.txt → protocol-specification.md} +16 -18
  27. package/dist/studio-reference/refund-desk-one-round.bosatsu +196 -0
  28. package/dist/studio-reference/refund-desk.bosatsu +194 -0
  29. package/dist/studio-reference/refund-policy.bosatsu +97 -0
  30. package/dist/studio-reference/refund-test.bosatsu +75 -0
  31. package/dist/studio-reference/render.yaml +42 -0
  32. package/dist/studio-reference/server.mjs +33 -0
  33. package/dist/studio-reference/store.mjs +16 -0
  34. package/dist/studio-reference/the-page.md +130 -0
  35. package/dist/studio-reference/worker-authority.md +159 -0
  36. package/package.json +2 -2
@@ -0,0 +1,256 @@
1
+ // The studio page's two person diagrams, drawn from records that hold only
2
+ // the person's words (packages/yichus-cli/src/studio/person.ts builds them):
3
+ //
4
+ // mountSections(root, layers) the app's sections as a stack, the layer
5
+ // diagram's stack (layer-diagram.js), each
6
+ // layer built only from the layers below it
7
+ // mountWhatHappens(root, record) each described action's decision tree in
8
+ // the person's words, ending in the
9
+ // what-happens view's outcome caps
10
+ // showMarks(marks) the agent's notes, layered over both as
11
+ // a conversation: each bubble points at
12
+ // the part it is about
13
+ // reportViewing(send) tells the studio what the person has in
14
+ // view, by id only, so the agent sees it
15
+ //
16
+ // The words the person's agent wrote are italic, as in what-happens.js;
17
+ // the marks, the colours and the checks are computed. A part nobody has
18
+ // named yet says so instead of showing its code name.
19
+ import { el, svg } from './dom.js';
20
+ import { stackLayout } from './layer-diagram.js';
21
+ import { outcomeMark } from './what-happens.js';
22
+ import { SANS, textWidth, clipText } from './label-fit.js';
23
+
24
+ const NOT_NAMED = 'a part not named yet';
25
+ const NAME = [13, 600], DOES = [11.5, 400], inset = 12;
26
+ const NOT_NAMED_RECORD = 'a kind of record not named yet';
27
+ // A phrase is computed text with the agent's own words ({ theirs }) in it.
28
+ const listed = (verb, names) => [verb, ...names.flatMap((n, i) => [...i ? [', '] : [], n === null ? NOT_NAMED_RECORD : { theirs: n }])];
29
+ const doesOf = s => {
30
+ const parts = [s.changes.length ? listed('changes ', s.changes) : null, s.looks.length ? listed('looks at ', s.looks) : null].filter(Boolean);
31
+ return parts.length ? parts.flatMap((p, i) => [...i ? [' · '] : [], ...p]) : ['keeps no records itself'];
32
+ };
33
+ const plain = phrase => phrase.map(p => typeof p === 'string' ? p : p.theirs).join('');
34
+ const measure = (text, [size, weight], italic = false) => textWidth(text, size, false, weight, italic, SANS);
35
+ const label = (text, [size, weight], room, italic = false) => clipText(text, size, room, false, weight, italic, SANS);
36
+ const nameOf = s => s.name ?? NOT_NAMED;
37
+
38
+ export function mountSections(root, layers) {
39
+ const draw = () => {
40
+ const width = Math.max(300, root.clientWidth);
41
+ // A tile is as wide as its words, up to the frame; longer words are
42
+ // shortened to fit and keep their whole text in the tile's title.
43
+ const widest = width - 2 * inset - 24;
44
+ const items = layers.map((layer, i) => layer.map((s, k) => ({ name: `section:${i}:${k}`,
45
+ min: Math.min(widest, Math.max(measure(nameOf(s), NAME, !!s.name), measure(plain(doesOf(s)), DOES, true)) + 2 * inset), weight: 1 + Math.sqrt(s.uses), s })));
46
+ const laid = stackLayout(items, width);
47
+ const text = (cls, x, y, words, font, room, key, italic = false) => svg('text', { class: cls, x, y, 'font-family': SANS, 'font-size': font[0], 'font-weight': font[1],
48
+ ...(italic ? { 'font-style': 'italic' } : {}), 'data-fit': key }, label(words, font, room, italic));
49
+ // A phrase is shortened as a whole, measured at its wider style, and the
50
+ // agent's words in what is left stay italic.
51
+ const phrase = (cls, x, y, words, font, room, key) => {
52
+ const whole = plain(words);
53
+ const kept = [label(whole, font, room, false), label(whole, font, room, true)].reduce((a, b) => (a.length <= b.length ? a : b));
54
+ const cut = kept.endsWith('…') && kept !== whole ? kept.length - 1 : whole.length;
55
+ let at = 0;
56
+ const spans = words.flatMap(p => {
57
+ const t = typeof p === 'string' ? p : p.theirs, piece = t.slice(0, Math.max(0, cut - at));
58
+ at += t.length;
59
+ return piece ? [svg('tspan', typeof p === 'string' ? {} : { 'font-style': 'italic' }, piece)] : [];
60
+ });
61
+ return svg('text', { class: cls, x, y, 'font-family': SANS, 'font-size': font[0], 'font-weight': font[1], 'data-fit': key },
62
+ spans, cut < whole.length ? svg('tspan', {}, '…') : []);
63
+ };
64
+ const picture = svg('svg', { class: 'studio-stack', viewBox: `0 0 ${laid.width} ${laid.height}`, width: laid.width, height: laid.height, role: 'img',
65
+ 'aria-label': `Your app in ${layers.length} layers, the top first: each is built only from the layers below it`, 'data-fit': 'canvas' },
66
+ svg('rect', { class: 'studio-canvas', width: laid.width, height: laid.height, 'data-box': 'canvas' }),
67
+ laid.bands.map((b, i) => svg('rect', { class: 'studio-band', 'data-shade': i % 2, x: 0, y: b.top, width: laid.width, height: b.height })),
68
+ laid.tiles.map(({ item: { name: key, s }, x, y, w, h }) => svg('g', { class: 'studio-section', 'data-named': String(!!s.name), 'data-anchor': s.anchor },
69
+ svg('title', {}, `${nameOf(s)}: ${plain(doesOf(s))}`),
70
+ svg('rect', { x, y, width: w, height: h, rx: 6, 'data-box': key }),
71
+ text('studio-section-name', x + inset, y + h / 2 - 4, nameOf(s), NAME, w - 2 * inset, key, !!s.name),
72
+ phrase('studio-section-does', x + inset, y + h / 2 + 13, doesOf(s), DOES, w - 2 * inset, key))));
73
+ root.replaceChildren(picture);
74
+ };
75
+ draw();
76
+ // One observer per root, drawing the latest record when the frame resizes.
77
+ watching.get(root)?.disconnect();
78
+ if ('ResizeObserver' in window) {
79
+ let seen = root.clientWidth;
80
+ const observer = new ResizeObserver(() => { if (root.clientWidth !== seen) { seen = root.clientWidth; draw(); } });
81
+ observer.observe(root);
82
+ watching.set(root, observer);
83
+ }
84
+ }
85
+ const watching = new WeakMap();
86
+
87
+ const TICK = { holds: '✓', fails: '✗', 'cannot say': '?' };
88
+ function cap(outcome) {
89
+ const mark = svg('svg', { class: 'studio-cap', viewBox: '0 0 20 20', width: 20, height: 20, 'aria-hidden': 'true' });
90
+ // outcomeMark draws from a status and a flag only, never from text
91
+ mark.innerHTML = outcomeMark(10, 10, outcome.status, outcome.refused);
92
+ return mark;
93
+ }
94
+ const words = (text, missing) => text ? el('em', {}, text) : el('span', { class: 'studio-unnamed' }, missing);
95
+ const outcomes = s => (s.kind === 'outcome' ? 1 : s.ways.reduce((n, w) => n + outcomes(w.then), 0));
96
+ const way = w => el('p', { class: 'studio-way', 'data-anchor': w.then.anchor === w.anchor ? null : w.anchor }, words(w.words, 'a way not described yet'));
97
+ // The what-happens view's spine: the way that leads on to the most outcomes
98
+ // continues straight down; a way that ends at once is a stub beside it; any
99
+ // other way that leads on is folded behind its words, like the view's bundle.
100
+ function step(s) {
101
+ if (s.kind === 'outcome') return el('div', { class: 'studio-outcome', 'data-status': s.status, 'data-anchor': s.anchor },
102
+ cap(s), el('span', { class: 'studio-said' }, el('span', { class: 'studio-end' }, s.refused ? 'turned away' : 'answered'), s.words ? [' ', el('em', {}, s.words)] : []),
103
+ s.checks.length ? el('ul', { class: 'studio-checks' }, s.checks.map(c => el('li', { 'data-verdict': c.verdict },
104
+ `${TICK[c.verdict]} `, c.says.map(p => typeof p === 'string' ? p : el('em', {}, p.theirs))))) : []);
105
+ const on = s.ways.filter(w => w.then.kind === 'choice').sort((a, b) => outcomes(b.then) - outcomes(a.then));
106
+ const [main, ...folded] = on;
107
+ return [
108
+ el('p', { class: 'studio-ask', 'data-anchor': s.anchor }, words(s.words, 'a check not described yet')),
109
+ el('ul', { class: 'studio-ways' },
110
+ s.ways.filter(w => w.then.kind === 'outcome').map(w => el('li', {}, way(w), step(w.then))),
111
+ folded.map(w => el('li', {}, el('details', { class: 'studio-fold' },
112
+ el('summary', { 'data-anchor': w.anchor }, words(w.words, 'a way not described yet'), ` · ${outcomes(w.then)} ways it can end`), el('div', { class: 'studio-folded' }, step(w.then)))))),
113
+ main ? [el('p', { class: 'studio-on', 'data-anchor': main.anchor }, '↓ ', words(main.words, 'a way not described yet')), step(main.then)] : [],
114
+ ];
115
+ }
116
+
117
+ export function mountWhatHappens(root, record) {
118
+ root.replaceChildren(
119
+ ...record.actions.map(a => el('section', { class: 'studio-action' },
120
+ el('h3', { 'data-anchor': a.anchor }, words(a.title, 'an action not described yet')),
121
+ a.first ? el('div', { class: 'studio-tree' }, step(a.first)) : el('p', { class: 'empty' }, 'This action could not be followed.'))),
122
+ record.waiting ? el('p', { class: 'studio-waiting' }, `${record.waiting} more ${record.waiting === 1 ? 'action is' : 'actions are'} not described yet.`) : []);
123
+ if (!record.actions.length && !record.waiting) root.replaceChildren(el('p', { class: 'empty' }, 'Your app has no actions yet.'));
124
+ }
125
+
126
+ // ------------------------------------------------------------ the agent
127
+
128
+ // The agent's notes sit in a layer of their own above the page, like the
129
+ // agent speaking over the pictures: each note is a bubble beside the part
130
+ // it is about, and that part glows under it. The layer only reads where
131
+ // the parts are drawn; it never changes a picture. A new set of notes
132
+ // arrives one bubble at a time and the page brings the first into view.
133
+ let layer = null, said = null, shown = [];
134
+ const GAP = 12, EDGE = 16;
135
+ const within = (value, low, high) => Math.max(low, Math.min(high, value));
136
+
137
+ export function showMarks(marks) {
138
+ if (!layer) {
139
+ layer = document.body.appendChild(el('div', { class: 'agent-layer', role: 'log', 'aria-label': 'Notes from your agent' }));
140
+ // The page reflows when it is resized, a fold opens or a picture is
141
+ // redrawn; the notes follow their parts.
142
+ new ResizeObserver(() => follow()).observe(document.body);
143
+ }
144
+ const fresh = !!marks && marks.said !== said;
145
+ said = marks?.said ?? null;
146
+ if (fresh || !marks) {
147
+ shown = (marks?.marks ?? []).map((mark, i) => note(mark, fresh ? i : null));
148
+ layer.replaceChildren(...shown.flatMap(n => [n.ring, n.bubble]));
149
+ }
150
+ // New notes about steps behind a fold open the fold; once open, the fold
151
+ // is the person's to close, and a note about a step folded away waits.
152
+ if (fresh) for (const n of shown) {
153
+ for (let fold = n.target()?.parentElement?.closest('details'); fold; fold = fold.parentElement?.closest('details')) fold.open = true;
154
+ }
155
+ follow();
156
+ const first = shown.map(n => n.target()).find(Boolean);
157
+ if (fresh && first) first.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
158
+ }
159
+
160
+ // One note: its ring, its bubble, and the part it points at, found again
161
+ // each time, since a picture redraws its parts.
162
+ function note(mark, order) {
163
+ const target = () => document.querySelector(`[data-anchor="${CSS.escape(mark.anchor)}"]`);
164
+ const ring = el('div', { class: 'agent-ring' });
165
+ const tail = el('span', { class: 'agent-tail' });
166
+ const close = el('button', { type: 'button', class: 'agent-close', 'aria-label': 'Hide this note' }, '×');
167
+ const bubble = el('div', { class: 'agent-bubble' }, tail,
168
+ el('p', { class: 'agent-who' }, 'Your agent', el('span', {}, ' · not checked')),
169
+ el('p', { class: 'agent-says' }, el('em', {}, mark.note)), close);
170
+ const n = { ring, bubble, tail, target };
171
+ close.addEventListener('click', () => { ring.remove(); bubble.remove(); shown = shown.filter(m => m !== n); follow(); });
172
+ if (order !== null) for (const part of [ring, bubble]) { part.classList.add('agent-in'); part.style.animationDelay = `${order * 350}ms`; }
173
+ return n;
174
+ }
175
+
176
+ function follow() {
177
+ // A part not on the page, or folded away, has its note wait.
178
+ const placed = shown.map(n => { const part = n.target(); return { n, part: part?.checkVisibility() ? part : null }; });
179
+ for (const { n, part } of placed) n.ring.hidden = n.bubble.hidden = !part;
180
+ // Placed down the page, so a bubble only ever waits below one above it.
181
+ const measured = placed.filter(p => p.part).map(({ n, part }) => ({ n, box: drawn(part) })).sort((a, b) => a.box.top - b.box.top);
182
+ const page = document.querySelector('main')?.getBoundingClientRect() ?? { right: innerWidth };
183
+ let below = -Infinity;
184
+ for (const { n, box } of measured) {
185
+ const x = box.left + scrollX, y = box.top + scrollY;
186
+ Object.assign(n.ring.style, { left: `${x - 4}px`, top: `${y - 4}px`, width: `${box.width + 8}px`, height: `${box.height + 8}px` });
187
+ const width = Math.min(300, innerWidth - 2 * EDGE);
188
+ // Beside the part when the page has room, so the picture stays in
189
+ // view; otherwise just under it.
190
+ const beside = box.right + GAP + width <= Math.min(page.right, innerWidth - EDGE);
191
+ n.bubble.dataset.side = beside ? 'right' : 'below';
192
+ const left = beside ? box.right + scrollX + GAP : within(x + 16, scrollX + EDGE, scrollX + innerWidth - width - EDGE);
193
+ const top = Math.max(beside ? y + box.height / 2 - 18 : y + box.height + GAP, below + 8);
194
+ Object.assign(n.bubble.style, { left: `${left}px`, top: `${top}px`, width: `${width}px` });
195
+ Object.assign(n.tail.style, beside ? { left: '-7px', top: `${within(y + box.height / 2 - top - 6, 10, 40)}px` }
196
+ : { left: `${within(x + box.width / 2 - left - 7, 12, width - 26)}px`, top: '' });
197
+ below = top + n.bubble.offsetHeight;
198
+ }
199
+ }
200
+
201
+ // Where a part's words are drawn: a picture's part as it is, a line of
202
+ // text as far as its words reach rather than the width of its column.
203
+ function drawn(part) {
204
+ if (part instanceof SVGElement) return part.getBoundingClientRect();
205
+ const words = document.createRange();
206
+ words.selectNodeContents(part);
207
+ return words.getBoundingClientRect();
208
+ }
209
+
210
+ // ------------------------------------------------------------ what is in view
211
+
212
+ // What the person has in view, so the agent can follow along when they say
213
+ // "this one": the page's sections and the drawn parts on screen, the
214
+ // questions and properties shown, the folds open and the last thing they
215
+ // clicked. Only ids go out, never words; the studio says the words from the
216
+ // records it drew. Each report names the drawing its anchors belong to.
217
+ // Returns the function to call after each refresh with the drawing now shown;
218
+ // a new drawing forgets the last click, whose anchor may now be another part.
219
+ export function reportViewing(send) {
220
+ let touched = null, drawing = null, sent = '', timer = 0;
221
+ const onScreen = node => {
222
+ if (!node.checkVisibility()) return false;
223
+ const box = node.getBoundingClientRect();
224
+ return box.bottom > 0 && box.top < innerHeight && box.right > 0 && box.left < innerWidth;
225
+ };
226
+ const ids = (selector, key) => [...new Set([...document.querySelectorAll(selector)].filter(onScreen).map(n => n.dataset[key]))];
227
+ const report = () => {
228
+ const now = JSON.stringify({
229
+ shown: document.visibilityState === 'visible',
230
+ sections: ids('main > section[data-section]', 'section'),
231
+ parts: ids('[data-anchor]', 'anchor'),
232
+ questions: ids('[data-question]', 'question'),
233
+ properties: ids('[data-property]', 'property'),
234
+ open: [...document.querySelectorAll('details.studio-fold[open] > summary[data-anchor]')].map(s => s.dataset.anchor),
235
+ touched,
236
+ drawing,
237
+ });
238
+ if (now !== sent) { sent = now; send(now); }
239
+ };
240
+ const soon = () => { clearTimeout(timer); timer = setTimeout(report, 500); };
241
+ const touch = event => {
242
+ const at = event.target instanceof Element ? event.target.closest('[data-anchor], [data-question], [data-property], main > section[data-section]') : null;
243
+ if (at) touched = at.dataset.anchor ? { kind: 'part', id: at.dataset.anchor } : at.dataset.question ? { kind: 'question', id: at.dataset.question }
244
+ : at.dataset.property ? { kind: 'property', id: at.dataset.property } : { kind: 'section', id: at.dataset.section };
245
+ soon();
246
+ };
247
+ for (const type of ['scroll', 'resize']) addEventListener(type, soon, { passive: true });
248
+ document.addEventListener('visibilitychange', soon);
249
+ document.addEventListener('toggle', soon, true);
250
+ document.addEventListener('click', touch, true);
251
+ document.addEventListener('focusin', touch);
252
+ return shown => {
253
+ if (shown !== drawing) { drawing = shown ?? null; touched = null; }
254
+ soon();
255
+ };
256
+ }
@@ -1,4 +1,4 @@
1
- // The system map's record (schema system-map-2, marks yichus-marks-2), built
1
+ // The system map's record (schema system-map-2, marks yichus-marks-3), built
2
2
  // from the engine's own output. web/system-map.js draws this record and
3
3
  // nothing else; every field names the engine field it was copied or derived
4
4
  // from (`from`). scripts/diagram-examples.mjs records one per program.
@@ -6,12 +6,13 @@
6
6
  // The record joins four engine calls by full id:
7
7
  // api_report headline.class, surface.routes
8
8
  // api_organize lens=structure entries, supply, ioKinds, ports, applied,
9
- // chains, keyed, generic, combinations, phases
9
+ // chains, keyed, generic, combinations, phases,
10
+ // programs
10
11
  // api_organize lens=abstractions generators, instantiations, routeShapes
11
12
  // api_organize lens=layers packages and their exported definitions
12
13
  // A name is copied as a label and never decides anything.
13
14
 
14
- export const MARKS_VERSION = 'yichus-marks-2';
15
+ export const MARKS_VERSION = 'yichus-marks-3';
15
16
 
16
17
  // Each mark, where it sits, and the record field that draws it. It travels
17
18
  // with every record so the file describes itself.
@@ -21,10 +22,11 @@ export const VOCABULARY = {
21
22
  callers: 'left of the wall: one glyph per transport kind, HTTP above, importer below, each with a bracket over the openings it calls; the kind the program lacks is the same glyph struck through <- callers[], ghostCallers[]',
22
23
  openings: 'the left wall: one opening per entry <- entries[]',
23
24
  hullBand: 'the middle band of the wall: wrappers sit here and nowhere else; empty band = no entry goes through a shared wrapper <- wrappers[]',
24
- afterBand: 'between the hull band and the reply bars: work an entry does after its wrapper returns, with its stations and checks; empty = none <- entries[].beside',
25
+ afterBand: 'between a hull\'s right wall and the reply bars: work an entry does after its wrapper returns, with its stations and checks; empty = none <- entries[].beside',
25
26
  replyBars: 'one bar per entry at the same x, past the hull band <- entries[]',
26
27
  rail: 'the bottom of the wall: a handle handed in from outside, with a riser to each use; a ? on the rail where a use is handed in on some paths only <- uses[how=handed|mixed]',
27
28
  rack: 'right of the wall: db, model, net, always in this order; struck = the engine checked and found none <- resources[], ghostResources[]',
29
+ programBand: 'the bottom of the wall, under the rail: one container per pure program, its request openings on its right edge, the engine lanes right of it; empty = the engine found no program <- programs[]',
28
30
  quietLine: 'bottom inside the wall: parts on no entry path, one stretch per package, with their forks, sockets, generic letters and steps <- keyed[], library.applied[], steps[]',
29
31
  },
30
32
  marks: {
@@ -33,23 +35,37 @@ export const VOCABULARY = {
33
35
  role: 'red word beside the lock: a role beyond sign-in <- entries[].roles',
34
36
  limit: 'gauge beside the path: a declared rate limit <- entries[].limits',
35
37
  station: 'ring on a path: code touching a table, with the verb and table beside it; filled = writes <- entries[].reach, wrappers[].effects, entries[].beside.effects',
36
- modelRing: 'violet ring: the entry reaches the model; the count under the model says how many entries do <- entries[].model',
38
+ modelRing: 'violet ring: the entry reaches the model <- entries[].model',
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',
37
40
  netRing: 'orange ring: the entry reaches the network <- entries[].net',
38
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',
39
- hull: 'translucent region in the hull band: paths merge into it, run one shared stretch, and split; everything inside it runs inside the wrapper <- wrappers[]',
42
+ 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[]',
40
43
  socket: 'broken-rim ring: a function the caller hands in runs here; its centre fills like a station <- entries[].passed[]',
41
44
  token: 'small grey word under a path: the value the caller fixes for the wrapper <- entries[].policy',
42
45
  afterDot: 'dot in the after band: a call the entry makes after its wrapper returns <- entries[].beside.calls',
43
46
  builder: 'plus-disc under a station with the call that builds the handle there <- uses[how=local].builtBy',
44
- unknown: 'a ? in a circle on the rail: the engine traced the handle only on some paths <- uses[how=mixed]',
47
+ 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]',
45
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[]',
46
- bundle: 'one cable with a count: entries with the same wrapper and identical marks; its title names every route; opening it draws each one <- bundles[]',
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[]',
47
50
  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',
48
- step: 'loop arc with a type on the quiet line: functions of the package take that type and return it <- steps[]',
51
+ 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[]',
49
52
  loop: 'loop arc with package names: packages whose types lead back to themselves <- phases.cycles',
50
53
  composes: 'hull round a chain: a definition takes the type the chain hands in at each step and applies it <- library.applied[].fedBy',
51
54
  port: 'plug strip on the right: a record of functions; each adapter a plug, hollow when nothing plugs it in <- library.ports[]',
52
55
  generic: 'the letter ∀ with its variables over a part that never looks inside what it carries <- library.generic[]',
56
+ program: 'large container: a pure program, its step and package named, with its package\'s own definition count (as the layer stack counts them) and how many more it uses from other packages, the most used named with their counts; routes that act on one step\'s answer per call are named as its starters ("one step per call of"), and routes that only replay it are named on hover <- programs[], definitions, usesElsewhere, elsewhere[], starters[advances=false], replays[]',
57
+ decision: 'bar and number in the program: its decisions, on one scale shared with its engines\' bars <- programs[].decisions',
58
+ struck: 'on a program, a struck "effects": no definition in its package performs an effect <- programs[].beside = []',
59
+ programEffect: 'red disc and count on a program: definitions in its own package that perform effects, named in its title <- programs[].beside[]',
60
+ request: 'opening on the program\'s right edge: a request constructor its step can return, with its payload\'s types <- programs[].requests[]',
61
+ variant: 'short opening under a request: a constructor of a user sum type its payload carries, with what the program calls it ("BriefCase → Lawyer") where a total match maps each kind to a plain constructor; eight drawn, the rest counted with their names in a title <- programs[].requests[].variants[], namings[]',
62
+ starter: 'dashed line from a route\'s reply bar into the program top: the route calls the step with its values and acts on the answer, found by value flow; a route that runs an engine is named under its loop instead <- programs[].starters[advances=false]',
63
+ gap: 'struck opening: a request constructor no engine handles <- programs[].gaps[]',
64
+ wire: 'line from an opening to the lane that handles it, ending in the names of the resources the arm reaches, or a dot each where they do not fit <- programs[].requests[].handledBy',
65
+ lane: 'thin vertical line on the resource side: an engine that performs the program\'s requests, its package above it (package.definition where lanes share one) and what it does written from its type (its own parameters → what its effect yields), its code name and whole type on hover; it ends in the resources its arms reach, then dashed pills under "also" for resources it reaches for no request <- programs[].engines[], pkgLabel, type, resources, also',
66
+ laneFork: 'bar and number on a lane: the engine\'s decisions, on the program\'s scale; 0 = a rule-free engine <- programs[].engines[].decisions',
67
+ 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
+ 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[]',
53
69
  },
54
70
  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.',
55
71
  };
@@ -66,6 +82,75 @@ export function effectsOf(steps) {
66
82
  }
67
83
  const isWrite = op => op !== 'read';
68
84
  const uniqBy = (xs, key) => { const seen = new Set(); return xs.filter(x => !seen.has(key(x)) && seen.add(key(x))); };
85
+ // The resource an engine effect reaches; the program runner's two kinds by name of kind.
86
+ const RUNNER = { CompileProgram: 'compile', RunProgram: 'run' };
87
+ export const effectResource = e => e.resource || RUNNER[e.kind] || 'io';
88
+ const LANE_RES = ['db', 'model', 'net', 'compile', 'run', 'io'];
89
+ const resOrder = rs => LANE_RES.filter(r => rs.includes(r));
90
+
91
+ // Programs: a pure step whose result carries a request, the engines that
92
+ // perform each request, and the requests no engine handles.
93
+ export function programsOf(st, layers = null) {
94
+ const found = (st.programs && st.programs.programs) || [];
95
+ // a package's own definitions, counted as the layers lens counts them
96
+ const ownCount = pkg => { const lp = layers && layers.packages.find(x => x.name === pkg); return lp ? lp.definitions.length : null; };
97
+ return found.map(p => {
98
+ const engines = p.engines.map(e => {
99
+ const resources = resOrder(uniq(e.handles.flatMap(h => h.effects.map(effectResource))));
100
+ return {
101
+ id: e.id, label: bare(e.id), pkg: pkgOf(e.id), pkgLabel: pkgOf(e.id).split('/').pop(),
102
+ handles: e.handles.map(h => h.constructor),
103
+ resources,
104
+ // effects the engine performs that are no request's: drawn, not hidden
105
+ also: resOrder(uniq((e.effects || []).map(effectResource))).filter(r => !resources.includes(r)),
106
+ replies: resOrder(uniq(e.replies.map(effectResource))),
107
+ decisions: +e.decisions, entries: e.entries, truncated: e.truncated,
108
+ loop: e.loop ? { kind: e.loop.kind, by: e.loop.by, label: e.loop.by ? bare(e.loop.by) : null } : { kind: 'sequence', by: null, label: null },
109
+ starters: (p.starters || []).filter(s => s.advances && s.via.some(v => v === e.id || e.entries.includes(s.entry))).map(s => s.entry),
110
+ // what it does, from its type: the program's own and standard parameters, and what its effect yields; the whole type on hover
111
+ type: { takes: e.labels.takes, returns: e.labels.returns, plainTakes: e.labels.plainTakes, yields: e.labels.yields,
112
+ from: 'structure.programs.programs[].engines[].labels' },
113
+ from: 'structure.programs.programs[].engines[]',
114
+ }; });
115
+ const requests = p.requests.map(q => ({
116
+ constructor: q.constructor, fields: q.fields.map(f => f.label),
117
+ variants: (q.variants || []).map(v => ({ path: v.path, type: bare(v.type), constructors: v.constructors,
118
+ // what the program calls each kind: total matches onto plain constructors, the type's own package first
119
+ namings: (v.namings || []).slice().sort((a, b) => (pkgOf(b.definition) === pkgOf(v.type)) - (pkgOf(a.definition) === pkgOf(v.type))).map(n => ({
120
+ definition: n.definition, label: bare(n.definition), type: bare(n.type), names: n.names.map(x => ({ constructor: x.constructor, name: x.name })),
121
+ from: 'structure.programs.programs[].requests[].variants[].namings[]' })),
122
+ from: 'structure.programs.programs[].requests[].variants[]' })),
123
+ handledBy: p.engines.filter(e => e.handles.some(h => h.constructor === q.constructor)).map(e => ({
124
+ engine: e.id, resources: resOrder(uniq(e.handles.find(h => h.constructor === q.constructor).effects.map(effectResource))) })),
125
+ gap: p.gaps.some(g => g.constructor === q.constructor),
126
+ from: 'structure.programs.programs[].requests[] + engines[].handles[]',
127
+ }));
128
+ return {
129
+ id: p.step, label: bare(p.step), pkg: p.package, pkgLabel: p.package.split('/').pop(),
130
+ result: bare(p.result), requestType: bare(p.requestType), carrier: p.carrier,
131
+ decisions: +p.decisions, finishes: p.finishes,
132
+ // definitions: the package's own, as the layer stack counts them; reached: everything the program's definitions use
133
+ definitions: ownCount(p.package) ?? +(p.ownDefinitions || 0), reached: p.definitions.length,
134
+ // of what it reaches, the definitions that live in other packages
135
+ usesElsewhere: p.definitions.filter(d => pkgOf(d) !== p.package).length,
136
+ // and which packages those are, the most used first
137
+ elsewhere: Object.entries(p.definitions.filter(d => pkgOf(d) !== p.package).reduce((m, d) => ({ ...m, [pkgOf(d)]: (m[pkgOf(d)] || 0) + 1 }), {}))
138
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([pkg, n]) => ({ pkg, label: pkg.split('/').pop(), n, from: 'structure.programs.programs[].definitions[]' })),
139
+ requests, engines,
140
+ starters: (p.starters || []).map(s => ({ entry: s.entry, path: s.entry.replace(/^route:/, ''), advances: s.advances, via: s.via,
141
+ from: 'structure.programs.programs[].starters[]' })),
142
+ // routes that call the step and act on nothing it says: they read where the program is
143
+ replays: (p.replays || []).map(s => ({ entry: s.entry, path: s.entry.replace(/^route:/, ''), via: s.via, from: 'structure.programs.programs[].replays[]' })),
144
+ // what each reaches, as the structure lens's effect sites say (io when none is a resource)
145
+ beside: (p.beside || []).map(b => ({ id: b.definition, label: bare(b.definition), kinds: b.kinds,
146
+ resources: resOrder(uniq((st.ioKinds || []).filter(k => k.definition === b.definition).flatMap(k => k.resources))),
147
+ from: 'structure.programs.programs[].beside[], structure.ioKinds[].resources' })),
148
+ gaps: p.gaps.map(g => ({ constructor: g.constructor, from: 'structure.programs.programs[].gaps[]' })),
149
+ from: 'structure.programs.programs[]',
150
+ };
151
+ });
152
+ }
153
+
69
154
  const outsideResourcesOf = steps => uniq((steps || []).map(e => e.resource).filter(r => r === 'model' || r === 'net'));
70
155
 
71
156
  export function buildSystemMap(engine, { program }) {
@@ -106,7 +191,7 @@ export function buildSystemMap(engine, { program }) {
106
191
  roles: r.requiredRoles || [],
107
192
  limits, limitComputed: ee.limits.written === 'computed',
108
193
  reachKinds: ee.reaches,
109
- model: ee.resources.includes('model'), net: ee.resources.includes('net'),
194
+ db: ee.resources.includes('db'), model: ee.resources.includes('model'), net: ee.resources.includes('net'),
110
195
  writes: reach.some(e => isWrite(e.op)),
111
196
  guards: rs.handlerGuards.map(bare), guardIds: rs.handlerGuards,
112
197
  wrapper, via: rs.viaSpecialization ? bare(rs.viaSpecialization) : null,
@@ -209,6 +294,7 @@ export function buildSystemMap(engine, { program }) {
209
294
  kind: routes.length ? 'service' : 'library',
210
295
  callers, ghostCallers, entries, wrappers, parts, uses, resources, ghostResources,
211
296
  library, keyed, steps,
297
+ programs: programsOf(st, layers),
212
298
  bundles: routes.length > 12 ? bundlesOf(entries, uses, keyed) : null,
213
299
  phases: st.phases,
214
300
  evidence: { tools: ['api_report', 'api_organize:abstractions', 'api_organize:structure', 'api_organize:layers'], headline: report.headline,
@@ -216,6 +302,34 @@ export function buildSystemMap(engine, { program }) {
216
302
  };
217
303
  }
218
304
 
305
+ // Two versions of one program: a check whose place on a route's path (the
306
+ // handler's own checks, the handed-in function's, the after band's, by
307
+ // position) holds a different definition in the head than in the base,
308
+ // where neither definition is anywhere else on that route, took the old
309
+ // one's place. That is all it says: nothing here shows the two do the
310
+ // same. A route only in one version is left alone.
311
+ export function replacedGuards(base, head) {
312
+ const slots = e => [['handler', e.guardIds], ['passed', e.passed[0]?.guardIds || []], ['after', e.beside?.guardIds || []]];
313
+ const byPath = new Map(base.entries.map(e => [e.path, e]));
314
+ const out = [];
315
+ for (const e of head.entries) {
316
+ const b = byPath.get(e.path);
317
+ if (!b) continue;
318
+ const all = x => new Set(slots(x).flatMap(([, ids]) => ids));
319
+ const ba = all(b), ha = all(e);
320
+ for (const [[slot, hs], [, bs]] of slots(e).map((s, i) => [s, slots(b)[i]]))
321
+ hs.forEach((id, i) => {
322
+ const was = bs[i];
323
+ if (was && was !== id && !ha.has(was) && !ba.has(id)) {
324
+ // the label tells the two apart: the old one's package too when both have one name
325
+ const wasLabel = bare(was) === bare(id) ? `${pkgOf(was).split('/').pop()}::${bare(was)}` : bare(was);
326
+ out.push({ entry: e.key, within: slot, at: i, guard: id, was, wasLabel, from: 'derived: the same place on the same route in the base record' });
327
+ }
328
+ });
329
+ }
330
+ return out;
331
+ }
332
+
219
333
  export const keyKindOf = e => e.authority === 'publishable' ? 'publishable' : e.signedIn ? 'secret' : 'public';
220
334
 
221
335
  // Bundles: entries that share a wrapper and draw identical marks. Names,