flowfig 0.8.0 → 0.8.2

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
@@ -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.0
68
+ - uses: iamalvisng/flowfig@v0.8.2
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 22 rules. It finds ids that point nowhere, text wider than its box,
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, 22 rules in `flowfig check` | Syntax errors only | No |
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": "SELECT" }
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
- first.svg — 8.8 kB
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
  ![A browser loads a user through an API from a database](docs/first.svg)
@@ -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 | Default |
287
- | ----------- | --------------------------------------------------------------- | --------------------------------------------------- |
288
- | `children` | The boxes and groups in the group, in order. | required |
289
- | `id` | The name that an edge can use to reach the whole group. | none |
290
- | `label` | The title of the frame. Only a group with a label has a frame. | none |
291
- | `direction` | `"row"` puts the children side by side. `"column"` stacks them. | `"row"` |
292
- | `gap` | The space between the children in px. | column: 28; row: fits the widest label, at least 56 |
293
- | `align` | `"start"`, `"center"` or `"end"`, across the direction. | `"center"` (a column stretches) |
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 22 rules: 11 errors and 11 warnings.
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.0
481
+ - uses: iamalvisng/flowfig@v0.8.2
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 full guide as Markdown.
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 print the guide (Markdown)
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
- process.stdout.write(GUIDE);
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
- report(console.error, process.stderr.isTTY);
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) => new Map((fig.steps ?? [])
23
- .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}]` : ''}`)))
24
- .map((id) => [id, id]));
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). Then run \`npx flowfig verify ${o.out}\` the same way. Run every command from this folder, ${o.cwd}.`;
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
- const tokens = (p.split('=')[0].match(/[\w$]+/g) ?? []).filter((t) => !MODIFIERS.has(t));
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
  }
@@ -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
- type Around = 'above' | 'below';
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 {};