flowfig 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,21 @@ An agent with the flowfig instructions made faithful, checked diagrams more ofte
28
28
 
29
29
  [How we measured](#how-we-measured).
30
30
 
31
+ ## One command
32
+
33
+ ```bash
34
+ npx flowfig draw "how does login work"
35
+ ```
36
+
37
+ `draw` runs Claude Code on the repo. It gives Claude Code the flowfig guide, `npx flowfig` and the tools Read, Glob and Grep.
38
+ The permission mode is `acceptEdits`, so Claude Code can write files. `draw` disallows the tools Edit, MultiEdit and
39
+ NotebookEdit. Claude Code writes the spec `how-does-login-work.json`, then renders `how-does-login-work.svg`. Then `draw`
40
+ checks the figure itself with `check --strict` and `verify`, and removes the spec file. If the agent writes no new figure,
41
+ `draw` exits 1 and shows the tool calls that the permission rules denied. `draw` prints the result, the reply of the agent
42
+ and the cost of the run. `draw` needs Claude Code on the machine and a login. `draw` does not run on Windows yet.
43
+ `--out` sets the path (it must end in `.svg`), `--model` sets the model, `--max-turns` sets the turn cap (default 40) and
44
+ `--json` prints `{ out, reply, cost, session, findings }` for scripts.
45
+
31
46
  ## Quick start
32
47
 
33
48
  ```bash
@@ -80,6 +95,8 @@ Then ask your agent: "draw a diagram of how login works in this repo".
80
95
  - [Three forms](#three-forms)
81
96
  - [The spec](#the-spec)
82
97
  - [Check a figure](#check-a-figure)
98
+ - [Verify in CI](#verify-in-ci)
99
+ - [MCP server](#mcp-server)
83
100
  - [Use in React](#use-in-react)
84
101
  - [Use from Node](#use-from-node)
85
102
  - [Use with your coding agent](#use-with-your-coding-agent)
@@ -172,6 +189,8 @@ Each SVG from the CLI carries its own spec. These commands made the rail-only fi
172
189
  npx flowfig --spec docs/checkout.svg > checkout.json # print the spec in the SVG
173
190
  # edit checkout.json: set "props.rail" to "only"
174
191
  npx flowfig checkout.json docs/checkout-rail-only.svg
192
+ npx flowfig verify docs/checkout.svg # check that each source still exists
193
+ npx flowfig diff old.svg docs/checkout.svg # list what changed in the spec
175
194
  ```
176
195
 
177
196
  ## The spec
@@ -181,14 +200,15 @@ full reference, run `npx flowfig docs`.
181
200
 
182
201
  ### Boxes
183
202
 
184
- | Field | Meaning | Default |
185
- | ------- | ----------------------------------------------------------------- | ----------- |
186
- | `id` | The name that edges and steps use. It must be unique. | required |
187
- | `label` | The title in the box. | required |
188
- | `sub` | A smaller line under the label. | none |
189
- | `shape` | `"box"`, `"decision"` (a diamond) or `"store"` (a data cylinder). | `"box"` |
190
- | `lines` | The least number of text lines that a content card keeps. | none |
191
- | `width` | The width in px. This value replaces the width that layout picks. | from layout |
203
+ | Field | Meaning | Default |
204
+ | -------- | ------------------------------------------------------------------------- | ----------- |
205
+ | `id` | The name that edges and steps use. It must be unique. | required |
206
+ | `label` | The title in the box. | required |
207
+ | `sub` | A smaller line under the label. | none |
208
+ | `shape` | `"box"`, `"decision"` (a diamond) or `"store"` (a data cylinder). | `"box"` |
209
+ | `source` | The code this draws: `path` or `path#symbol`. `flowfig verify` checks it. | none |
210
+ | `lines` | The least number of text lines that a content card keeps. | none |
211
+ | `width` | The width in px. This value replaces the width that layout picks. | from layout |
192
212
 
193
213
  ### Groups
194
214
 
@@ -205,14 +225,15 @@ full reference, run `npx flowfig docs`.
205
225
 
206
226
  ### Edges
207
227
 
208
- | Field | Meaning | Default |
209
- | -------- | ----------------------------------------------------------------------- | ---------- |
210
- | `from` | The id of the box or group where the edge starts. | required |
211
- | `to` | The id of the box or group where the edge ends. | required |
212
- | `id` | The name that beats use. | `from->to` |
213
- | `label` | The text on the edge. | none |
214
- | `around` | `"above"` or `"below"` routes the edge over or under the boxes between. | none |
215
- | `quiet` | `true` draws the edge only while a step uses it. | `false` |
228
+ | Field | Meaning | Default |
229
+ | -------- | ------------------------------------------------------------------------------ | ---------- |
230
+ | `from` | The id of the box or group where the edge starts. | required |
231
+ | `to` | The id of the box or group where the edge ends. | required |
232
+ | `id` | The name that beats use. | `from->to` |
233
+ | `label` | The text on the edge. | none |
234
+ | `around` | `"above"` or `"below"` routes the edge over or under the boxes between. | none |
235
+ | `quiet` | `true` draws the edge only while a step uses it. | `false` |
236
+ | `source` | The code this edge draws: `path` or `path#symbol`. `flowfig verify` checks it. | none |
216
237
 
217
238
  ### Steps
218
239
 
@@ -229,13 +250,13 @@ Each step is one story. The player shows one tab for each step. With no steps, t
229
250
 
230
251
  A beat is an edge id, an array of edge ids that run at the same time, or an object:
231
252
 
232
- | Field | Meaning | Default |
233
- | ------- | ---------------------------------------------------------------------------------- | -------------- |
234
- | `edges` | One hop or an array of hops. A hop is an edge id or `{ edge, back, data, async }`. | none (a pause) |
235
- | `say` | The line of narration for the beat. | none |
236
- | `show` | `{ boxId: content }` fills the content card of a box until the step ends. | none |
237
- | `light` | The ids of the boxes to highlight for this beat only. | none |
238
- | `ms` | The length of the beat in ms. | `speed` (900) |
253
+ | Field | Meaning | Default |
254
+ | ------- | ------------------------------------------------------------------------------------------ | -------------- |
255
+ | `edges` | One hop or an array of hops. A hop is an edge id or `{ edge, back, data, async, source }`. | none (a pause) |
256
+ | `say` | The line of narration for the beat. | none |
257
+ | `show` | `{ boxId: content }` fills the content card of a box until the step ends. | none |
258
+ | `light` | The ids of the boxes to highlight for this beat only. | none |
259
+ | `ms` | The length of the beat in ms. | `speed` (900) |
239
260
 
240
261
  In a hop, `back: true` runs the packet from `to` to `from`. `data` is a small card on the packet. `async: true` marks a message
241
262
  that does not wait for an answer.
@@ -294,6 +315,45 @@ A render runs the same check first. If the check finds an error, the render writ
294
315
  In React, `<Flow check />` runs the same rules on the layout that the browser drew. The player prints each fault with
295
316
  `console.warn`.
296
317
 
318
+ ## Verify in CI
319
+
320
+ A box, an edge or a hop can name the code it draws: `"source": "src/auth/login.ts#verifyPassword"`. `npx flowfig verify docs/login.svg`
321
+ fails when the file or the symbol is gone. The action runs `verify` on every figure in a pull request. It comments the old and
322
+ the new image for each SVG the PR changes, with the spec changes as a list, and it names each figure whose linked code the PR
323
+ changes.
324
+
325
+ A process figure for a team links its boxes to the SOP document, not to code: `"source": "docs/sop/refunds.md#step-3-approve-the-refund"`. The symbol is the heading as a GitHub anchor. If the heading is gone, `verify` fails.
326
+
327
+ ```yaml
328
+ permissions:
329
+ contents: read
330
+ pull-requests: write
331
+ steps:
332
+ - uses: actions/checkout@v4
333
+ with:
334
+ fetch-depth: 0
335
+ - uses: actions/setup-node@v4
336
+ with:
337
+ node-version: 22
338
+ - uses: iamalvisng/flowfig@v0.2.0
339
+ with:
340
+ figures: 'docs/**/*.svg' # default **/*.svg
341
+ ```
342
+
343
+ ## MCP server
344
+
345
+ `npx flowfig mcp` serves the tools `docs`, `check`, `render`, `verify` and `diff` over stdio, for an agent with no shell.
346
+ `render` writes the SVG file and returns the check lines and the path, so no SVG text goes through the model.
347
+
348
+ `npx flowfig init` registers the server for Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`), GitHub Copilot
349
+ (`.vscode/mcp.json`), Gemini CLI (`.gemini/settings.json`) and Kiro (`.kiro/settings/mcp.json`). It merges one entry into
350
+ the file and keeps every other server. `--no-mcp` skips this step. If you formatted the file with other spacing, the first
351
+ run rewrites it once. For any other client, add:
352
+
353
+ ```json
354
+ { "mcpServers": { "flowfig": { "command": "npx", "args": ["flowfig", "mcp"] } } }
355
+ ```
356
+
297
357
  ## Use in React
298
358
 
299
359
  ```tsx
@@ -348,6 +408,8 @@ writeFileSync('first.svg', toSvg(spec));
348
408
  - `check(spec, options)` returns the findings. It also takes `width` and `minText`.
349
409
  - `render(spec, options)` returns `{ svg, scene }`. The scene is the layout that the check reads. Its shape can change.
350
410
 
411
+ `flowfig/verify` is for Node only. It exports `verify`, `links`, `owners` and `parseSource`, the same checks as `flowfig verify`.
412
+
351
413
  `toSvg` does not put the spec in the SVG. Only the CLI adds the spec, which `--spec` reads back.
352
414
 
353
415
  ## Use with your coding agent
@@ -378,8 +440,9 @@ On a terminal, `init` shows a picker. The picker selects the agents that the rep
378
440
  | `--agents <ids>` | Write for these agents, for example `--agents claude,cursor`. |
379
441
  | `--all-agents` | Write for all 7 agents. |
380
442
  | `-y`, `--yes` | Write for the agents that the repo uses, with no picker. |
381
- | `--global` | Write only the Claude Code skill, to your home directory. |
443
+ | `--global` | Write the Claude skill and the MCP entry to your home folder. |
382
444
  | `--dry-run` | Print what `init` would write, and write nothing. |
445
+ | `--no-mcp` | Do not register the MCP server. |
383
446
  | `--list-agents` | Print the agent ids. |
384
447
 
385
448
  With no terminal and no `-y`, `--agents` or `--all-agents`, `init` prints the agent ids and exits with code 2.
package/dist/check.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // The rules behind `flowfig check`. They never draw and never measure: a renderer gives a Scene, and these read it. So one rule set
2
2
  // covers the SVG (estimated text) and the React player (measured text), and any later view that gives a scene.
3
- import { DARK, LIGHT, ON_ACCENT, edgeId, isGroup, nodes, str, toBeat } from './model.js';
3
+ import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, nodes, str, toBeat, } from './model.js';
4
4
  import { textWidth } from './text.js';
