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.
@@ -41,7 +41,7 @@
41
41
  // (poteto) TLA+ diagrams.
42
42
 
43
43
  import { mark, tg, MIN_LABEL, COLORS as C } from './system-map.js';
44
- import { walk, leavesOf, pathEffects, allEffects, stationLabel, outcomeLabel, isWriteOp } from './what-happens-record.js';
44
+ import { walk, leavesOf, pathEffects, allEffects, stationLabel, stationDetail, fromWord, outcomeLabel, isWriteOp } from './what-happens-record.js';
45
45
  import { SANS, MONO, textWidth, clipText, boxAttr, fitAttr, labelAttr, labelled } from './label-fit.js';
46
46
 
47
47
  const RED = C.guard, GREEN = '#1f7a45', GREEN_SOFT = '#e4f4ea', RED_SOFT = '#fbe9e7';
@@ -223,9 +223,9 @@ export function renderWhatHappens(record, opts = {}) {
223
223
  y = frameTop + (phone ? 18 : 22);
224
224
  // the entry rack: the described entries, then one line for the others
225
225
  y = rack(record, entry, P, y, out, !!opts.ghosts || opts.open === 'ghosts');
226
- // whether a base was compared, always in this place: a fix is then read
227
- // off the frame, also when nothing is green
228
- y = compared(record, P, y + (phone ? 13 : 15), out);
226
+ // what a compared base says, in this place: a fix is then read off the
227
+ // frame, also when nothing is green; with no base, nothing is drawn here
228
+ if (record.diff) y = compared(record, P, y + (phone ? 13 : 15), out);
229
229
  y += phone ? 10 : 14;
230
230
  // the entry's own line
231
231
  const o = parseOpen(opts.open);
@@ -243,6 +243,13 @@ export function renderWhatHappens(record, opts = {}) {
243
243
  }).map(effectKey));
244
244
  const x0 = P.pad + (phone ? 6 : 12);
245
245
  y = entryHead(entry, root || stepNode, record, P, x0, y, out, opts.open);
246
+ // entries that take the same steps, on other tables and checks
247
+ if (entry.sameShape && !root && !stepNode) {
248
+ const names = entry.sameShape.entries.map(id => id.replace(/^route:/, ''));
249
+ lines(`same steps as ${names.join(', ')} · on their own tables and checks`, P.fsS, P.W - P.pad - x0 - 12, false, 2)
250
+ .forEach(l => { out.push(tg([`sameShape:${entry.entry}`], T(x0 + 12, y + P.row * 0.2, l, { size: P.fsS, fill: C.muted, mono: false }))); y += P.row * 0.8; });
251
+ y += P.row * 0.3;
252
+ }
246
253
  const subject = root ? root.next : stepNode || entry.tree;
247
254
  // work beside the wrapper, folded; a wrapper's work after the handler is
248
255
  // folded on each path that runs it, just before its outcome
@@ -263,6 +270,35 @@ export function renderWhatHappens(record, opts = {}) {
263
270
  y = tree(subject, 0, x0, y, P, out, entry, true);
264
271
  }
265
272
  y = witnessList(record, entry, subject, P, x0, y + (phone ? 10 : 14), out);
273
+ // what each table it touches holds, by its checked row type, and then the tables only
274
+ // the entries with the same steps touch (which this tree stands for), each said to be theirs
275
+ const touched = new Map();
276
+ const touch = (es, by) => { for (const e of es || []) for (const tb of e.tables || []) if (!touched.has(tb)) touched.set(tb, by); };
277
+ for (const e of [entry, ...(entry.sameShape?.entries || []).map(id => record.entries.find(x => x.entry === id)).filter(Boolean)]) {
278
+ const by = e === entry ? null : e.entry.replace(/^route:/, '');
279
+ touch(e.effects, by);
280
+ walk(e === entry ? subject : e.tree, x => { if (!x.kind) touch(x.effects, by); });
281
+ }
282
+ for (const [tb, by] of [...touched].filter(([tb]) => record.rows && record.rows[tb])) {
283
+ for (const l of lines(`${by ? `in ${by}, ` : ''}${tb} holds ${record.rows[tb]}`, P.fsS, P.W - P.pad - x0 - 6, true, 3)) { y += P.row * 0.8; out.push(tg([`rows:${tb}`], T(x0, y, l, { size: P.fsS, fill: C.muted }))); }
284
+ y += P.row * 0.2;
285
+ }
286
+ // the checks it names are the author's labels: what each decides is in its code
287
+ const library = id => /^(Bosatsu|Yichus)\//.test(id);
288
+ const namedIds = [];
289
+ walk(subject, t => { if (t.kind === 'node' && t.from?.kind === 'call' && !library(t.from.callee) && !namedIds.includes(t.from.callee)) namedIds.push(t.from.callee); });
290
+ const named = [...new Set(namedIds.map(bare))];
291
+ if (named.length) {
292
+ const note = `${named.join(', ')}: names the author chose; this shows where each runs, what it is given and what happens when it fails, not what it decides`;
293
+ for (const l of lines(note, P.fsS, P.W - P.pad - x0 - 6, false, 3)) { y += P.row * 0.8; out.push(tg(['names'], T(x0, y, l, { size: P.fsS, fill: C.muted, mono: false }))); }
294
+ y += P.row * 0.3;
295
+ }
296
+ // what the author wrote above each named check: their words, in italic, not checked
297
+ for (const id of namedIds.filter(id => record.docs && record.docs[id])) {
298
+ const said = `${bare(id)}, in its author's comment (unchecked): ${record.docs[id].replace(/\s+/g, ' ')}`;
299
+ for (const l of lines(said, P.fsS, P.W - P.pad - x0 - 6, false, 3)) { y += P.row * 0.8; out.push(tg([`docs:${id}`], T(x0, y, l, { size: P.fsS, fill: C.muted, mono: false, italic: true }))); }
300
+ y += P.row * 0.2;
301
+ }
266
302
  const H = Math.ceil(y + (phone ? 10 : 16));
