diff --git a/docs/adr/0014-story-diagrams-verified-claims.md b/docs/adr/0014-story-diagrams-verified-claims.md new file mode 100644 index 0000000..f31f3fe --- /dev/null +++ b/docs/adr/0014-story-diagrams-verified-claims.md @@ -0,0 +1,93 @@ +# ADR-0014 — Story diagrams: the architecture and flow slots answer the reader's real questions, with claims the tool verifies + +Status: Accepted + Implemented (renderers, verifier, `make-diagrams` wiring, brain schema, tests); **not yet validated across a corpus of real builds** — see Consequences. +Date: 2026-10-10 +Amends: ADR-0005 (Station 4), ADR-0012 (grounded radial architecture), INV-23 (every image teaches) + +## Context + +The owner reviewed the architecture and flow graphics the pipeline produced for `realiti4/claude-swap` +(the page, and the README PR) and called them "vanilla … not particularly interesting, nor even +particularly helpful". Two specific misses: + +1. The flow diagram said "pick account with most quota left" as one box in a five-box vertical list. + It never showed *which* account would be picked, or that the choice is made among several — the + one thing a reader needs to see. +2. The owner asked, in conversation, "does this work for Claude and Codex?" — and the explainer could + not answer. Nothing in the pipeline extracts *what it works with*. + +Root cause, from `tools/make-diagrams.mjs`: `buildArchModel` and `buildFlowModel` lay out the +dependency graph and the entrypoints mechanically. They describe *how the code is arranged*. A repo +with one module and zero internal edges (claude-swap: "1 module, 0 internal edges") falls back to the +authored 3–5 card concept row — the vertical list. The renderers have no vocabulary for a *choice* or +for *compatibility*, so no prompt can make them show either. + +The owner then reviewed two hand-authored graphics and said: "these should be the default … this is +the level and style I want associated with each one." + +## Decision + +Add two diagram types, drawn from small brain-authored models, that **replace** the architecture and +flow slots when present and verified (`src/story-diagrams.mjs`): + +- **`visuals.systemMap`** → `architectureDiagram`. Inputs → one engine (2–4 steps) → surfaces with + their real commands, plus a **WORKS WITH** strip and a **RUNS ON** row. +- **`visuals.decision`** → `flowDiagram`. Candidates drawn as gauges against a threshold; the active, + skipped, picked and eligible ones marked; the handoff animated. + +Same slots, same files (`assets/architecture.svg`, `assets/flow.svg`), same `build.json` keys — so +`assemble-page`, `readme-enhance`, the grader and the lightbox are unchanged. + +### Claims are verified by the tool, never taken from the brain + +This is the load-bearing rule. A picture of "works with Codex ✓" that the brain hallucinated is worse +than the vanilla one. So `make-diagrams` checks against the cloned repo (`repo.clonePath`): + +| Claim | Brain supplies | The tool does | +|---|---|---| +| `yes` / `partial` | `file` + a verbatim `quote` | drops it unless the quote is really in that file | +| `no` | `terms` (1–5 words) | greps README + source itself; keeps a "no" only on **zero** matches, and records "searched … 0 matches" | +| decision rule | `rule: { file, quote }` | drops the whole decision unless the quote is in the file | + +A "no" is therefore a *measurement the tool made*, not an assertion. A term that is mentioned drops +the pill — "unsupported" would be a guess. If nothing survives, the slot falls back to the grounded +graph diagram (ADR-0011: never fail a build for this). Evidence is recorded in `build.json` +(`visuals..storyEvidence` / `.storyNotes`). + +### Style + +The reference is the owner-approved claude-swap pair (`tests/fixtures/story-claude-swap.mjs`): dark +card on any theme, real commands in monospace, numbers on the gauges, one idea per picture. Motion is +CSS only, gated on `prefers-reduced-motion`, and the **static state is the end of the story** so a +still image or a reduced-motion reader still gets all of it. No scripts, no `foreignObject`, no +external references. + +## Consequences + +- **Mobile legibility is the known weakness.** Both canvases are ~1100 px wide; on a 312 px column + the 13 px labels render near 3.7 px. The page's tap-to-zoom lightbox covers it for now, and + `legibilityOf` does not see CSS-sized text, so these report `null`. A portrait layout is the + follow-up; do not claim mobile parity until it exists. +- **Not yet validated on a corpus.** Two repos' worth of evidence exists (claude-swap, hand-authored). + Whether the brain authors good `systemMap` / `decision` models unprompted across many repos is + unmeasured. The fallback keeps that safe; the gain is unproven until measured. +- **First tool→`src/` import.** Tools were self-contained; `make-diagrams` now imports + `../src/story-diagrams.mjs`. Tests that copy the tool elsewhere must mirror `tools/` + `src/` + (done in `diagram-form-diversity.test.mjs`). +- **`decision` fits choosing systems** (routers, pickers, schedulers); `systemMap` fits pipelines. A + repo with neither should omit the keys — the prompt says so — and keeps the graph diagram. +- Numbers on the decision gauges are illustrative, and the diagram says so in its subtitle and + ``; the *rule* is real and verified. + +## Verification (2026-10-10) + +- 23 unit tests (`tests/story-diagrams.test.mjs`): rendering, XML validity, no script/foreignObject, + reduced-motion gating, scale of the gauges, truncation, escaping, and **guards proven by mutation** + (removing the evidence/quote check fails 2 tests). +- End-to-end: `make-diagrams` on the claude-swap build with an injected fabricated claim — the + fabricated "Cursor" pill was dropped (`quote not found in README.md`), the real claims and the + zero-hit Codex search were kept, `xmllint` clean. +- Real brain run on claude-swap (`--only visual-brief`, then `make-diagrams`): the brain authored both + models unprompted — including a Codex "no" — and the tool confirmed the quotes and a zero-hit search. + The first run hit `max_tokens=6000` (the larger schema), so the visual-brief cap is now 10000. +- Full suite: 234 tests, 0 fail (dependencies installed). diff --git a/skills/explainmyrepo/SKILL.md b/skills/explainmyrepo/SKILL.md index a5361a7..29c4be6 100644 --- a/skills/explainmyrepo/SKILL.md +++ b/skills/explainmyrepo/SKILL.md @@ -477,6 +477,24 @@ parallel. bulleted list, not a structure): `"rows": [{ "items": ["your sentence", "tokenizer", "add · skip · subtract", "384 numbers"], "connect": true }]` +- **STORY DIAGRAMS (`visuals.systemMap`, `visuals.decision`) — answer the questions a reader + actually has (ADR-0014).** The dependency graph says how the code is arranged; it cannot say + *what this plugs into*, *what it works with*, or *how it decides*. Author these two when the repo + has a real answer, and OMIT them when it does not: + - `systemMap` → replaces the architecture slot: inputs → one engine (2–4 steps) → the surfaces that + drive it (with their real commands) + a **works-with** strip + **runs-on**. Include the hosts, + editors and platforms a reader would WONDER about (does it work with Codex? Windows?), each as + `yes`/`partial` with `file` + a VERBATIM `quote`, or `no` with `terms` to search. + - `decision` → replaces the flow slot, for repos that CHOOSE among candidates by a rule (pickers, + routers, schedulers, rankers): 2–6 candidates as gauges against a threshold, one picked, one + skipped for a stated reason, and `rule: { file, quote }` pointing at the source line that + defines it. + **You do not get to assert these.** `make-diagrams` checks every quote against the cloned repo and + runs the "no" searches itself; whatever it cannot confirm is dropped, and if nothing survives the + slot keeps the grounded graph diagram. So quote, do not paraphrase, and prefer fewer true claims. + Shapes are in `src/story-diagrams.mjs` (`validateSystemMap`, `validateDecision`) and a worked + example is `tests/fixtures/story-claude-swap.mjs`. + - **THE HERO ANIMATION (`visuals.heroAnim`) — motion that PERFORMS the argument.** Author this for every repo that has a "trick" (almost all of them do). It renders above the fold as a 9s loop and it is the only motion on the page. **Animate the ARGUMENT, never the chrome.** Crawling dashed diff --git a/src/brain.mjs b/src/brain.mjs index feda127..320773b 100644 --- a/src/brain.mjs +++ b/src/brain.mjs @@ -259,7 +259,9 @@ Author the visual brief. Return JSON: "flow": { "altText": "one-line description of the runtime/process flow diagram", "rows": [ { "items": ["3 to 5 SHORT labels naming the runtime steps, in order", "..."], "connect": true } ] - } + }, + "systemMap": "OPTIONAL — omit this key unless the brief shows a real inputs -> one engine -> surfaces pipeline. Shape: { \"title\": \"What X plugs into\", \"subtitle\": \"...\", \"inputs\": { \"label\": \"YOUR ACCOUNTS\", \"items\": [{ \"name\": \"<=40 chars\", \"sub\": \"...\" }] (1-5), \"note\": \"optional, 2 short lines separated by a newline\" }, \"engine\": { \"name\": \"...\", \"sub\": \"...\", \"steps\": [{ \"title\": \"<=30\", \"sub\": \"<=80\" }] (2-4) }, \"outputs\": { \"label\": \"THREE WAYS TO DRIVE IT\", \"streamLabel\": \"one event stream\", \"items\": [{ \"name\": \"...\", \"cmd\": \"a real command\", \"sub\": \"...\" }] (1-4) }, \"worksWith\": { \"items\": [ { \"name\": \"Claude Code CLI\", \"status\": \"yes\", \"file\": \"README.md\", \"quote\": \"a VERBATIM line from that file, copied from the brief\" }, { \"name\": \"Codex\", \"status\": \"no\", \"terms\": [\"codex\"] } ] (1-6), \"noNote\": \"not mentioned anywhere in the README or source\", \"runsOn\": [\"macOS\", \"Windows\"] } }", + "decision": "OPTIONAL — omit this key unless the repo visibly CHOOSES among candidates by a rule (picks, ranks, routes, selects). Shape: { \"title\": \"The moment X picks one\", \"subtitle\": \"Illustrative numbers. ...\", \"threshold\": { \"value\": 90, \"label\": \"switch point\" }, \"qualify\": { \"value\": 83, \"label\": \"to qualify\" } or null, \"candidates\": [{ \"name\": \"<=24\", \"value\": 0-100, \"status\": \"active|skipped|picked|eligible\", \"note\": \"short\" }] (2-6, at most one active and one picked), \"steps\": [{ \"title\": \"<=40\", \"sub\": \"<=60\" }] (2-4), \"caption\": \"one line\", \"rule\": { \"file\": \"path\", \"quote\": \"a VERBATIM line of source that defines the rule, copied from the brief\" } }" } } THE SWAP TEST — the single rule that decides whether an image ships (INV-22 / ADR-0008): @@ -288,8 +290,9 @@ DIAGRAM RULES (bigIdea + insight are DRAWN as real glassmorphic concept-cards jo - Use ONE row with "connect": true for a SEQUENCE (cards joined top-to-bottom by arrows). Use MULTIPLE rows (each "connect": false) for parallel/grouped ideas drawn without an arrow between groups. - bigIdea = the central mechanism in 3-6 cards (how the pieces combine to do the one big thing). insight = the single clever move in 2-4 cards. Keep BOTH distinct from the architecture diagram — do not just relist every module. - architecture.rows and flow.rows are a REQUIRED FALLBACK, not decoration (issue #17.4, pacphi). Those two diagrams are normally drawn from the real KB dep-graph and entrypoints. But when a repo's dependency graph is TRIVIAL (a docs vault, a single-module tool — 0 internal edges), a dependency map would be a picture of nothing, so make-diagrams REFUSES to draw one and falls back to your rows instead. Without them the build stops dead at Station 4. Author them for every repo; they cost you two lines and they are the difference between a page and a crash. -- architecture.rows = the CONCEPT of how the thing is built (the 3-5 parts a reader must hold in their head), NOT the package wiring — the wiring is what the grounded renderer already draws when it can. flow.rows = the runtime steps in order.`; - const out = await callClaudeJSON({ apiKey, env: ctx?._env, model, system, user, maxTokens: 6000 }); +- architecture.rows = the CONCEPT of how the thing is built (the 3-5 parts a reader must hold in their head), NOT the package wiring — the wiring is what the grounded renderer already draws when it can. flow.rows = the runtime steps in order. +- systemMap and decision (ADR-0014) are the BETTER answer to "what does it plug into / how does it decide" and REPLACE the architecture / flow drawing when present and verified. They are optional: omit rather than invent. THE TOOL VERIFIES EVERY CLAIM against the real repo and silently drops what it cannot confirm: a "yes"/"partial" must carry a file and a VERBATIM quote that is in that file in the brief; a "no" must list the search terms the tool will grep (use the other hosts / editors / platforms a reader would wonder about — the tool performs the search, you do not assert the absence). Use real commands and real numbers from the source; never paraphrase a quote.`; + const out = await callClaudeJSON({ apiKey, env: ctx?._env, model, system, user, maxTokens: 10000 }); if (!out?.hero?.prompt) throw new Error('authorVisualBrief: missing hero.prompt'); const okRows = (d) => d && Array.isArray(d.rows) && d.rows.length && d.rows.every((r) => r && Array.isArray(r.items) && r.items.length @@ -327,6 +330,10 @@ export function visualsSlotFromBrief(brief) { // could author a perfectly good fallback and the pipeline would still die at Station 4. architectureDiagram: conceptModel(brief.diagrams.architecture, 'How it is built'), flowDiagram: conceptModel(brief.diagrams.flow, 'What happens to your data'), + // ADR-0014: optional evidence-backed story models. Carried through UNCHANGED — make-diagrams + // validates and VERIFIES them against the cloned repo before anything is drawn. + ...(brief.diagrams.systemMap && typeof brief.diagrams.systemMap === 'object' ? { systemMap: brief.diagrams.systemMap } : {}), + ...(brief.diagrams.decision && typeof brief.diagrams.decision === 'object' ? { decision: brief.diagrams.decision } : {}), }; } diff --git a/src/story-diagrams.mjs b/src/story-diagrams.mjs new file mode 100644 index 0000000..c327135 --- /dev/null +++ b/src/story-diagrams.mjs @@ -0,0 +1,401 @@ +// src/story-diagrams.mjs — the two "story" diagram types (ADR-0014). +// +// WHY: the dependency-graph diagrams answer "how is the code laid out?". Readers ask other things: +// "what does this plug into, and does it work with MY tool?" and "how does it actually decide?". +// These renderers draw those two answers from a small, EVIDENCE-BACKED model the brain authors. +// +// renderSystemMap(model) — inputs → engine → outputs, plus a WORKS WITH strip whose every entry +// carries its evidence (a "no" is a recorded search, never a guess). +// renderDecision(model) — candidates as gauges against a threshold; skipped / picked marked; the +// handoff animated. Shows a choice being made, not a list of steps. +// +// PURE: no I/O, no network. validate*() enforce grounding BEFORE anything is drawn; render*() never +// emit a label wider than its box (fit() truncates with an ellipsis), so output is always legible. +// Motion is gated on prefers-reduced-motion, and the static (reduced-motion) state is the END of the +// story, so a still image still tells all of it. No scripts, no foreignObject, no external refs. + +const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +const num = (n) => Number(Number(n).toFixed(1)); + +// Average advance widths (px per char) for the fonts the SVG asks for — deliberately a touch wide. +const W_BOLD16 = 8.2, W_REG13 = 6.1, W_CODE13 = 7.9, W_BOLD14 = 7.8; + +/** Truncate `text` with an ellipsis so it fits `maxPx` at `perChar` px per character. */ +export function fit(text, maxPx, perChar) { + const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim(); + const max = Math.max(1, Math.floor(maxPx / perChar)); + return s.length <= max ? s : `${s.slice(0, Math.max(1, max - 1)).trimEnd()}…`; +} + +/** Word-wrap to at most `maxLines` lines of `maxPx`; only the final line is ever truncated. */ +export function wrap(text, maxPx, perChar, maxLines = 2) { + const words = String(text == null ? '' : text).replace(/\s+/g, ' ').trim().split(' ').filter(Boolean); + const cap = Math.max(1, Math.floor(maxPx / perChar)); + const lines = []; + let cur = ''; + for (const w of words) { + const next = cur ? `${cur} ${w}` : w; + if (next.length <= cap) { cur = next; continue; } + if (cur) lines.push(cur); + cur = w; + } + if (cur) lines.push(cur); + if (lines.length <= maxLines) return lines.map((l) => fit(l, maxPx, perChar)); + const head = lines.slice(0, maxLines - 1); + return [...head, fit(lines.slice(maxLines - 1).join(' '), maxPx, perChar)]; +} + +const isStr = (v, max = 160) => typeof v === 'string' && v.trim() !== '' && v.length <= max; + +// ── validation (grounding gate) ──────────────────────────────────────────────────────────────────── +/** @returns {string[]} problems; empty means the model is drawable AND grounded. */ +export function validateSystemMap(m) { + const e = []; + if (!m || typeof m !== 'object') return ['systemMap: not an object']; + if (!isStr(m.title, 60)) e.push('title: required, ≤60 chars'); + const list = (v, min, max, name) => { + if (!Array.isArray(v) || v.length < min || v.length > max) { e.push(`${name}: need ${min}–${max} items`); return []; } + return v; + }; + list(m.inputs && m.inputs.items, 1, 5, 'inputs.items').forEach((it, i) => { + if (!isStr(it && it.name, 40)) e.push(`inputs.items[${i}].name required`); + }); + const steps = list(m.engine && m.engine.steps, 2, 4, 'engine.steps'); + if (!m.engine || !isStr(m.engine.name, 40)) e.push('engine.name required'); + steps.forEach((s, i) => { if (!isStr(s && s.title, 30) || !isStr(s && s.sub, 80)) e.push(`engine.steps[${i}] needs title ≤30 and sub ≤80`); }); + list(m.outputs && m.outputs.items, 1, 4, 'outputs.items').forEach((it, i) => { + if (!isStr(it && it.name, 40)) e.push(`outputs.items[${i}].name required`); + if (it && it.cmd != null && !isStr(it.cmd, 40)) e.push(`outputs.items[${i}].cmd too long`); + }); + const ww = list(m.worksWith && m.worksWith.items, 1, 6, 'worksWith.items'); + ww.forEach((it, i) => { + if (!isStr(it && it.name, 28)) e.push(`worksWith.items[${i}].name required`); + if (!it || !['yes', 'partial', 'no'].includes(it.status)) { e.push(`worksWith.items[${i}].status must be yes|partial|no`); return; } + // the grounding rule: every claim must be CHECKABLE by the tool — it does not take the brain's word. + if (it.status === 'no') { + const t = Array.isArray(it.terms) ? it.terms : []; + if (t.length < 1 || t.length > 5 || !t.every((x) => isStr(x, 30))) e.push(`worksWith.items[${i}].terms required for a "no" (1–5 search terms the tool will grep for)`); + } else if (!isStr(it.file, 200) || !isStr(it.quote, 240)) { + e.push(`worksWith.items[${i}] needs file + quote — a verbatim line from that file that supports the claim`); + } + }); + return e; +} + +export function validateDecision(m) { + const e = []; + if (!m || typeof m !== 'object') return ['decision: not an object']; + if (!isStr(m.title, 60)) e.push('title: required, ≤60 chars'); + const c = Array.isArray(m.candidates) ? m.candidates : []; + if (c.length < 2 || c.length > 6) e.push('candidates: need 2–6'); + c.forEach((it, i) => { + if (!isStr(it && it.name, 24)) e.push(`candidates[${i}].name required`); + if (!it || typeof it.value !== 'number' || it.value < 0 || it.value > 100) e.push(`candidates[${i}].value must be 0–100`); + if (!it || !['active', 'skipped', 'picked', 'eligible'].includes(it.status)) e.push(`candidates[${i}].status invalid`); + }); + if (c.filter((x) => x && x.status === 'picked').length > 1) e.push('candidates: at most one picked'); + if (c.filter((x) => x && x.status === 'active').length > 1) e.push('candidates: at most one active'); + if (!m.threshold || typeof m.threshold.value !== 'number' || !isStr(m.threshold.label, 60)) e.push('threshold: {value, label} required'); + const steps = Array.isArray(m.steps) ? m.steps : []; + if (steps.length < 2 || steps.length > 4) e.push('steps: need 2–4'); + steps.forEach((s, i) => { if (!isStr(s && s.title, 40) || !isStr(s && s.sub, 60)) e.push(`steps[${i}] needs title ≤40 and sub ≤60`); }); + if (!m.rule || !isStr(m.rule.file, 200) || !isStr(m.rule.quote, 240)) e.push('rule: {file, quote} required — a verbatim line from the source that defines the decision'); + return e; +} + +// ── shared style ─────────────────────────────────────────────────────────────────────────────────── +const FONT = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; +const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; +const C = { bg: '#0d1117', card: '#161b22', line: '#30363d', fg: '#f0f6fc', mute: '#8b949e', red: '#f85149', amber: '#d29922', green: '#3fb950', blue: '#4493c9', code: '#79c0ff' }; + +function style(extra = '') { + return ``; +} + +const norm = (t) => String(t).toLowerCase().replace(/[\s*_`>#|-]+/g, ' ').trim(); + +/** + * Check every "works with" claim against the real repo and return a NEW model holding only what the + * tool itself could confirm. `read(file)` → text|null; `search(terms)` → [{file,line}] (case-insensitive). + * yes / partial → kept only if the quote is really in the file; + * no → kept only if the tool's own search finds ZERO mentions (a "no" is a measurement); + * a term that IS mentioned means "unsupported" would be a guess — the pill is dropped. + * @returns {{ model: object, notes: string[] }} + */ +export function verifySystemMap(model, { read, search }) { + const notes = []; + const items = []; + for (const it of model.worksWith.items) { + if (it.status === 'no') { + const hits = search(it.terms) || []; + if (hits.length === 0) { + items.push({ ...it, evidence: `searched ${it.terms.join(', ')} in the README and source: 0 matches` }); + } else { + notes.push(`dropped "${it.name}": claimed no support but ${hits.length} mention(s), e.g. ${hits[0].file}:${hits[0].line}`); + } + continue; + } + const text = read(it.file); + if (text != null && norm(text).includes(norm(it.quote))) { + items.push({ ...it, evidence: `${it.file}: "${it.quote}"` }); + } else { + notes.push(`dropped "${it.name}": quote not found in ${it.file}${text == null ? ' (file unreadable)' : ''}`); + } + } + return { model: { ...model, worksWith: { ...model.worksWith, items } }, notes }; +} + +/** A decision is only drawn when its rule's quote really is in the named file. @returns {{ok:boolean, note?:string}} */ +export function verifyDecision(model, { read }) { + const text = read(model.rule.file); + if (text != null && norm(text).includes(norm(model.rule.quote))) return { ok: true }; + return { ok: false, note: `decision dropped: quote not found in ${model.rule.file}${text == null ? ' (file unreadable)' : ''}` }; +} + +export function describeSystemMap(m) { + const ww = m.worksWith.items.map((x) => `${x.name}: ${x.status === 'yes' ? 'works' : x.status === 'partial' ? 'partly' : 'no evidence of support'}`).join('; '); + return `${m.inputs.items.map((i) => i.name).join(', ')} feed ${m.engine.name} (${m.engine.steps.map((x) => x.title).join(', ')}), which drives ${m.outputs.items.map((x) => x.name).join(', ')}. Works with — ${ww}.`; +} + +export function describeDecision(m) { + const sum = m.candidates.map((c) => `${c.name} at ${c.value} percent${c.status === 'active' ? ' (active, over the line)' : c.status === 'skipped' ? ' (skipped)' : c.status === 'picked' ? ' (picked)' : ' (eligible)'}`).join('; '); + return `${sum}. ${m.steps.map((x) => x.title).join('. ')}. Numbers are illustrative.`; +} + +// ── SYSTEM MAP ───────────────────────────────────────────────────────────────────────────────────── +const SW = { yes: [C.green, '#12261a', '✓'], partial: [C.amber, '#2a2412', '~'], no: [C.amber, '#2a1a12', '✕'] }; +const DOTS = [C.red, C.amber, C.green, C.blue, '#a371f7']; + +export function renderSystemMap(m) { + const problems = validateSystemMap(m); + if (problems.length) throw new Error(`systemMap invalid: ${problems.join('; ')}`); + const W = 1110, LX = 40, LW = 210, EX = 330, EW = 330, OX = 770, OW = 300, TOP = 104, LH = 16; + const ins = m.inputs.items, steps = m.engine.steps, outs = m.outputs.items; + + // every card measures its own wrapped text, so nothing is cut mid-word and cards stay tidy + const inCards = ins.map((it) => { + const nm = wrap(it.name, LW - 56, W_BOLD16, 2); + const sub = it.sub ? wrap(it.sub, LW - 56, W_REG13, 2) : []; + return { it, nm, sub, h: 62 + Math.max(0, nm.length - 1) * 18 + Math.max(0, sub.length - 1) * LH }; + }); + let y = TOP + 12; + inCards.forEach((c) => { c.y = y; y += c.h + 14; }); + const inBottom = y - 14; + const noteLines = m.inputs.note ? String(m.inputs.note).split('\n').slice(0, 2).flatMap((ln) => wrap(ln, LW - 30, W_REG13, 2)).slice(0, 3) : []; + const noteH = noteLines.length ? 22 + noteLines.length * 20 : 0; + const leftBottom = inBottom + (noteH ? 16 + noteH : 0); + + const engSub = m.engine.sub ? wrap(m.engine.sub, EW - 44, W_REG13, 2) : []; + const stepCards = steps.map((st) => { const sub = wrap(st.sub, EW - 100, W_REG13, 2); return { st, sub, h: 62 + Math.max(0, sub.length - 1) * LH }; }); + y = TOP + 68 + Math.max(0, engSub.length - 1) * LH; + stepCards.forEach((c) => { c.y = y; y += c.h + 22; }); + const engBottom = y - 22 + 20; + + const outCards = outs.map((it) => { const sub = it.sub ? wrap(it.sub, OW - 40, W_REG13, 2) : []; return { it, sub, h: 80 + Math.max(0, sub.length - 1) * LH }; }); + y = TOP + 12; + outCards.forEach((c) => { c.y = y; y += c.h + 21; }); + const outBottom = y - 21; + + const chartBottom = Math.max(leftBottom, engBottom, outBottom); + const ww = m.worksWith.items; + const runsOn = Array.isArray(m.worksWith.runsOn) ? m.worksWith.runsOn.slice(0, 5) : []; + const bandY = chartBottom + 24, bandH = runsOn.length ? 130 : 90; + const H = bandY + bandH + 28; + const engMid = (TOP + engBottom) / 2; + + const o = []; + o.push(``); + o.push(`${esc(m.title)}`); + o.push(`${esc(describeSystemMap(m))}`); + o.push(style()); + o.push(``); + o.push(`${esc(fit(m.title, 1000, 13.5))}`); + if (m.subtitle) o.push(`${esc(fit(m.subtitle, 1000, 7))}`); + + // inputs + o.push(`${esc(fit(m.inputs.label || 'INPUTS', LW, 9))}`); + inCards.forEach((c, i) => { + const cy = c.y + 31; + o.push(``); + o.push(``); + c.nm.forEach((ln, k) => o.push(`${esc(ln)}`)); + const subY = c.y + 47 + (c.nm.length - 1) * 18; + c.sub.forEach((ln, k) => o.push(`${esc(ln)}`)); + const spread = (i - (ins.length - 1) / 2) * 12; + o.push(``); + }); + if (noteLines.length) { + const ny = inBottom + 16; + o.push(``); + noteLines.forEach((ln, i) => o.push(`${esc(ln)}`)); + } + + // engine + o.push(``); + o.push(`${esc(fit(m.engine.name, EW - 44, W_BOLD16))}`); + engSub.forEach((ln, k) => o.push(`${esc(ln)}`)); + const stepCols = [C.blue, C.amber, C.green, '#a371f7']; + stepCards.forEach((c, i) => { + o.push(``); + o.push(`${i + 1}`); + o.push(`${esc(fit(c.st.title, EW - 100, W_BOLD16))}`); + c.sub.forEach((ln, k) => o.push(`${esc(ln)}`)); + if (i < stepCards.length - 1) { + const ax = EX + EW / 2, ay = c.y + c.h + 3; + o.push(``); + } + }); + + // outputs + o.push(`${esc(fit(m.outputs.label || 'OUTPUTS', OW, 9))}`); + if (m.outputs.streamLabel) { + wrap(m.outputs.streamLabel, OX - EX - EW - 10, W_REG13, 2).forEach((ln, k) => o.push(`${esc(ln)}`)); + } + outCards.forEach((c) => { + o.push(``); + o.push(`${esc(fit(c.it.name, OW - 40, W_BOLD16))}`); + if (c.it.cmd) o.push(`${esc(fit(c.it.cmd, OW - 40, W_CODE13))}`); + c.sub.forEach((ln, k) => o.push(`${esc(ln)}`)); + o.push(``); + }); + + // works-with strip — every pill is backed by evidence the tool itself checked + o.push(``); + o.push(`WORKS WITH`); + let px = 64; + ww.forEach((it) => { + const [stroke, fill, mark] = SW[it.status]; + const label = `${mark} ${fit(it.name, 200, W_BOLD14)}`; + const w = Math.round(label.length * W_BOLD14 + 36); + o.push(``); + o.push(`${esc(label)}`); + px += w + 14; + }); + const no = ww.find((x) => x.status === 'no'); + if (no && m.worksWith.noNote && px < W - 340) { + o.push(`${esc(fit(m.worksWith.noNote, W - 64 - px - 6, W_REG13))}`); + } + if (runsOn.length) { + o.push(`RUNS ON`); + let rx = 140; + runsOn.forEach((r) => { + const t = fit(r, 160, W_REG13), w = Math.round(t.length * W_REG13 + 30); + o.push(``); + o.push(`${esc(t)}`); + rx += w + 12; + }); + } + o.push(''); + return `${o.join('\n')}\n`; +} + +// ── DECISION ─────────────────────────────────────────────────────────────────────────────────────── +const ST = { active: C.red, skipped: C.amber, picked: C.green, eligible: C.blue }; + +export function renderDecision(m) { + const problems = validateDecision(m); + if (problems.length) throw new Error(`decision invalid: ${problems.join('; ')}`); + const cs = m.candidates, n = cs.length, steps = m.steps; + const X0 = 70, TW = 110, PITCH = 155, TOPY = 150, BASE = 450, PX = 3; // 100% = TOPY, 0% = BASE + const chartRight = X0 + (n - 1) * PITCH + TW; + const PANEL = chartRight + 170; + const W = Math.max(PANEL + 320, 1000), H = 572; + const yOf = (v) => num(BASE - v * PX); + const cx = (i) => X0 + i * PITCH + TW / 2; + const act = cs.findIndex((c) => c.status === 'active'); + const pick = cs.findIndex((c) => c.status === 'picked'); + const thrY = yOf(m.threshold.value); + const q = m.qualify && typeof m.qualify.value === 'number' ? m.qualify : null; + + const o = []; + o.push(``); + o.push(`${esc(m.title)}`); + o.push(`${esc(describeDecision(m))}`); + o.push(style(` + .sd-ring { opacity: 1; } .sd-act, .sd-actC { opacity: 0; } + .sd-fillA { transform-box: fill-box; transform-origin: bottom; } + .sd-arc { stroke-dasharray: 1; stroke-dashoffset: 0; } + @media (prefers-reduced-motion: no-preference) { + .sd-fillA { animation: sd-grow 10s ease-in-out infinite; } + .sd-alert { animation: sd-alert 10s ease-in-out infinite; } + .sd-mk { animation: sd-mark 10s ease-out infinite; } + .sd-pk { animation: sd-pick 10s ease-out infinite; } + .sd-ring { animation: sd-ringk 10s ease-in-out infinite; } + .sd-arc { animation: sd-arck 10s ease-in-out infinite; } + .sd-act { animation: sd-leave 10s ease-in-out infinite; } + .sd-actC { animation: sd-enter 10s ease-in-out infinite; } + } + @keyframes sd-grow { 0% { transform: scaleY(.84); } 26%, 100% { transform: scaleY(1); } } + @keyframes sd-alert { 0%, 24% { opacity: 0; } 30% { opacity: 1; } 38% { opacity: .35; } 46%, 94% { opacity: 1; } 100% { opacity: 0; } } + @keyframes sd-mark { 0%, 44% { opacity: 0; } 50%, 94% { opacity: 1; } 100% { opacity: 0; } } + @keyframes sd-pick { 0%, 56% { opacity: 0; } 62%, 82% { opacity: 1; } 86%, 100% { opacity: 0; } } + @keyframes sd-ringk { 0%, 56% { opacity: 0; } 64% { opacity: 1; } 72% { opacity: .55; } 80%, 94% { opacity: 1; } 100% { opacity: 0; } } + @keyframes sd-arck { 0%, 62% { stroke-dashoffset: 1; opacity: 1; } 78%, 94% { stroke-dashoffset: 0; opacity: 1; } 100% { stroke-dashoffset: 0; opacity: 0; } } + @keyframes sd-leave { 0%, 78% { opacity: 1; } 84%, 100% { opacity: 0; } } + @keyframes sd-enter { 0%, 84% { opacity: 0; } 90%, 94% { opacity: 1; } 100% { opacity: 0; } }`)); + o.push(``); + o.push(``); + o.push(`${esc(fit(m.title, W - 80, 13.5))}`); + o.push(`${esc(fit(m.subtitle || 'Illustrative numbers.', W - 80, 7))}`); + + cs.forEach((c, i) => { + const x = X0 + i * PITCH, top = yOf(c.value), h = num(BASE - 3 - top); + o.push(``); + o.push(``); + }); + o.push(``); + o.push(`${esc(`${m.threshold.value}%`)}`); + wrap(m.threshold.label, PANEL - chartRight - 40, W_REG13, 2).forEach((ln, k) => o.push(`${esc(ln)}`)); + if (q) { + const qy = yOf(q.value); + o.push(``); + o.push(`${esc(`${q.value}% ${fit(q.label || 'to qualify', PANEL - chartRight - 70, 6.5)}`)}`); + } + if (act >= 0) o.push(``); + if (pick >= 0) o.push(``); + if (act >= 0 && pick >= 0) { + const a = cx(act) + 33, b = cx(pick); + o.push(``); + } + cs.forEach((c, i) => { + const mid = cx(i); + if (c.status === 'active') { + o.push(`ACTIVE`); + } else if (c.status === 'skipped') { + o.push(`✕ skipped`); + } else if (c.status === 'picked') { + o.push(`✓ PICKED`); + o.push(`ACTIVE`); + } else { + o.push(`✓ eligible`); + } + o.push(`${esc(fit(c.name, PITCH - 20, W_BOLD16))}`); + o.push(`${esc(`${c.value}%`)}`); + if (c.note) wrap(c.note, PITCH - 12, W_REG13, 2).forEach((ln, k) => o.push(`${esc(ln)}`)); + }); + + o.push(`${esc(fit(m.panelLabel || 'HOW IT DECIDES', 280, 9))}`); + steps.forEach((s, i) => { + const y = 196 + i * 72; + o.push(`${i + 1}`); + o.push(`${esc(fit(s.title, W - PANEL - 48, 8.0))}`); + wrap(s.sub, W - PANEL - 48, W_REG13, 2).forEach((ln, k) => o.push(`${esc(ln)}`)); + }); + if (m.caption) o.push(`${esc(fit(m.caption, W - 80, W_REG13))}`); + o.push(''); + return `${o.join('\n')}\n`; +} diff --git a/tests/diagram-form-diversity.test.mjs b/tests/diagram-form-diversity.test.mjs index 4d94034..ad69d3c 100644 --- a/tests/diagram-form-diversity.test.mjs +++ b/tests/diagram-form-diversity.test.mjs @@ -166,7 +166,13 @@ test('INV-23 — holds for a library repo with no runtime entrypoints (flow skip // to take, and assert the tool dies loudly naming INV-23 instead of emitting two identical shapes. test('INV-23 — the guard FAILS LOUD when no distinct form remains (mutation proof, not a tautology)', () => { const mutantDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-form-mutant-')); - const mutant = path.join(mutantDir, 'make-diagrams.mjs'); + // Mirror the real layout (tools/ + src/): the tool imports ../src/story-diagrams.mjs (ADR-0014), so a + // bare copy in a temp dir would die on MODULE_NOT_FOUND and the mutation would be "caught" for the + // wrong reason — exactly the failure mode the stderr assertion below exists to rule out. + fs.mkdirSync(path.join(mutantDir, 'tools')); + fs.mkdirSync(path.join(mutantDir, 'src')); + fs.copyFileSync(path.join(path.dirname(TOOL), '..', 'src', 'story-diagrams.mjs'), path.join(mutantDir, 'src', 'story-diagrams.mjs')); + const mutant = path.join(mutantDir, 'tools', 'make-diagrams.mjs'); const src = fs.readFileSync(TOOL, 'utf8'); // Strand the insight slot: no preferences => the resolver can find no free family for it. const NEEDLE = /conceptHeading: 'The clever move', conceptPrefs: \[[^\]]*\]/; diff --git a/tests/fixtures/story-claude-swap.mjs b/tests/fixtures/story-claude-swap.mjs new file mode 100644 index 0000000..0177702 --- /dev/null +++ b/tests/fixtures/story-claude-swap.mjs @@ -0,0 +1,62 @@ +// Reference models: claude-swap (realiti4/claude-swap), from its README and src/claude_swap/autoswitch.py + settings.py, 2026-10-10. +// These are the two graphics the owner signed off as "the level and style I want", expressed as data. +export const SYSTEM_MAP = { + title: 'What claude-swap plugs into', + subtitle: 'Built from the README and source: one engine, three ways to drive it, and what it works with.', + inputs: { + label: 'YOUR ACCOUNTS', + items: [ + { name: 'Account 1', sub: 'saved login, with its usage' }, + { name: 'Account 2', sub: 'saved login, with its usage' }, + { name: 'Account 3', sub: 'saved login, with its usage' }, + ], + note: 'macOS: the Keychain\nWindows, Linux/WSL: files', + }, + engine: { + name: 'Auto-switch engine', sub: 'one engine, no screen of its own', + steps: [ + { title: 'Poll usage', sub: '5h and 7d windows, every 60s' }, + { title: 'Decide', sub: '90% line, 10-pt margin, 5m cooldown' }, + { title: 'Swap', sub: 'refresh the token, hold a lock, switch' }, + ], + }, + outputs: { + label: 'THREE WAYS TO DRIVE IT', streamLabel: 'one event stream', + items: [ + { name: 'Command line', cmd: 'cswap auto', sub: 'readable lines, or JSON for scripts' }, + { name: 'Terminal dashboard', cmd: 'cswap', sub: 'live usage bars for every account' }, + { name: 'Menu bar', cmd: 'cswap menubar', sub: 'macOS only, optional, click to switch' }, + ], + }, + worksWith: { + items: [ + { name: 'Claude Code CLI', status: 'yes', file: 'README.md', quote: 'Works with both the Claude Code CLI and the VS Code extension' }, + { name: 'VS Code extension', status: 'yes', file: 'README.md', quote: 'Works with both the Claude Code CLI and the VS Code extension' }, + { name: 'Codex', status: 'no', terms: ['codex'] }, + ], + noNote: 'not mentioned anywhere in the README or source', + runsOn: ['macOS', 'Windows', 'Linux / WSL'], + }, +}; + +export const DECISION = { + title: 'The moment claude-swap switches you', + subtitle: "Illustrative numbers. Each tank shows an account's busiest window (5h or 7d).", + threshold: { value: 90, label: 'switch point' }, + qualify: { value: 83, label: 'to qualify' }, + candidates: [ + { name: 'work', value: 93, status: 'active', note: 'over the line' }, + { name: 'team', value: 85, status: 'skipped', note: 'not 10 pts better' }, + { name: 'personal', value: 34, status: 'picked', note: 'most headroom' }, + { name: 'side', value: 62, status: 'eligible', note: 'room, but less' }, + ], + steps: [ + { title: 'Active account crosses 90%', sub: 'default threshold, busiest of 5h/7d' }, + { title: 'Needs 10 pts more room than work', sub: 'so near-equal accounts never flap' }, + { title: 'Pick the most headroom', sub: 'here: personal, 34% used' }, + { title: 'Swap before the old one runs dry', sub: 'a running Claude Code picks it up' }, + ], + rule: { file: 'src/claude_swap/autoswitch.py', quote: 'if h - active_headroom < settings.hysteresis_pct:' }, + caption: "Before the swap it refreshes the pick's token if that expires within 10 minutes, then switches while holding a lock.", +}; + diff --git a/tests/story-diagrams.test.mjs b/tests/story-diagrams.test.mjs new file mode 100644 index 0000000..cb0e4d7 --- /dev/null +++ b/tests/story-diagrams.test.mjs @@ -0,0 +1,214 @@ +// tests/story-diagrams.test.mjs — the system-map and decision diagram renderers (ADR-0014). +// +// The reference models below describe claude-swap (realiti4/claude-swap, read from its README and +// src/claude_swap/autoswitch.py + settings.py on 2026-10-10). They are the two graphics the owner +// signed off as "the level and style I want", expressed as data. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + fit, wrap, renderSystemMap, renderDecision, validateSystemMap, validateDecision, verifySystemMap, verifyDecision, +} from '../src/story-diagrams.mjs'; +import { SYSTEM_MAP, DECISION } from './fixtures/story-claude-swap.mjs'; + +const clone = (o) => JSON.parse(JSON.stringify(o)); + +function xmllintOk(svg) { + const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sd-')), 'x.svg'); + fs.writeFileSync(f, svg); + try { execFileSync('xmllint', ['--noout', f], { stdio: 'pipe' }); return true; } + catch (e) { if (e.code === 'ENOENT') return null; throw e; } +} + +for (const [name, render, model] of [['system map', renderSystemMap, SYSTEM_MAP], ['decision', renderDecision, DECISION]]) { + test(`${name}: renders well-formed, script-free, self-contained SVG`, () => { + const svg = render(model); + const ok = xmllintOk(svg); + if (ok !== null) assert.equal(ok, true, 'xmllint must accept it'); + assert.match(svg, /^/); + assert.match(svg, //); + }); + + test(`${name}: motion is gated on prefers-reduced-motion`, () => { + const svg = render(model); + assert.match(svg, /@media \(prefers-reduced-motion: no-preference\)/); + // strip every @media (...) { ... } block with real brace matching, then no animation may remain + let out = svg, at; + while ((at = out.indexOf('@media')) !== -1) { + let i = out.indexOf('{', at), depth = 0; + for (; i < out.length; i++) { if (out[i] === '{') depth++; else if (out[i] === '}' && --depth === 0) break; } + out = out.slice(0, at) + out.slice(i + 1); + } + assert.doesNotMatch(out.replace(/@keyframes[^{]+\{(?:[^{}]|\{[^{}]*\})*\}/g, ''), /animation:/); + }); +} + +test('system map: states what it works with, including the recorded "no"', () => { + const svg = renderSystemMap(SYSTEM_MAP); + assert.match(svg, /✓ Claude Code CLI/); + assert.match(svg, /✓ VS Code extension/); + assert.match(svg, /✕ Codex/); + assert.match(svg, /not mentioned anywhere in the README or source/); + assert.match(svg, /Linux \/ WSL/); + assert.match(svg, /cswap menubar/); +}); + +test('system map: GUARD — a claim the tool cannot check is refused, not drawn', () => { + const m = clone(SYSTEM_MAP); + delete m.worksWith.items[0].quote; + assert.throws(() => renderSystemMap(m), /needs file \+ quote/); + const m2 = clone(SYSTEM_MAP); + delete m2.worksWith.items[2].terms; + assert.throws(() => renderSystemMap(m2), /terms required for a "no"/); +}); + +// A tiny in-memory "repo" for the verifier. +const REPO = { + 'README.md': 'Multi-account switcher for Claude Code.\n\nWorks with both the Claude Code CLI and the VS Code extension.\n', + 'src/claude_swap/autoswitch.py': ' if h - active_headroom < settings.hysteresis_pct:\n continue\n', +}; +const read = (f) => (f in REPO ? REPO[f] : null); +const search = (terms) => Object.entries(REPO).flatMap(([file, txt]) => txt.split('\n') + .map((l, i) => ({ file, line: i + 1, l })).filter(({ l }) => terms.some((t) => l.toLowerCase().includes(t.toLowerCase())))); + +test('verify: real quotes survive; a "no" with zero mentions is a recorded measurement', () => { + const { model, notes } = verifySystemMap(SYSTEM_MAP, { read, search }); + assert.equal(notes.length, 0, notes.join('; ')); + assert.equal(model.worksWith.items.length, 3); + assert.match(model.worksWith.items[2].evidence, /searched codex .*: 0 matches/); +}); + +test('verify: GUARD — a fabricated quote is dropped, never drawn', () => { + const m = clone(SYSTEM_MAP); + m.worksWith.items[0].quote = 'Seamlessly integrates with every IDE on earth'; + const { model, notes } = verifySystemMap(m, { read, search }); + assert.equal(model.worksWith.items.some((x) => x.name === 'Claude Code CLI'), false); + assert.ok(notes.some((n) => /quote not found/.test(n))); +}); + +test('verify: GUARD — a "no" is dropped when the repo DOES mention the term', () => { + const m = clone(SYSTEM_MAP); + m.worksWith.items[2].terms = ['claude code']; + const { model, notes } = verifySystemMap(m, { read, search }); + assert.equal(model.worksWith.items.some((x) => x.name === 'Codex'), false); + assert.ok(notes.some((n) => /claimed no support but/.test(n))); +}); + +test('verify: GUARD — a claim naming an unreadable file is dropped', () => { + const m = clone(SYSTEM_MAP); + m.worksWith.items[1].file = 'docs/does-not-exist.md'; + const { notes } = verifySystemMap(m, { read, search }); + assert.ok(notes.some((n) => /file unreadable/.test(n))); +}); + +test('verify decision: the rule must really be in the named file', () => { + assert.equal(verifyDecision(DECISION, { read }).ok, true); + const m = clone(DECISION); + m.rule.quote = 'if account.is_best(): return True'; + assert.equal(verifyDecision(m, { read }).ok, false); +}); + +test('system map: GUARD — unknown status and wrong counts are refused', () => { + const m = clone(SYSTEM_MAP); + m.worksWith.items[1].status = 'probably'; + assert.ok(validateSystemMap(m).some((e) => /status must be/.test(e))); + const m2 = clone(SYSTEM_MAP); + m2.engine.steps = [m2.engine.steps[0]]; + assert.ok(validateSystemMap(m2).some((e) => /engine\.steps/.test(e))); +}); + +test('decision: shows a real choice — threshold, qualify line, skipped and picked', () => { + const svg = renderDecision(DECISION); + assert.match(svg, />90%<\/text>/); + assert.match(svg, />switch point<\/text>/); + assert.match(svg, /83% to qualify/); + assert.match(svg, /✕ skipped/); + assert.match(svg, /✓ PICKED/); + assert.match(svg, /✓ eligible/); + assert.match(svg, /class="sd-arc"/, 'handoff arc from active to picked'); +}); + +test('decision: reduced-motion still tells the whole story (static state = the END)', () => { + const svg = renderDecision(DECISION); + const base = svg.slice(svg.indexOf('