5
+ import { owners, parseSource } from './source.js';
5
6
  const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
6
7
  const warn = (rule, ids, message) => ({ rule, severity: 'warning', ids, message });
7
8
  const groupIds = (g) => [...(g.id ? [g.id] : []), ...g.children.flatMap((c) => (isGroup(c) ? groupIds(c) : []))];
@@ -43,6 +44,10 @@ export function checkSpec(fig) {
43
44
  if (e.quiet && !used.has(edges[i]))
44
45
  out.push(err('hidden-edge', [edges[i]], `edge "${edges[i]}" is quiet and no beat uses it, so the figure never shows it`));
45
46
  });
47
+ // A link with a bad form never verifies, so say so here, where the spec is checked.
48
+ for (const [who, source] of owners(fig))
49
+ if (source != null && !parseSource(source))
50
+ out.push(warn('bad-source', [], `${who}: source "${source}" is not path or path#symbol`));
46
51
  return out;
47
52
  }
48
53
  /** A point on the cubic Bézier at `t`. */
@@ -73,7 +78,7 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
73
78
  const labeled = scene.edges.filter((e) => e.label);
74
79
  labeled.forEach((e, i) => {
75
80
  for (const f of labeled.slice(i + 1))
76
- if (overlap(e.label, f.label, 1))
81
+ if ((e.step == null || f.step == null || e.step === f.step) && overlap(e.label, f.label, 1))
77
82
  out.push(err('label-overlap', [e.id, f.id], `the labels of edges "${e.id}" and "${f.id}" overlap`));
78
83
  for (const b of scene.boxes)
79
84
  if (overlap(e.label, b.rect, 1))
@@ -123,7 +128,16 @@ const PAIRS = [
123
128
  ['muted', 'bg'],
124
129
  ['fg', 'surface'],
125
130
  ['text', 'accent'],
131
+ ['fg', 'tint'],
132
+ ['muted', 'tint'],
126
133
  ];
134
+ /** A color mixed over a base at pct %, per channel like the CSS color-mix. The default is the active-box tint: 10 % accent over the surface. */
135
+ function tintOf(t, color = t.accent, base = t.surface, pct = 10) {
136
+ const [a, s] = [rgb(color), rgb(base)];
137
+ if (!a || !s)
138
+ return base; // an unreadable color is reported by its own pairs
139
+ return `rgb(${a.map((v, i) => (pct / 100) * v + (1 - pct / 100) * s[i]).join(',')})`;
140
+ }
127
141
  /** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
128
142
  export function checkTheme(theme = {}) {
129
143
  const out = [];
@@ -137,7 +151,8 @@ export function checkTheme(theme = {}) {
137
151
  if (custom)
138
152
  sets.push(['custom', { ...LIGHT, ...Object.fromEntries(Object.entries(theme).filter(([, v]) => v != null)) }]);
139
153
  const unread = new Set();
140
- for (const [name, t] of sets)
154
+ for (const [name, base] of sets) {
155
+ const t = { ...base, tint: tintOf(base) };
141
156
  for (const [fg, bg] of PAIRS) {
142
157
  const a = fg === 'text' ? ON_ACCENT : t[fg];
143
158
  const r = contrast(a, t[bg]);
@@ -149,6 +164,17 @@ export function checkTheme(theme = {}) {
149
164
  else if (r < 4.5)
150
165
  out.push(err('low-contrast', [], `${name} theme: ${fg} on ${bg} has contrast ${r}:1 (minimum 4.5:1)`));
151
166
  }
167
+ // A toned box shows fg and muted text on its tone tint: 8 % over bg (off, trail) and 10 % over surface (active).
168
+ for (const [tname, tone] of Object.entries(TONES)) {
169
+ for (const bg of [tintOf(t, tone, t.bg, 8), tintOf(t, tone, t.surface, 10)]) {
170
+ for (const fg of ['fg', 'muted']) {
171
+ const r = contrast(t[fg], bg);
172
+ if (r != null && r < 4.5)
173
+ out.push(err('low-contrast', [], `${name} theme: ${fg} on the ${tname} box tint has contrast ${r}:1 (minimum 4.5:1)`));
174
+ }
175
+ }
176
+ }
177
+ }
152
178
  for (const c of unread)
153
179
  out.push(warn('color-not-checked', [], `cannot read the color "${c}", so its contrast is not checked`));
154
180
  return out;
package/dist/cli.js CHANGED
@@ -15,8 +15,12 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
15
15
  * flowfig figure.ts out.svg # a module whose default export is a spec
16
16
  * flowfig --spec out.svg # print back the spec the SVG carries
17
17
  * flowfig check <input> [--json] # list the faults; the input can also be an SVG this wrote
18
+ * flowfig verify <input>... [--root dir] # check that the code each figure links to still exists
19
+ * flowfig diff <old> <new> [--json|--md] # list the spec changes between two figures
18
20
  * flowfig docs # print the full guide (Markdown)
21
+ * flowfig mcp # serve check, render, verify, diff and docs over MCP (stdio)
19
22
  * flowfig init [dir] # write flowfig instructions for the coding agents of a repo
23
+ * flowfig draw "<question>" [--out out.svg] # ask Claude Code for a figure, then check it
20
24
  *
21
25
  * A render checks first and writes nothing on an error (--no-check skips that). --strict makes warnings errors; --width and
22
26
  * --min-text set the page width and the smallest text the reader should get.
@@ -27,11 +31,23 @@ import { readFileSync, writeFileSync } from 'node:fs';
27
31
  import { resolve } from 'node:path';
28
32
  import { pathToFileURL } from 'node:url';
29
33
  import { GUIDE } from './guide.js';
34
+ import { runDraw } from './draw.js';
30
35
  import { runInit } from './init.js';
31
- import { counts } from './model.js';
32
- import { check, toSvg } from './svg.js';
33
- const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
34
- const SPEC_CLOSE = ']]></metadata>';
36
+ import { serve } from './mcp.js';
37
+ import { diff, formatDiff } from './diff.js';
38
+ import { loadSpec, reportLines, sortFindings, specOf, svgWithSpec } from './load.js';
39
+ import { check } from './svg.js';
40
+ import { links, verify } from './verify.js';
41
+ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] render a figure; a spec on stdin with -
42
+ flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
43
+ flowfig --spec figure.svg print the spec the SVG carries
44
+ flowfig verify <input>... [--root <dir>] [--json] [--strict] check the code links of one or more figures
45
+ flowfig diff <old> <new> [--json|--md] list the spec changes between two figures
46
+ flowfig docs print the guide (Markdown)
47
+ flowfig mcp serve check, render, verify, diff and docs over MCP (stdio)
48
+ flowfig init [dir] [--agents <ids>] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
49
+ flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] ask Claude Code for a figure, then check it
50
+ flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only)`;
35
51
  /** Bad use, not a bad figure: exit 2 with a message, not a stack trace. A declaration, so TypeScript narrows after a call. */
36
52
  function usage(message) {
37
53
  console.error(message);
@@ -49,22 +65,98 @@ const value = (name) => {
49
65
  const n = Number(args.splice(i, 2)[1] || NaN);
50
66
  return n > 0 && Number.isFinite(n) ? n : usage(`${name} needs a positive number`);
51
67
  };
52
- function specOf(svg, name) {
53
- const at = svg.indexOf(SPEC_OPEN);
54
- if (at === -1)
55
- usage(`${name}: no figure spec inside this SVG`);
56
- return svg.slice(at + SPEC_OPEN.length, svg.indexOf(SPEC_CLOSE, at));
68
+ if (!args[0] || ['help', '--help', '-h'].includes(args[0])) {
69
+ if (args[0])
70
+ console.log(USAGE);
71
+ else
72
+ usage(USAGE);
73
+ process.exit(0);
57
74
  }
58
75
  if (args[0] === 'docs') {
59
76
  process.stdout.write(GUIDE);
60
77
  process.exit(0);
61
78
  }
79
+ if (args[0] === 'mcp') {
80
+ await serve(process.stdin, process.stdout);
81
+ // A pipe write is asynchronous: wait until the queued response lines are out before the exit.
82
+ await new Promise((done) => process.stdout.write('', () => done(undefined)));
83
+ process.exit(0);
84
+ }
62
85
  if (args[0] === 'init')
63
86
  process.exit(await runInit(args.slice(1)));
87
+ if (args[0] === 'draw')
88
+ process.exit(await runDraw(args.slice(1)));
64
89
  if (args[0] === '--spec') {
65
90
  if (!args[1])
66
91
  usage('--spec needs the path of an SVG');
67
- console.log(specOf(readFileSync(args[1], 'utf8'), args[1]));
92
+ let svg = '';
93
+ try {
94
+ svg = readFileSync(args[1], 'utf8');
95
+ }
96
+ catch (e) {
97
+ usage(`${args[1]}: ${e.message}`);
98
+ }
99
+ try {
100
+ console.log(specOf(svg, args[1]));
101
+ }
102
+ catch (e) {
103
+ usage(e.message);
104
+ }
105
+ process.exit(0);
106
+ }
107
+ /** A spec from stdin, a JSON file, an SVG this wrote, or a module. Exit 2 with a message on any failure. */
108
+ async function load(input) {
109
+ try {
110
+ if (input.endsWith('.json') || input.endsWith('.svg'))
111
+ return loadSpec(input);
112
+ if (input === '-')
113
+ return loadSpec(JSON.parse(readFileSync(0, 'utf8')), input);
114
+ return loadSpec((await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(input)).href))).default, input);
115
+ }
116
+ catch (e) {
117
+ const m = e.message;
118
+ return usage(m.startsWith(`${input}: `) ? m : `${input}: ${m}`);
119
+ }
120
+ }
121
+ if (args[0] === 'verify') {
122
+ args.shift();
123
+ const json = flag('--json'), strict = flag('--strict');
124
+ const at = args.indexOf('--root');
125
+ const root = at === -1 ? process.cwd() : (args.splice(at, 2)[1] ?? usage('--root needs a folder'));
126
+ const unknown = args.find((a) => a.startsWith('-') && a !== '-');
127
+ if (unknown)
128
+ usage(`unknown flag ${unknown}`);
129
+ if (!args.length)
130
+ usage('verify needs at least one figure');
131
+ const findings = [];
132
+ const all = [];
133
+ for (const input of args) {
134
+ const props = await load(input);
135
+ const found = sortFindings([...check(props), ...verify(props, { root })], strict).map((f) => ({ ...f, figure: input }));
136
+ findings.push(...found);
137
+ all.push(...links(props).map((l) => ({ ...l, figure: input })));
138
+ }
139
+ const errors = findings.filter((f) => f.severity === 'error').length;
140
+ if (json)
141
+ console.log(JSON.stringify({ findings, links: all }, null, 2));
142
+ else {
143
+ const n = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
144
+ for (const f of findings)
145
+ console.log(`${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.figure}: ${f.message}`);
146
+ console.log(`${n(errors, 'error')}, ${n(findings.length - errors, 'warning')}`);
147
+ }
148
+ process.exit(errors ? 1 : 0);
149
+ }
150
+ if (args[0] === 'diff') {
151
+ args.shift();
152
+ const json = flag('--json'), md = flag('--md');
153
+ const unknown = args.find((a) => a.startsWith('-') && a !== '-');
154
+ if (unknown)
155
+ usage(`unknown flag ${unknown}`);
156
+ if (args.length !== 2)
157
+ usage('diff needs two figures: flowfig diff <old> <new> [--json|--md]');
158
+ const changes = diff(await load(args[0]), await load(args[1]));
159
+ console.log(json ? JSON.stringify(changes, null, 2) : formatDiff(changes, md ? 'md' : 'text'));
68
160
  process.exit(0);
69
161
  }
70
162
  const command = args[0] === 'check' ? args.shift() : 'render';
@@ -75,44 +167,17 @@ if (unknown)
75
167
  usage(`unknown flag ${unknown}`);
76
168
  const [input, out] = args;
77
169
  if (!input)
78
- usage('usage: flowfig <-|spec.json|figure.ts> [out.svg] | flowfig check <-|spec.json|figure.ts|figure.svg> [--json] | flowfig docs | flowfig init [dir]');
79
- let loaded;
80
- try {
81
- loaded =
82
- input === '-'
83
- ? JSON.parse(readFileSync(0, 'utf8'))
84
- : input.endsWith('.json')
85
- ? JSON.parse(readFileSync(input, 'utf8'))
86
- : input.endsWith('.svg')
87
- ? JSON.parse(specOf(readFileSync(input, 'utf8'), input))
88
- : (await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(input)).href))).default;
89
- }
90
- catch (e) {
91
- usage(`${input}: ${e.message}`);
92
- }
93
- const props = loaded?.props ?? loaded;
94
- if (!props?.layout)
95
- usage(`${input}: no figure props (expected { props: { layout, edges, steps } })`);
96
- if (!Array.isArray(props.edges))
97
- usage(`${input}: edges must be an array (use [] for none)`);
98
- if (props.steps?.some((s) => !Array.isArray(s?.flow)))
99
- usage(`${input}: each step needs a flow array`);
100
- const findings = skip
101
- ? []
102
- : check(props, opts)
103
- .map((f) => (strict ? { ...f, severity: 'error' } : f))
104
- .sort((a, b) => (a.severity === b.severity ? a.rule.localeCompare(b.rule) : a.severity === 'error' ? -1 : 1));
170
+ usage(USAGE);
171
+ const props = await load(input);
172
+ const findings = skip ? [] : sortFindings(check(props, opts), strict);
105
173
  const errors = findings.filter((f) => f.severity === 'error').length;