267
303
  const frame = labelled('frame', `<rect x="2.5" y="${frameTop}" width="${W - 5}" height="${H - frameTop - 3}" rx="10" fill="none" stroke="${C.frame}" stroke-width="1"/>` +
268
304
  `<text x="14" y="${frameTop + 4}" font-family="${SANS}" font-size="${phone ? 9 : 10}" font-weight="600" fill="${C.muted}" stroke="${C.page}" stroke-width="6" paint-order="stroke">Yichus output</text>`);
@@ -277,6 +313,12 @@ function rack(record, selected, P, y, out, expanded) {
277
313
  const x0 = pad + (phone ? 6 : 12);
278
314
  let x = x0, yy = y;
279
315
  const ghosts = record.entries.filter(e => !e.described);
316
+ // no entry described: one line that opens to list them all
317
+ if (ghosts.length === record.entries.length && !expanded) {
318
+ const n = ghosts.length, text = `${n} ${n === 1 ? 'entry' : 'entries'}`;
319
+ out.push(tg([`ghosts:${n}`], `<g data-ghosts="folded"${opener('ghosts', `Show the ${n} entries no scenario describes`)}>${title(`${n} entries no scenario describes`)}${ghostHead(x, yy)}${T(x + 11, yy, text, { size: fs, fill: C.muted, mono: false })}</g>`));
320
+ return yy + 4;
321
+ }
280
322
  out.push(T(x, yy, ghosts.length ? `entries · ${record.entries.length - ghosts.length} of ${record.entries.length} described by a scenario` : `entries · every one described`,
281
323
  { size: fs, fill: C.muted, mono: false }));
282
324
  yy += lh;
@@ -315,10 +357,6 @@ function rack(record, selected, P, y, out, expanded) {
315
357
  }
316
358
  function compared(record, P, y, out) {
317
359
  const x = P.pad + (P.phone ? 6 : 12), d = record.diff;
318
- if (!d) {
319
- out.push(tg(['compared:none'], `<g${labelAttr('compared')}>${title('no earlier version of the program was compared, so nothing here says what was fixed')}${T(x, y, 'no base compared', { size: P.fsS, fill: C.faint, mono: false })}</g>`));
320
- return y + 4;
321
- }
322
360
  const parts = [[`compared with ${d.base}`, C.muted, null], [`${d.fixed.length} fixed`, d.fixed.length ? GREEN : C.muted, 600], [`${d.stillRed.length} still failing`, d.stillRed.length ? RED : C.muted, 600]];
323
361
  if (d.unresolved.length) parts.push([`${d.unresolved.length} no longer checked`, C.muted, 600]);
324
362
  if (d.goneAtHead.length) parts.push([`${d.goneAtHead.length} gone with ${d.goneAtHead.length === 1 ? 'its entry' : 'their entries'}`, C.muted, null]);
@@ -370,13 +408,25 @@ function stations(effects, x, y, P, out, key) {
370
408
  const text = stationLabel(e) + (isCore(e) ? '' : ` · ${e.phase === 'after' ? `after the handler${whose}${after ? (after.everyPath ? ' · on every path that runs it' : ' · not on every path that runs it') : ''}` : e.layer === 'beside' ? 'beside the wrapper' : `before the handler${whose}`}`);
371
409
  const where = `${text} in ${String(e.site).split('::').pop()}${e.line ? `, line ${e.line}` : ''}`;
372
410
  const rows = lines(text, P.fsS, P.W - P.pad - x - r - 10, true, 2);
373
- out.push(tg([`effect:${key}:${e.op}:${e.tables.join(',')}`], `<g data-layer="${e.layer || 'core'}">${title(where)}${ring}${rows.map((l, i) => T(x + r + 6 + (i ? 8 : 0), cy + 3.5 + i * P.row * 0.8, l, { size: P.fsS, fill: isCore(e) ? color : C.hullInk })).join('')}</g>`));
374
- if (rows.length > 1) out.push(line(x, cy, x, cy + P.row * 1.2));
375
- y = cy + P.row * 0.6 + (rows.length - 1) * P.row * 0.8;
411
+ // the row it touches and where what it writes comes from, under the label
412
+ // (measured at the size it is drawn at: never below the smallest label)
413
+ const dsz = Math.max(MIN_LABEL, P.fsS - 1);
414
+ const detail = stationDetail(e), more = detail ? lines(detail, dsz, P.W - P.pad - x - r - 18, true, 2) : [];
415
+ const all = [...rows.map(l => [l, P.fsS, isCore(e) ? color : C.hullInk]), ...more.map(l => [l, dsz, C.muted])];
416
+ out.push(tg([`effect:${key}:${e.op}:${e.tables.join(',')}`], `<g data-layer="${e.layer || 'core'}">${title(detail ? `${where}: ${detail}` : where)}${ring}${all.map(([l, size, fill], i) => T(x + r + 6 + (i ? 8 : 0), cy + 3.5 + i * P.row * 0.8, l, { size, fill })).join('')}</g>`));
417
+ if (all.length > 1) out.push(line(x, cy, x, cy + P.row * (0.6 + (all.length - 1) * 0.8)));
418
+ y = cy + P.row * 0.6 + (all.length - 1) * P.row * 0.8;
376
419
  }
377
420
  return y;
378
421
  }
379
422
 
423
+ const forkSources = t => {
424
+ const f = t.from || {};
425
+ if (f.kind === 'call' || f.kind === 'derived') return (f.sources || []).map(fromWord).join(', ') || null;
426
+ if (f.kind === 'read') return f.op === 'mint id' ? 'new id' : `${f.table || bare(f.primitive)} as read`;
427
+ if (f.kind === 'request' || f.kind === 'caller') return [f.kind, ...(f.path || [])].join('.');
428
+ return null;
429
+ };
380
430
  // One subtree from (x, y) down. Returns the y where it ends.
381
431
  // A folded step: one shaded box on the line (the map's hull), its role,
382
432
  // what it answers instead of running the handler, and its effects.
@@ -467,9 +517,18 @@ function tree(t, depth, x, y, P, out, entry, top) {
467
517
  out.push(tg([`words:${t.id}`], `<g>${title(`${t.test} (${t.key})`)}${T(lx, yy, clip(t.words, P.fs, room, false, 400, true), { size: P.fs, italic: true, mono: false, fill: crossNode ? C.hullInk : C.ink })}</g>`));
468
518
  if (!P.phone) out.push(T(lx + Math.min(room, width(clip(t.words, P.fs, room, false, 400, true), P.fs, false, 400, true)) + 8, yy, clip(t.test, P.fsS, Math.max(0, room - width(t.words, P.fs, false, 400, true) - 10)), { size: P.fsS, fill: C.faint }));
469
519
  } else {
470
- for (const [i, l] of lines(t.test, P.fs, room, !guard, 2, guard ? 600 : 400).entries())
520
+ const rows = lines(t.test, P.fs, room, !guard, 2, guard ? 600 : 400);
521
+ for (const [i, l] of rows.entries())
471
522
  out.push(T(lx, yy + i * P.row * 0.85, l, { size: P.fs, mono: !guard, fill: guard ? RED : crossNode ? C.hullInk : C.ink2, weight: guard ? 600 : null }));
472
- if (width(t.test, P.fs, !guard, guard ? 600 : 400) > room) yy += P.row * 0.85;
523
+ if (rows.length > 1) yy += P.row * 0.85;
524
+ // what the branch tests comes from: the request, the caller, a read
525
+ const src = forkSources(t);
526
+ if (src) {
527
+ const after = width(rows[rows.length - 1], P.fs, !guard, guard ? 600 : 400) + 8, left = room - after;
528
+ const text = `← ${src}`;
529
+ if (width(text, P.fsS, true) <= left) out.push(T(lx + after, yy, text, { size: P.fsS, fill: C.muted }));
530
+ else { yy += P.row * 0.8; out.push(T(lx, yy, clip(text, P.fsS, room), { size: P.fsS, fill: C.muted })); }
531
+ }
473
532
  }
474
533
  y = yy + P.row * 0.35;
475
534
  // the spine continues straight down: the arm that reaches the main
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yichus",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Local Bosatsu compilation, Yichus analysis, and generated apps using the WebMCP engine",
5
5
  "license": "MIT",
6
6
  "homepage": "https://yich.us",
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "zod": "^4.3.6",
22
22
  "@modelcontextprotocol/sdk": "^1.26.0",
23
- "@yichus/api-mcp": "0.1.4"
23
+ "@yichus/api-mcp": "0.1.5"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^22.0.0",