flowfig 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -17
- package/dist/check.js +64 -5
- package/dist/cli.js +13 -10
- package/dist/diff.js +12 -5
- package/dist/draw.js +1 -1
- package/dist/edges.js +53 -6
- package/dist/geometry.d.ts +11 -2
- package/dist/geometry.js +255 -67
- package/dist/guide.d.ts +22 -2
- package/dist/guide.js +282 -222
- package/dist/index.js +36 -28
- package/dist/load.d.ts +2 -0
- package/dist/load.js +24 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +16 -6
- package/dist/model.d.ts +8 -2
- package/dist/model.js +54 -11
- package/dist/scene.d.ts +2 -0
- package/dist/svg.js +49 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ jobs:
|
|
|
65
65
|
- uses: actions/setup-node@v4
|
|
66
66
|
with:
|
|
67
67
|
node-version: 22
|
|
68
|
-
- uses: iamalvisng/flowfig@v0.8.
|
|
68
|
+
- uses: iamalvisng/flowfig@v0.8.1
|
|
69
69
|
with:
|
|
70
70
|
figures: 'docs/**/*.svg' # default **/*.svg
|
|
71
71
|
```
|
|
@@ -117,7 +117,7 @@ the run.
|
|
|
117
117
|
|
|
118
118
|
1. **The agent writes JSON, not pictures.** The agent reads the code and writes a spec of the parts, the calls and their
|
|
119
119
|
order. flowfig does the layout.
|
|
120
|
-
2. **flowfig checks the spec.** `flowfig check` has
|
|
120
|
+
2. **flowfig checks the spec.** `flowfig check` has 23 rules. It finds ids that point nowhere, text wider than its box,
|
|
121
121
|
edges through boxes, overlapping labels and low contrast. A render runs the same check and writes nothing on an error.
|
|
122
122
|
The agent reads the faults and fixes the spec.
|
|
123
123
|
3. **The output is one animated SVG with no script.** The SVG plays in a GitHub README, PR or issue. It follows the light
|
|
@@ -167,7 +167,7 @@ The options are in [Open and share a figure](#open-and-share-a-figure).
|
|
|
167
167
|
| ------------------------------ | --------------------------------- | ------------------------------------ | ---------------- |
|
|
168
168
|
| Made by an agent from the code | Yes, with the `init` instructions | Yes, as Mermaid text | No |
|
|
169
169
|
| Linked to the code | Yes, `verify` fails in CI | No | No |
|
|
170
|
-
| Checked for faults | Yes,
|
|
170
|
+
| Checked for faults | Yes, 23 rules in `flowfig check` | Syntax errors only | No |
|
|
171
171
|
| Animated | Yes, one packet per message | No | No |
|
|
172
172
|
| Shows payloads and order | Yes, on the map and the rail | Order and text in a sequence diagram | No |
|
|
173
173
|
| Plays in a GitHub README | Yes, as an SVG | Yes, GitHub renders it | Yes, as an image |
|
|
@@ -215,7 +215,7 @@ Save this spec as `first.json`. The spec has 3 boxes, 2 edges and 1 step.
|
|
|
215
215
|
},
|
|
216
216
|
"edges": [
|
|
217
217
|
{ "from": "browser", "to": "api", "label": "GET /user" },
|
|
218
|
-
{ "from": "api", "to": "db", "label": "
|
|
218
|
+
{ "from": "api", "to": "db", "label": "read the user" }
|
|
219
219
|
],
|
|
220
220
|
"steps": [
|
|
221
221
|
{
|
|
@@ -235,10 +235,18 @@ Render the spec:
|
|
|
235
235
|
|
|
236
236
|
```console
|
|
237
237
|
$ npx flowfig first.json
|
|
238
|
+
0 errors, 0 warnings
|
|
238
239
|
figure: 3 boxes, 0 groups, 2 edges, 1 step, 4 messages
|
|
239
|
-
|
|
240
|
+
browser -> api: GET /user
|
|
241
|
+
api -> db: read the user
|
|
242
|
+
step "Load a user": 4 hops
|
|
243
|
+
first.svg — 9.7 kB
|
|
240
244
|
```
|
|
241
245
|
|
|
246
|
+
The render prints the check result, the counts, one line per edge and one line per step. Read these lines to check the figure
|
|
247
|
+
without a second command. If a box or an edge has a `source` or a `via`, the render also prints the `verify` counts.
|
|
248
|
+
`--no-verify` skips them.
|
|
249
|
+
|
|
242
250
|
The command writes `first.svg`:
|
|
243
251
|
|
|
244
252
|

|
|
@@ -283,14 +291,14 @@ full reference, run `npx flowfig docs`.
|
|
|
283
291
|
|
|
284
292
|
`layout` is a group. A group holds boxes and other groups.
|
|
285
293
|
|
|
286
|
-
| Field | Meaning
|
|
287
|
-
| ----------- |
|
|
288
|
-
| `children` | The boxes and groups in the group, in order.
|
|
289
|
-
| `id` | The name that an edge can use to reach the whole group.
|
|
290
|
-
| `label` | The title of the frame. Only a group with a label has a frame.
|
|
291
|
-
| `direction` | `"row"` puts the children side by side. `"column"` stacks them.
|
|
292
|
-
| `gap` | The space between the children in px.
|
|
293
|
-
| `align` | `"start"`, `"center"` or `"end"`, across the direction.
|
|
294
|
+
| Field | Meaning | Default |
|
|
295
|
+
| ----------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
|
296
|
+
| `children` | The boxes and groups in the group, in order. | required |
|
|
297
|
+
| `id` | The name that an edge can use to reach the whole group. | none |
|
|
298
|
+
| `label` | The title of the frame. Only a group with a label has a frame. | none |
|
|
299
|
+
| `direction` | `"row"` puts the children side by side. `"column"` stacks them. | `"row"` |
|
|
300
|
+
| `gap` | The smallest space between the children in px. It grows to fit the labels of edges that cross the group. | column: 28; row: fits the widest label, at least 56 |
|
|
301
|
+
| `align` | `"start"`, `"center"` or `"end"`, across the direction. | `"center"` (a column stretches) |
|
|
294
302
|
|
|
295
303
|
### Edges
|
|
296
304
|
|
|
@@ -360,7 +368,7 @@ figure: 5 boxes, 3 groups, 4 edges, 2 steps, 6 messages
|
|
|
360
368
|
|
|
361
369
|
The last line gives the counts of the parts of the figure. Compare the counts with the parts that you planned.
|
|
362
370
|
|
|
363
|
-
`flowfig check` has
|
|
371
|
+
`flowfig check` has 23 rules: 11 errors and 12 warnings.
|
|
364
372
|
|
|
365
373
|
| Rule | Severity | What it finds |
|
|
366
374
|
| --------------------------- | -------- | --------------------------------------------------------------------------------------------------- |
|
|
@@ -386,6 +394,7 @@ The last line gives the counts of the parts of the figure. Compare the counts wi
|
|
|
386
394
|
| `timeline-and-lanes` | warning | The figure sets `timeline` and `lanes`. The renderers draw the timeline and ignore `lanes`. |
|
|
387
395
|
| `font-estimated` | warning | `theme.font` is set. The SVG check estimates text width for the system font. |
|
|
388
396
|
| `color-not-checked` | warning | A color that the check cannot read, so its contrast is not checked. |
|
|
397
|
+
| `plain-text` | warning | Text with a code name, a filler word, or a `say` or caption over 20 words. |
|
|
389
398
|
|
|
390
399
|
| Option | Effect |
|
|
391
400
|
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
@@ -469,7 +478,7 @@ jobs:
|
|
|
469
478
|
- uses: actions/setup-node@v4
|
|
470
479
|
with:
|
|
471
480
|
node-version: 22
|
|
472
|
-
- uses: iamalvisng/flowfig@v0.8.
|
|
481
|
+
- uses: iamalvisng/flowfig@v0.8.1
|
|
473
482
|
with:
|
|
474
483
|
figures: 'docs/**/*.svg' # default **/*.svg
|
|
475
484
|
```
|
|
@@ -477,7 +486,8 @@ jobs:
|
|
|
477
486
|
## MCP server
|
|
478
487
|
|
|
479
488
|
`npx flowfig mcp` serves the tools `docs`, `check`, `render`, `verify` and `diff` over stdio, for an agent with no shell.
|
|
480
|
-
`render` writes the SVG file and returns the check lines and the path, so no SVG text goes through the model.
|
|
489
|
+
`render` writes the SVG file and returns the check lines and the path, so no SVG text goes through the model. `docs` takes an
|
|
490
|
+
optional `topic`.
|
|
481
491
|
|
|
482
492
|
`npx flowfig init` registers the server for Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`), GitHub Copilot
|
|
483
493
|
(`.vscode/mcp.json`), Gemini CLI (`.gemini/settings.json`) and Kiro (`.kiro/settings/mcp.json`). It merges one entry into
|
|
@@ -503,6 +513,7 @@ The player has these controls:
|
|
|
503
513
|
- A pause button, and a speed button that changes between 1× and 2×.
|
|
504
514
|
- A full screen button. Esc closes full screen.
|
|
505
515
|
- A hover on a box highlights its edges. With the rail, a click on a row starts that message, and a hover highlights its edge.
|
|
516
|
+
- A hover on a box, an edge or a rail row with a `source` shows the `source`. The SVG shows it as a tooltip too.
|
|
506
517
|
|
|
507
518
|
The player props are the spec fields. `theme` sets the colors, `speed` sets the packet time, and `autoplay={false}` stops the
|
|
508
519
|
auto start. `check` runs the check rules. If a figure is wider than its container, the player shrinks the figure to half size at
|
|
@@ -586,7 +597,8 @@ A section sits between `<!-- flowfig:start -->` and `<!-- flowfig:end -->`. A se
|
|
|
586
597
|
of the file. A second run also replaces a whole file that `init` wrote. If a whole file exists and did not come from `init`,
|
|
587
598
|
`init` skips the file. `init` also skips a section file that has a start marker and no end marker.
|
|
588
599
|
|
|
589
|
-
`npx flowfig docs` prints the
|
|
600
|
+
`npx flowfig docs` prints the core guide as Markdown. `npx flowfig docs <topic>` prints one topic: `lanes`, `timeline`, `rail`,
|
|
601
|
+
`marks` or `verify`.
|
|
590
602
|
|
|
591
603
|
## What flowfig does not draw
|
|
592
604
|
|
package/dist/check.js
CHANGED
|
@@ -1,11 +1,68 @@
|
|
|
1
1
|
import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, lanePlan, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
|
|
2
2
|
import { layoutRail } from './rail.js';
|
|
3
|
-
import { crosses } from './geometry.js';
|
|
3
|
+
import { crosses, hit as curveHit } from './geometry.js';
|
|
4
4
|
import { textWidth } from './text.js';
|
|
5
5
|
import { owners, parseSource } from './source.js';
|
|
6
6
|
const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
|
|
7
7
|
const warn = (rule, ids, message) => ({ rule, severity: 'warning', ids, message });
|
|
8
8
|
const groupIds = (g) => [...(g.id ? [g.id] : []), ...g.children.flatMap((c) => (isGroup(c) ? groupIds(c) : []))];
|
|
9
|
+
const FILLER = /\b(seamless(ly)?|robust|powerful|leverag(e|es|ed|ing)|effortless(ly)?|cutting-edge|state-of-the-art|holistic|synergy|empower(s|ed|ing)?|elegant(ly)?)\b/i;
|
|
10
|
+
const codeShape = (t) => /^[^A-Za-z]*[a-z][A-Za-z0-9]*[A-Z]|[A-Za-z]_[A-Za-z]|\(\)|::|\w\.\w+\(/.test(t);
|
|
11
|
+
const DB_COMMAND = /^(SELECT|INSERT|UPDATE|DELETE|UPSERT|MERGE|SET|GET|DEL|HSET|HGET|HDEL|EXPIRE|INCR|DECR|LPUSH|RPUSH|LPOP|RPOP|SADD|ZADD|PUBLISH|XADD)\b/;
|
|
12
|
+
const HTTP_LINE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) \//;
|
|
13
|
+
const codeToken = (t) => codeShape(t) || t.split(/[./]/).some(codeShape);
|
|
14
|
+
function plainText(fig) {
|
|
15
|
+
const out = [];
|
|
16
|
+
const check = (owner, field, text, o = {}) => {
|
|
17
|
+
const t = str(text).trim();
|
|
18
|
+
if (!t)
|
|
19
|
+
return;
|
|
20
|
+
const tokens = t.split(/\s+/);
|
|
21
|
+
const symbol = o.source
|
|
22
|
+
? parseSource(o.source)
|
|
23
|
+
?.symbol?.split(/[.#:]+/)
|
|
24
|
+
.pop()
|
|
25
|
+
: undefined;
|
|
26
|
+
const code = o.code === false ? undefined : tokens.find((w) => codeToken(w) || (w === symbol && /[a-z][A-Z]/.test(w)));
|
|
27
|
+
const filler = FILLER.exec(t)?.[0];
|
|
28
|
+
const command = o.command && DB_COMMAND.test(t) && !HTTP_LINE.test(t);
|
|
29
|
+
const why = [
|
|
30
|
+
command && `${field} "${t}" looks like code; use plain words`,
|
|
31
|
+
code &&
|
|
32
|
+
(tokens.length === 1 && field === 'label'
|
|
33
|
+
? `${field} "${t}" looks like code; use plain words, the code name goes in source; if this is a product name, keep it`
|
|
34
|
+
: `${field} has the code name "${code}"; use plain words; if this is a product name, keep it`),
|
|
35
|
+
symbol && t === symbol && !code && !/^[A-Za-z]+$/.test(t) && `${field} "${t}" repeats its source; use plain words`,
|
|
36
|
+
filler && `${field} "${t}" has the filler word "${filler}"`,
|
|
37
|
+
o.words && tokens.length > 20 && `${field} has ${tokens.length} words; keep it to 20`,
|
|
38
|
+
].filter(Boolean);
|
|
39
|
+
if (why.length)
|
|
40
|
+
out.push(warn('plain-text', [], `${owner}: ${why.join('; ')}`));
|
|
41
|
+
};
|
|
42
|
+
for (const n of nodes(fig.layout)) {
|
|
43
|
+
check(`box "${n.id}"`, 'label', n.label, { source: n.source, command: true });
|
|
44
|
+
check(`box "${n.id}"`, 'sub', n.sub);
|
|
45
|
+
}
|
|
46
|
+
const groupLabels = (g) => {
|
|
47
|
+
if (g.label != null)
|
|
48
|
+
check(`group "${g.id ?? str(g.label)}"`, 'label', g.label);
|
|
49
|
+
for (const c of g.children)
|
|
50
|
+
if (isGroup(c))
|
|
51
|
+
groupLabels(c);
|
|
52
|
+
};
|
|
53
|
+
groupLabels(fig.layout);
|
|
54
|
+
fig.edges.forEach((e) => check(`edge "${edgeId(e)}"`, 'label', e.label, { source: e.source, command: true }));
|
|
55
|
+
for (const s of fig.steps ?? []) {
|
|
56
|
+
const name = str(s.label);
|
|
57
|
+
check(`step "${name}"`, 'label', s.label);
|
|
58
|
+
check(`step "${name}"`, 'caption', s.caption, { words: true });
|
|
59
|
+
s.flow.map(toBeat).forEach((b, i) => {
|
|
60
|
+
check(`step "${name}"`, `say ${i + 1}`, b.say, { words: true });
|
|
61
|
+
b.hops.forEach((h) => check(`step "${name}"`, `data ${i + 1}`, h.data, { code: false }));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
9
66
|
/** Faults in the spec: dangling ids, duplicate ids, empty steps. The renderers skip them silently. */
|
|
10
67
|
export function checkSpec(fig) {
|
|
11
68
|
const out = [];
|
|
@@ -104,6 +161,7 @@ export function checkSpec(fig) {
|
|
|
104
161
|
for (const n of nodes(fig.layout))
|
|
105
162
|
if (n.at != null && !validAt(n.at))
|
|
106
163
|
out.push(err('bad-at', [n.id], `box "${n.id}": at ${n.at} is not an integer of 0 or more`));
|
|
164
|
+
out.push(...plainText(fig));
|
|
107
165
|
return out;
|
|
108
166
|
}
|
|
109
167
|
const at = ([p0, p1, p2, p3], t) => {
|
|
@@ -128,11 +186,9 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
128
186
|
if (e.behind && !e.elbow)
|
|
129
187
|
continue;
|
|
130
188
|
const line = e.elbow ?? e.pts;
|
|
131
|
-
const pts = line
|
|
132
|
-
? line.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(line[k], q, i / 16)))
|
|
133
|
-
: Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
|
|
189
|
+
const pts = line?.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(line[k], q, i / 16)));
|
|
134
190
|
for (const b of scene.boxes)
|
|
135
|
-
if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
|
|
191
|
+
if (b.id !== e.from && b.id !== e.to && (pts ? pts.some((p) => inside(p, b.rect, 2)) : curveHit(e.curve, [b.rect], -2).length))
|
|
136
192
|
out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}"`));
|
|
137
193
|
}
|
|
138
194
|
const pills = scene.edges.filter((e) => e.pts && e.label);
|
|
@@ -181,6 +237,9 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
181
237
|
for (const b of scene.boxes)
|
|
182
238
|
if (overlap(e.label, b.rect, 1))
|
|
183
239
|
out.push(err('label-overlap', [e.id, b.id], `the label of edge "${e.id}" covers box "${b.id}"`));
|
|
240
|
+
const [p, a] = [e.label, scene.area];
|
|
241
|
+
if (a && e.step == null && (p.x < a.x - 0.5 || p.y < a.y - 0.5 || p.x + p.w > a.x + a.w + 0.5 || p.y + p.h > a.y + a.h + 0.5))
|
|
242
|
+
out.push(err('label-overlap', [e.id], `the label of edge "${e.id}" is outside the figure`));
|
|
184
243
|
});
|
|
185
244
|
const shown = scene.minFont * Math.min(1, width / scene.width);
|
|
186
245
|
if (shown < minText)
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
14
14
|
import { pathToFileURL } from 'node:url';
|
|
15
15
|
import { captureFrames, findBrowser, launch } from './browser.js';
|
|
16
16
|
import { delays, encodeGif } from './gif.js';
|
|
17
|
-
import { GUIDE } from './guide.js';
|
|
17
|
+
import { GUIDE, TOPICS } from './guide.js';
|
|
18
18
|
import { runDraw } from './draw.js';
|
|
19
19
|
import { runInit } from './init.js';
|
|
20
20
|
import { serve } from './mcp.js';
|
|
@@ -22,7 +22,7 @@ import { openedLine, openSvg, runOpen } from './open.js';
|
|
|
22
22
|
import { pageHtml } from './page.js';
|
|
23
23
|
import { decodePng } from './png.js';
|
|
24
24
|
import { diff, formatDiff } from './diff.js';
|
|
25
|
-
import { loadSpec, reportLines, sortFindings, specOf, svgWithSpec } from './load.js';
|
|
25
|
+
import { loadSpec, reportLines, sortFindings, specOf, summaryLines, svgWithSpec, verifyLines } from './load.js';
|
|
26
26
|
import { check, render, toSvg } from './svg.js';
|
|
27
27
|
import { checkRendered } from './check.js';
|
|
28
28
|
import { coverageLine, links, unsureLines, verifyReport } from './verify.js';
|
|
@@ -31,13 +31,13 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] rende
|
|
|
31
31
|
flowfig --spec figure.svg print the spec the SVG carries
|
|
32
32
|
flowfig verify <input>... [--root <dir>] [--json] [--strict] check the code links of one or more figures
|
|
33
33
|
flowfig diff <old> <new> [--json|--md] list the spec changes between two figures
|
|
34
|
-
flowfig docs
|
|
34
|
+
flowfig docs [topic] print the guide, or one topic (Markdown)
|
|
35
35
|
flowfig mcp serve check, render, verify, diff and docs over MCP (stdio)
|
|
36
36
|
flowfig init [dir] [--agents <ids>] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
|
|
37
37
|
flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] [--open] ask Claude Code for a figure, then check it
|
|
38
38
|
flowfig open <figure.svg> [--html <path>] show the figure in the default browser
|
|
39
39
|
flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
|
|
40
|
-
flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only)`;
|
|
40
|
+
flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only)`;
|
|
41
41
|
function usage(message) {
|
|
42
42
|
console.error(message);
|
|
43
43
|
process.exit(2);
|
|
@@ -62,7 +62,10 @@ if (!args[0] || ['help', '--help', '-h'].includes(args[0])) {
|
|
|
62
62
|
process.exit(0);
|
|
63
63
|
}
|
|
64
64
|
if (args[0] === 'docs') {
|
|
65
|
-
|
|
65
|
+
const topic = args[1];
|
|
66
|
+
if (topic && !Object.hasOwn(TOPICS, topic))
|
|
67
|
+
usage(`unknown topic ${topic}; topics: ${Object.keys(TOPICS).join(', ')}`);
|
|
68
|
+
process.stdout.write(topic ? TOPICS[topic] : GUIDE);
|
|
66
69
|
process.exit(0);
|
|
67
70
|
}
|
|
68
71
|
if (args[0] === 'mcp') {
|
|
@@ -316,7 +319,7 @@ if (args[0] === 'gif') {
|
|
|
316
319
|
process.exit(stopped || code);
|
|
317
320
|
}
|
|
318
321
|
const command = args[0] === 'check' ? args.shift() : 'render';
|
|
319
|
-
const json = flag('--json'), strict = flag('--strict'), skip = flag('--no-check'), open = flag('--open');
|
|
322
|
+
const json = flag('--json'), strict = flag('--strict'), skip = flag('--no-check'), skipVerify = flag('--no-verify'), open = flag('--open');
|
|
320
323
|
if (command === 'check' && open)
|
|
321
324
|
usage('--open works with a render, not with check');
|
|
322
325
|
const opts = { width: value('--width'), minText: value('--min-text') };
|
|
@@ -330,9 +333,9 @@ const props = await load(input);
|
|
|
330
333
|
const rendered = skip ? undefined : render(props, opts);
|
|
331
334
|
const findings = rendered ? sortFindings(checkRendered(props, opts, rendered.scene), strict) : [];
|
|
332
335
|
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
333
|
-
const report = (print, tty) => {
|
|
336
|
+
const report = (print, tty, extra = []) => {
|
|
334
337
|
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;
|
|
335
|
-
for (const line of reportLines(props, findings, !skip))
|
|
338
|
+
for (const line of [...reportLines(props, findings, !skip), ...extra])
|
|
336
339
|
print(paint(line));
|
|
337
340
|
};
|
|
338
341
|
if (command === 'check') {
|
|
@@ -342,10 +345,10 @@ if (command === 'check') {
|
|
|
342
345
|
report(console.log, process.stdout.isTTY);
|
|
343
346
|
process.exit(errors ? 1 : 0);
|
|
344
347
|
}
|
|
345
|
-
|
|
348
|
+
const dest = out ?? (input === '-' ? 'figure.svg' : input.replace(/\.[^./\\]+$/, '') + '.svg');
|
|
349
|
+
report(console.error, process.stderr.isTTY, errors ? [] : [...summaryLines(props), ...(skipVerify ? [] : verifyLines(props, dest))]);
|
|
346
350
|
if (errors)
|
|
347
351
|
process.exit(1);
|
|
348
|
-
const dest = out ?? (input === '-' ? 'figure.svg' : input.replace(/\.[^./\\]+$/, '') + '.svg');
|
|
349
352
|
const withSpec = svgWithSpec(props, opts, rendered?.svg);
|
|
350
353
|
writeFileSync(dest, withSpec);
|
|
351
354
|
console.log(`${dest} — ${(withSpec.length / 1024).toFixed(1)} kB`);
|
package/dist/diff.js
CHANGED
|
@@ -19,9 +19,16 @@ function compare(kind, before, after, fieldsOf) {
|
|
|
19
19
|
out.push({ kind, op: 'added', id });
|
|
20
20
|
return out;
|
|
21
21
|
}
|
|
22
|
-
const messages = (fig) =>
|
|
23
|
-
|
|
24
|
-
.
|
|
22
|
+
const messages = (fig) => {
|
|
23
|
+
const seen = new Map();
|
|
24
|
+
const ids = (fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => {
|
|
25
|
+
const key = `${str(s.label)}: ${h.edge}${h.back ? ' back' : ''}${h.data != null ? ` "${str(h.data)}"` : ''}${h.tone ? ` [${h.tone}]` : ''}${h.source ? ` <${h.source}>` : ''}${h.via ? ` via ${h.via}` : ''}`;
|
|
26
|
+
const n = (seen.get(key) ?? 0) + 1;
|
|
27
|
+
seen.set(key, n);
|
|
28
|
+
return n > 1 ? `${key} #${n}` : key;
|
|
29
|
+
})));
|
|
30
|
+
return new Map(ids.map((id) => [id, id]));
|
|
31
|
+
};
|
|
25
32
|
export function diff(before, after) {
|
|
26
33
|
const boxes = (f) => {
|
|
27
34
|
const m = new Map();
|
|
@@ -33,8 +40,8 @@ export function diff(before, after) {
|
|
|
33
40
|
const steps = (f) => new Map((f.steps ?? []).map((s) => [str(s.label), s]));
|
|
34
41
|
const out = [
|
|
35
42
|
...compare('box', boxes(before), boxes(after), (a, b) => fields(a, b, ['label', 'sub', 'shape', 'tone', 'mark', 'from', 'to', 'source', 'group'])),
|
|
36
|
-
...compare('edge', edges(before), edges(after), (a, b) => fields(a, b, ['from', 'to', 'label', 'source'])),
|
|
37
|
-
...compare('step', steps(before), steps(after), () => []),
|
|
43
|
+
...compare('edge', edges(before), edges(after), (a, b) => fields(a, b, ['from', 'to', 'label', 'source', 'via'])),
|
|
44
|
+
...compare('step', steps(before), steps(after), (a, b) => fields(a, b, ['caption'])),
|
|
38
45
|
...compare('message', messages(before), messages(after), () => []),
|
|
39
46
|
];
|
|
40
47
|
// The rail is a boolean or 'only', so str() would hide it.
|
package/dist/draw.js
CHANGED
|
@@ -22,7 +22,7 @@ export function slug(question) {
|
|
|
22
22
|
const specPath = (out) => out.replace(/\.svg$/, '.json');
|
|
23
23
|
export function agentArgs(o) {
|
|
24
24
|
const spec = specPath(o.out);
|
|
25
|
-
const system = `${AGENT_TEXT.trimEnd()}\n\nWrite the spec to ${spec} with the Write tool. Then run exactly \`npx flowfig ${spec} ${o.out}\` as one command, alone on its line: no heredoc, no \`;\`, no \`&&\`, no pipe (the permission rule matches one plain command only).
|
|
25
|
+
const system = `${AGENT_TEXT.trimEnd()}\n\nWrite the spec to ${spec} with the Write tool. Then run exactly \`npx flowfig ${spec} ${o.out}\` as one command, alone on its line: no heredoc, no \`;\`, no \`&&\`, no pipe (the permission rule matches one plain command only). Run every command from this folder, ${o.cwd}.`;
|
|
26
26
|
const args = [
|
|
27
27
|
'-p',
|
|
28
28
|
o.question,
|
package/dist/edges.js
CHANGED
|
@@ -38,6 +38,30 @@ const CONSTS = {
|
|
|
38
38
|
cs: /^[ \t]*(?:(?:public|private|protected|internal)\s+)?const\s+string\s+(\w+)\s*=\s*(")([^"\n]*)"/gm,
|
|
39
39
|
rs: /^(?:pub(?:\([^)]*\))?\s+)?const\s+(\w+)\s*:\s*&(?:'static\s+)?str\s*=\s*(")([^"\n]*)"/gm,
|
|
40
40
|
};
|
|
41
|
+
const VARIABLE = {
|
|
42
|
+
ts: /^\s*(?:export\s+)?(?:const|let|var)\s/,
|
|
43
|
+
py: /^[\p{L}_][\p{L}\p{N}_]*\s*(?::[^=\n]*)?=(?!=)/u,
|
|
44
|
+
go: /^\s*(?:var|const)\b/,
|
|
45
|
+
rs: /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:static|const|let)\b/,
|
|
46
|
+
java: /^[^({=;]*[=;]/,
|
|
47
|
+
cs: /^[^({=;]*[=;]/,
|
|
48
|
+
};
|
|
49
|
+
function topStatements(code, name) {
|
|
50
|
+
let out = '';
|
|
51
|
+
for (const m of code.matchAll(new RegExp(`^${esc(name)}\\s*[.[(]`, 'gm'))) {
|
|
52
|
+
let i = m.index;
|
|
53
|
+
for (let d = 0; i < code.length; i++) {
|
|
54
|
+
if ('([{'.includes(code[i]))
|
|
55
|
+
d++;
|
|
56
|
+
else if (')]}'.includes(code[i]))
|
|
57
|
+
d--;
|
|
58
|
+
else if (d <= 0 && (code[i] === ';' || (code[i] === '\n' && !/^\s*[.)]/.test(code.slice(i + 1, i + 80)))))
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
out += '\n' + code.slice(m.index, i + 1);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
41
65
|
const IMPORT_LINES = /\b(?:import|export)\s+(?:type\s+)?[\w*\s,{}$]*?\bfrom\b|^[ \t]*(?:from|use|using|package|import)\b.*|.*\brequire\(.*/gm;
|
|
42
66
|
const MODIFIERS = new Set([
|
|
43
67
|
'mut',
|
|
@@ -86,14 +110,25 @@ function params(body, from) {
|
|
|
86
110
|
}
|
|
87
111
|
return [...parts, text.slice(last)];
|
|
88
112
|
}
|
|
113
|
+
function paramName(p, lang) {
|
|
114
|
+
const tokens = (p.split('=')[0].match(/[\w$]+/g) ?? []).filter((t) => !MODIFIERS.has(t));
|
|
115
|
+
return lang === 'java' || lang === 'cs' ? tokens.at(-1) : tokens[0];
|
|
116
|
+
}
|
|
117
|
+
function paramCall(body, lang, fromName) {
|
|
118
|
+
const open = body.indexOf('(', fromName ? Math.max(0, body.indexOf(fromName)) : 0);
|
|
119
|
+
if (open < 0)
|
|
120
|
+
return undefined;
|
|
121
|
+
const rest = body.slice(matchClose(body, open) + 1);
|
|
122
|
+
return params(body, fromName)
|
|
123
|
+
.map((p) => paramName(p, lang))
|
|
124
|
+
.find((n) => n && new RegExp(`(?<![\\w$.])${esc(n)}\\s*\\(`).test(rest));
|
|
125
|
+
}
|
|
89
126
|
function shadows(body, name, lang, fromName, whole) {
|
|
90
127
|
const n = esc(name);
|
|
91
128
|
if (!whole)
|
|
92
|
-
for (const p of params(body, fromName))
|
|
93
|
-
|
|
94
|
-
if ((lang === 'java' || lang === 'cs' ? tokens.at(-1) : tokens[0]) === name)
|
|
129
|
+
for (const p of params(body, fromName))
|
|
130
|
+
if (paramName(p, lang) === name)
|
|
95
131
|
return true;
|
|
96
|
-
}
|
|
97
132
|
return ([`\\b(let|const|var|val|mut)\\s+${n}(?![\\w$])`, `(?<![\\w$.])${n}\\s*:=`, `(^|[\\n;{])[ \\t]*${n}\\s*(:[^=\\n]+)?=(?![=>])`].some((p) => new RegExp(p).test(body)) || typedName(body, n));
|
|
98
133
|
}
|
|
99
134
|
const defaultIs = (ti, symbol) => new RegExp(`export\\s+default\\s+(async\\s+)?(function\\s*\\*?\\s*|class\\s+)?${esc(symbol.split('.')[0])}\\b`).test(ti.code);
|
|
@@ -284,9 +319,18 @@ export function edgeResult(root, caller, callee, via, read = readFile, cache = n
|
|
|
284
319
|
let body = fi.code.slice(at.start, at.end);
|
|
285
320
|
if (whole)
|
|
286
321
|
body = body.replace(IMPORT_LINES, (m) => m.replace(/[^\n]/g, ' '));
|
|
322
|
+
const isVar = !whole && VARIABLE[fi.lang].test(body);
|
|
323
|
+
if (isVar && (fi.lang === 'ts' || fi.lang === 'py'))
|
|
324
|
+
body += topStatements(fi.code, from.symbol.split('.').at(-1));
|
|
325
|
+
const doubt = () => {
|
|
326
|
+
if (isVar && fi.lang !== 'ts' && fi.lang !== 'py')
|
|
327
|
+
return { result: 'unsure', reason: `${label(from)} is a variable; verify does not read its uses` };
|
|
328
|
+
const p = whole ? undefined : paramCall(body, fi.lang, from.symbol?.split('.').at(-1));
|
|
329
|
+
return p ? { result: 'unsure', reason: `the caller calls its parameter ${p}; the target is not known` } : null;
|
|
330
|
+
};
|
|
287
331
|
if (!to.symbol) {
|
|
288
332
|
const r = fileResult(root, fi, ti, body, whole, from, to, read, cache);
|
|
289
|
-
return r.result === 'not-found' ? { ...r, reason: r.reason + hint() } : r;
|
|
333
|
+
return r.result === 'not-found' ? (doubt() ?? { ...r, reason: r.reason + hint() }) : r;
|
|
290
334
|
}
|
|
291
335
|
const toParts = to.symbol.split('.');
|
|
292
336
|
const toName = toParts.at(-1);
|
|
@@ -388,7 +432,7 @@ export function edgeResult(root, caller, callee, via, read = readFile, cache = n
|
|
|
388
432
|
return unsure(`${n} has no path to ${owner}`);
|
|
389
433
|
}
|
|
390
434
|
const dynamic = new RegExp(`\\{[^}]*\\b${esc(n)}\\b[^}]*\\}\\s*=\\s*(?:await\\s+)?(?:import|require)\\(`).test(body);
|
|
391
|
-
if ((shadowed || (n === toName && !dynamic && localType(cx, fi, body, n, lang, !whole))) && n !== fromName)
|
|
435
|
+
if ((shadowed || (n === toName && !dynamic && localType(cx, fi, body, n, lang, !whole && !isVar))) && n !== fromName)
|
|
392
436
|
return ignore;
|
|
393
437
|
if (pyStar)
|
|
394
438
|
return unsure(`${n} can come from a star import`);
|
|
@@ -506,5 +550,8 @@ export function edgeResult(root, caller, callee, via, read = readFile, cache = n
|
|
|
506
550
|
const pick = verdicts.find((v) => v.res === 'found') ?? verdicts.find((v) => v.res === 'unsure');
|
|
507
551
|
if (pick)
|
|
508
552
|
return { result: pick.res === 'found' ? 'found' : 'unsure', reason: pick.why };
|
|
553
|
+
const d = [...names].some((n) => word(n).test(body)) ? null : doubt();
|
|
554
|
+
if (d)
|
|
555
|
+
return d;
|
|
509
556
|
return { result: 'not-found', reason: `${who} does not call ${toName}${hint()}` };
|
|
510
557
|
}
|
package/dist/geometry.d.ts
CHANGED
|
@@ -25,12 +25,19 @@ export type Routed = {
|
|
|
25
25
|
curve: [Pt, Pt, Pt, Pt];
|
|
26
26
|
stub?: Stub;
|
|
27
27
|
elbow?: Pt[];
|
|
28
|
+
around?: Around;
|
|
29
|
+
/** The px a detour and its label need outside the boxes on the `around` side. */
|
|
30
|
+
room?: number;
|
|
31
|
+
/** The px an elbow detour goes outside the boxes, for each side. */
|
|
32
|
+
bleed?: Partial<Record<Around, number>>;
|
|
28
33
|
};
|
|
29
34
|
/** A rect that a route keeps clear of. `box` marks a box. */
|
|
30
35
|
export type Avoid = Rect & {
|
|
31
36
|
box?: boolean;
|
|
32
37
|
};
|
|
33
|
-
|
|
38
|
+
export declare const avoidOf: (boxes: Rect[], extra: Rect[], lanes: boolean) => Avoid[];
|
|
39
|
+
type Around = 'above' | 'below' | 'left' | 'right';
|
|
40
|
+
export declare const hit: (curve: [Pt, Pt, Pt, Pt], boxes: Rect[], m: number) => Rect[];
|
|
34
41
|
/** Routes edges between measured boxes as curved SVG paths. `elbow` draws right-angle paths; `stub` splits a cross-lane edge in two. */
|
|
35
42
|
export declare function route(edges: {
|
|
36
43
|
id: string;
|
|
@@ -45,7 +52,9 @@ export declare function route(edges: {
|
|
|
45
52
|
}[], rects: Record<string, Rect>, tips?: Set<string>, avoid?: Avoid[], area?: Rect, lanes?: {
|
|
46
53
|
bands: Rect[];
|
|
47
54
|
boxes: Rect[];
|
|
48
|
-
}): Routed[];
|
|
55
|
+
}, pad?: number): Routed[];
|
|
56
|
+
export declare const arcRoom: (routed: Routed[], way: Around) => number;
|
|
57
|
+
export declare function labelAlong(r: Routed, w: number, boxes: Rect[], placed: Rect[], fig?: Rect, lines?: Pt[]): Pt;
|
|
49
58
|
/** True if segment a-b crosses c-d. The ends of a-b are trimmed by 1.5 px. */
|
|
50
59
|
export declare function crosses(a: Pt, b: Pt, c: Pt, d: Pt): boolean;
|
|
51
60
|
export {};
|