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 +87 -24
- package/dist/check.js +29 -3
- package/dist/cli.js +112 -49
- package/dist/diff.d.ts +9 -0
- package/dist/diff.js +59 -0
- package/dist/draw.d.ts +12 -0
- package/dist/draw.js +178 -0
- package/dist/guide.d.ts +2 -2
- package/dist/guide.js +34 -12
- package/dist/index.js +74 -24
- package/dist/init.d.ts +15 -0
- package/dist/init.js +92 -6
- package/dist/load.d.ts +12 -0
- package/dist/load.js +59 -0
- package/dist/mcp.d.ts +139 -0
- package/dist/mcp.js +216 -0
- package/dist/model.d.ts +25 -3
- package/dist/model.js +14 -0
- package/dist/rail.d.ts +2 -1
- package/dist/rail.js +15 -1
- package/dist/scene.d.ts +2 -0
- package/dist/source.d.ts +18 -0
- package/dist/source.js +29 -0
- package/dist/svg.d.ts +2 -0
- package/dist/svg.js +113 -27
- package/dist/verify.d.ts +7 -0
- package/dist/verify.js +43 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +5 -1
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
|
|
185
|
-
|
|
|
186
|
-
| `id`
|
|
187
|
-
| `label`
|
|
188
|
-
| `sub`
|
|
189
|
-
| `shape`
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
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
|
|
209
|
-
| -------- |
|
|
210
|
-
| `from` | The id of the box or group where the edge starts.
|
|
211
|
-
| `to` | The id of the box or group where the edge ends.
|
|
212
|
-
| `id` | The name that beats use.
|
|
213
|
-
| `label` | The text on the edge.
|
|
214
|
-
| `around` | `"above"` or `"below"` routes the edge over or under the boxes between.
|
|
215
|
-
| `quiet` | `true` draws the edge only while a step uses it.
|
|
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
|
|
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.
|
|
236
|
-
| `show` | `{ boxId: content }` fills the content card of a box until the step ends.
|
|
237
|
-
| `light` | The ids of the boxes to highlight for this beat only.
|
|
238
|
-
| `ms` | The length of the beat in ms.
|
|
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
|
|
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,
|
|
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 {
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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(
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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>;
|