106
- /** The findings for a person: one line each, colored only on a terminal. A render prints them to stderr, next to its own output. */
174
+ /** The findings for a person: one line each, colored only on a terminal, then the counts. A render prints them to stderr, next to
175
+ * its own output. A clean render still prints `0 errors, 0 warnings`: the agent copies that line into its reply. */
107
176
  const report = (print, tty) => {
108
- const paint = (s, code) => (tty && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
109
- for (const f of findings)
110
- print(`${paint(f.severity.padEnd(8), f.severity === 'error' ? 31 : 33)} ${f.rule.padEnd(18)} ${f.message}`);
111
- const n = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
112
- if (findings.length || print === console.log)
113
- print(`${n(errors, 'error')}, ${n(findings.length - errors, 'warning')}`);
114
- const c = counts(props);
115
- print(`figure: ${n(c.boxes, 'box').replace('boxs', 'boxes')}, ${n(c.groups, 'group')}, ${n(c.edges, 'edge')}, ${n(c.steps, 'step')}, ${n(c.messages, 'message')}`);
177
+ // The severity field is 8 characters wide, so the painted field keeps the same width.
178
+ const paint = (s) => tty && !process.env.NO_COLOR ? s.replace(/^(error {3}|warning )/, (w) => `\x1b[${w[0] === 'e' ? 31 : 33}m${w}\x1b[0m`) : s;
179
+ for (const line of reportLines(props, findings, !skip))
180
+ print(paint(line));
116
181
  };
117
182
  if (command === 'check') {
118
183
  if (json)
@@ -126,8 +191,6 @@ if (errors)
126
191
  process.exit(1);
127
192
  const dest = out ?? (input === '-' ? 'figure.svg' : input.replace(/\.[^./\\]+$/, '') + '.svg');
128
193
  // The spec rides along in <metadata>: an SVG is then its own source, and no JSON has to be kept.
129
- // `]]>` would close the CDATA early; it cannot appear in JSON-encoded text, but be sure.
130
- const spec = JSON.stringify({ props }).replaceAll(']]>', ']]\\u003e');
131
- const withSpec = toSvg(props).replace(/(<svg[^>]*>\n?)/, (tag) => `${tag}${SPEC_OPEN}${spec}${SPEC_CLOSE}\n`);
194
+ const withSpec = svgWithSpec(props);
132
195
  writeFileSync(dest, withSpec);
133
196
  console.log(`${dest} — ${(withSpec.length / 1024).toFixed(1)} kB`);
package/dist/diff.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { type FlowProps } from './model.ts';
2
+ export type Change = {
3
+ kind: 'box' | 'edge' | 'step' | 'message' | 'rail';
4
+ op: 'added' | 'removed' | 'changed';
5
+ id: string;
6
+ detail?: string;
7
+ };
8
+ export declare function diff(before: FlowProps, after: FlowProps): Change[];
9
+ export declare function formatDiff(changes: Change[], as: 'text' | 'md'): string;
package/dist/diff.js ADDED
@@ -0,0 +1,59 @@
1
+ // The spec diff: what a PR changed in a figure, by id. The SVG geometry follows from the spec, so the spec is the diff.
2
+ import { edgeId, isGroup, str, toBeat } from './model.js';
3
+ const show = (v) => (v == null ? '(none)' : `"${str(v)}"`);
4
+ /** The fields that changed, as `name "old" -> "new"` parts; empty when equal. */
5
+ const fields = (a, b, names) => names.filter((k) => str(a[k]) !== str(b[k])).map((k) => `${String(k)} ${show(a[k])} -> ${show(b[k])}`);
6
+ /** Compare two maps by key. The order is stable: changed first, then removed, then added. */
7
+ function compare(kind, before, after, fieldsOf) {
8
+ const out = [];
9
+ for (const [id, a] of before) {
10
+ const b = after.get(id);
11
+ if (!b)
12
+ continue;
13
+ const d = fieldsOf(a, b);
14
+ if (d.length)
15
+ out.push({ kind, op: 'changed', id, detail: d.join('; ') });
16
+ }
17
+ for (const id of before.keys())
18
+ if (!after.has(id))
19
+ out.push({ kind, op: 'removed', id });
20
+ for (const id of after.keys())
21
+ if (!before.has(id))
22
+ out.push({ kind, op: 'added', id });
23
+ return out;
24
+ }
25
+ const messages = (fig) => new Map((fig.steps ?? [])
26
+ .flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => `${str(s.label)}: ${h.edge}${h.back ? ' back' : ''}${h.data != null ? ` "${str(h.data)}"` : ''}${h.tone ? ` [${h.tone}]` : ''}`)))
27
+ .map((id) => [id, id]));
28
+ export function diff(before, after) {
29
+ const boxes = (f) => {
30
+ const m = new Map();
31
+ // The group is its label, else its id; the top level has none.
32
+ const walk = (g, parent) => g.children.forEach((c) => (isGroup(c) ? walk(c, c.label != null ? str(c.label) : c.id) : m.set(c.id, { ...c, group: parent })));
33
+ walk(f.layout);
34
+ return m;
35
+ };
36
+ const edges = (f) => new Map(f.edges.map((e) => [edgeId(e), e]));
37
+ const steps = (f) => new Map((f.steps ?? []).map((s) => [str(s.label), s]));
38
+ const out = [
39
+ ...compare('box', boxes(before), boxes(after), (a, b) => fields(a, b, ['label', 'sub', 'shape', 'tone', 'source', 'group'])),
40
+ ...compare('edge', edges(before), edges(after), (a, b) => fields(a, b, ['from', 'to', 'label', 'source'])),
41
+ ...compare('step', steps(before), steps(after), () => []),
42
+ ...compare('message', messages(before), messages(after), () => []),
43
+ ];
44
+ // The rail is a boolean or 'only', so str() would hide it.
45
+ if ((before.rail ?? false) !== (after.rail ?? false))
46
+ out.push({ kind: 'rail', op: 'changed', id: 'rail', detail: `${before.rail ?? '(none)'} -> ${after.rail ?? '(none)'}` });
47
+ return out;
48
+ }
49
+ export function formatDiff(changes, as) {
50
+ if (!changes.length)
51
+ return as === 'md' ? '- no change in the spec' : 'no change in the spec';
52
+ return changes
53
+ .map((c) => {
54
+ const id = as === 'md' ? `\`${c.id}\`` : c.id;
55
+ const line = `${c.kind} ${c.op}: ${id}${c.detail ? ` (${c.detail})` : ''}`;
56
+ return as === 'md' ? `- ${line}` : line;
57
+ })
58
+ .join('\n');
59
+ }
package/dist/draw.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** A file name from the question: lower case, letters and digits, joined by -, at most 60 characters. */
2
+ export declare function slug(question: string): string;
3
+ /** The claude argv after the binary. The system prompt carries the agent text, the output path and the folder rule. */
4
+ export declare function agentArgs(o: {
5
+ question: string;
6
+ out: string;
7
+ cwd: string;
8
+ maxTurns: number;
9
+ model?: string;
10
+ }): string[];
11
+ /** Run `flowfig draw`. Prints, and returns the exit code. */
12
+ export declare function runDraw(argv: string[]): Promise<number>;