flowfig 0.8.4 → 0.10.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/dist/cli.js CHANGED
@@ -18,24 +18,32 @@ 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';
21
+ import { runAtlas } from './atlas.js';
22
+ import { runFromMermaid } from './mermaid.js';
21
23
  import { openedLine, openSvg, runOpen } from './open.js';
22
24
  import { pageHtml } from './page.js';
23
25
  import { decodePng } from './png.js';
24
- import { diff, formatDiff } from './diff.js';
26
+ import { diff, formatDiff, mergeFigures } from './diff.js';
25
27
  import { loadSpec, reportLines, sortFindings, specOf, summaryLines, svgWithSpec, verifyLines } from './load.js';
26
28
  import { check, render, toSvg } from './svg.js';
27
29
  import { checkRendered } from './check.js';
28
- import { coverageLine, links, unsureLines, verifyReport } from './verify.js';
30
+ import { coverageLine, links, parseSource, unsureLines, verifyReport } from './verify.js';
31
+ import { trace, traceLines } from './trace.js';
32
+ import { coverageLines, coverageReport } from './coverage.js';
29
33
  const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] render a figure; a spec on stdin with -
30
34
  flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
31
35
  flowfig --spec figure.svg print the spec the SVG carries
32
36
  flowfig verify <input>... [--root <dir>] [--json] [--strict] check the code links of one or more figures
33
- flowfig diff <old> <new> [--json|--md] list the spec changes between two figures
37
+ flowfig diff <old> <new> [--json|--md] [--svg <out.svg>] list the spec changes between two figures, or draw them in one SVG
38
+ flowfig trace <file#symbol> [--depth <n>] [--max <n>] [--root <dir>] [--json] list the calls under a symbol, each one checked
39
+ flowfig coverage [--figures <glob>] [--entries <glob>] [--root <dir>] [--strict] [--json] list failing and stale figures, and code with no figure
34
40
  flowfig docs [topic] print the guide, or one topic (Markdown)
35
41
  flowfig mcp serve check, render, verify, diff and docs over MCP (stdio)
36
- flowfig init [dir] [--agents <ids>] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
42
+ flowfig init [dir] [--agents <ids> | --all-agents | --list-agents] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
37
43
  flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] [--open] ask Claude Code for a figure, then check it
44
+ flowfig atlas [--figures <glob>] [--root <dir>] [--out <dir>] [--open] write one linked web page for each figure, and an index
38
45
  flowfig open <figure.svg> [--html <path>] show the figure in the default browser
46
+ flowfig from-mermaid <file.mmd|file.md|-> [--out <dir>] convert Mermaid diagrams to specs
39
47
  flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
40
48
  flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only)`;
41
49
  function usage(message) {
@@ -54,6 +62,10 @@ const value = (name) => {
54
62
  const n = Number(args.splice(i, 2)[1] || NaN);
55
63
  return n > 0 && Number.isFinite(n) ? n : usage(`${name} needs a positive number`);
56
64
  };
65
+ const option = (name) => {
66
+ const i = args.indexOf(name);
67
+ return i === -1 ? undefined : (args.splice(i, 2)[1] ?? usage(`${name} needs a value`));
68
+ };
57
69
  if (!args[0] || ['help', '--help', '-h'].includes(args[0])) {
58
70
  if (args[0])
59
71
  console.log(USAGE);
@@ -78,8 +90,12 @@ if (args[0] === 'init')
78
90
  process.exit(await runInit(args.slice(1)));
79
91
  if (args[0] === 'draw')
80
92
  process.exit(await runDraw(args.slice(1)));
93
+ if (args[0] === 'atlas')
94
+ process.exit(await runAtlas(args.slice(1)));
81
95
  if (args[0] === 'open')
82
96
  process.exit(await runOpen(args.slice(1)));
97
+ if (args[0] === 'from-mermaid')
98
+ process.exit(runFromMermaid(args.slice(1)));
83
99
  if (args[0] === '--spec') {
84
100
  if (!args[1])
85
101
  usage('--spec needs the path of an SVG');
@@ -148,15 +164,62 @@ if (args[0] === 'verify') {
148
164
  }
149
165
  process.exit(errors ? 1 : 0);
150
166
  }
167
+ if (args[0] === 'trace') {
168
+ args.shift();
169
+ const json = flag('--json');
170
+ const depth = value('--depth') ?? 2, max = value('--max') ?? 40;
171
+ if (!Number.isInteger(depth) || !Number.isInteger(max))
172
+ usage('--depth and --max need a whole number');
173
+ const root = resolve(option('--root') ?? '.');
174
+ const unknown = args.find((a) => a.startsWith('-'));
175
+ if (unknown)
176
+ usage(`unknown flag ${unknown}`);
177
+ if (args.length !== 1 || !parseSource(args[0])?.symbol)
178
+ usage('usage: flowfig trace <file#symbol> [--depth <n>] [--max <n>] [--root <dir>] [--json]');
179
+ try {
180
+ const r = trace(args[0], { root, depth, max });
181
+ console.log(json ? JSON.stringify(r, null, 2) : traceLines(r, depth, max).join('\n'));
182
+ }
183
+ catch (e) {
184
+ console.error(`trace: ${e.message}`);
185
+ process.exit(1);
186
+ }
187
+ process.exit(0);
188
+ }
189
+ if (args[0] === 'coverage') {
190
+ args.shift();
191
+ const json = flag('--json'), strict = flag('--strict');
192
+ const root = resolve(option('--root') ?? '.');
193
+ const figures = option('--figures'), entries = option('--entries');
194
+ const unknown = args.find((a) => a.startsWith('-'));
195
+ if (unknown)
196
+ usage(`unknown flag ${unknown}`);
197
+ if (args.length)
198
+ usage('usage: flowfig coverage [--figures <glob>] [--entries <glob>] [--root <dir>] [--strict] [--json]');
199
+ if (!existsSync(root))
200
+ usage(`${root}: the folder does not exist`);
201
+ const r = coverageReport({ root, figures, entries });
202
+ console.log(json ? JSON.stringify(r, null, 2) : coverageLines(r).join('\n'));
203
+ process.exit(strict && r.figures.some((f) => f.state === 'fail' || f.state === 'stale') ? 1 : 0);
204
+ }
151
205
  if (args[0] === 'diff') {
152
206
  args.shift();
153
207
  const json = flag('--json'), md = flag('--md');
208
+ const svgOut = option('--svg');
154
209
  const unknown = args.find((a) => a.startsWith('-') && a !== '-');
155
210
  if (unknown)
156
211
  usage(`unknown flag ${unknown}`);
157
212
  if (args.length !== 2)
158
- usage('diff needs two figures: flowfig diff <old> <new> [--json|--md]');
159
- const changes = diff(await load(args[0]), await load(args[1]));
213
+ usage('diff needs two figures: flowfig diff <old> <new> [--json|--md] [--svg <out.svg>]');
214
+ const [before, after] = [await load(args[0]), await load(args[1])];
215
+ const changes = diff(before, after);
216
+ if (svgOut) {
217
+ const { figure, marks } = mergeFigures(before, after);
218
+ const { svg, scene } = render(figure, { marks });
219
+ for (const f of sortFindings(checkRendered(figure, {}, scene), false))
220
+ console.error(`${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.message}`);
221
+ writeFileSync(svgOut, svg);
222
+ }
160
223
  console.log(json ? JSON.stringify(changes, null, 2) : formatDiff(changes, md ? 'md' : 'text'));
161
224
  process.exit(0);
162
225
  }
package/dist/code.d.ts CHANGED
@@ -16,6 +16,8 @@ type Extent = [number, number];
16
16
  export declare const esc: (s: string) => string;
17
17
  export declare function matchClose(code: string, open: number): number;
18
18
  export declare function pyBody(code: string, start: number): Extent;
19
+ export declare function depthAt(code: string, pos: number, lo: number): number;
20
+ export declare const definitions: (file: CodeFile, name: string, lo?: number, hi?: number) => number;
19
21
  export declare function outside(root: string, path: string): boolean;
20
22
  export declare function codeFile(root: string, path: string, read?: Read, cache?: Map<string, CodeFile | null>): CodeFile | null;
21
23
  export declare const locate: (file: CodeFile, symbol: string) => {
package/dist/code.js CHANGED
@@ -371,7 +371,7 @@ function defPatterns(lang, name, inContainer) {
371
371
  }
372
372
  }
373
373
  const KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'await', 'typeof', 'else', 'do', 'throw']);
374
- function depthAt(code, pos, lo) {
374
+ export function depthAt(code, pos, lo) {
375
375
  let d = 0;
376
376
  for (let i = lo; i < pos; i++) {
377
377
  if (code[i] === '{')
@@ -400,12 +400,12 @@ function pyMemberIndent(code, lo, hi) {
400
400
  }
401
401
  return min;
402
402
  }
403
- function findIn(code, lang, name, lo, hi, member, receiver) {
403
+ function hitsIn(code, lang, name, lo, hi, member, receiver) {
404
+ const hits = [];
404
405
  if (KEYWORDS.has(name) && !/^(rs|py|go)$/.test(lang))
405
- return null;
406
+ return hits;
406
407
  const inContainer = member != null;
407
408
  const memberIndent = inContainer && lang === 'py' ? pyMemberIndent(code, lo, hi) : -1;
408
- const hits = [];
409
409
  const pats = defPatterns(lang, name, inContainer);
410
410
  for (const re of pats) {
411
411
  re.lastIndex = lo;
@@ -465,6 +465,11 @@ function findIn(code, lang, name, lo, hi, member, receiver) {
465
465
  hits.push({ start, end: ext[1], depth });
466
466
  }
467
467
  }
468
+ return hits;
469
+ }
470
+ export const definitions = (file, name, lo = 0, hi = file.code.length) => new Set(hitsIn(file.code, file.lang, name, lo, hi, null).map((h) => h.start)).size;
471
+ function findIn(code, lang, name, lo, hi, member, receiver) {
472
+ const hits = hitsIn(code, lang, name, lo, hi, member, receiver);
468
473
  if (!hits.length)
469
474
  return null;
470
475
  hits.sort((a, b) => a.depth - b.depth || a.start - b.start);
@@ -0,0 +1,27 @@
1
+ export type FigureState = 'fail' | 'stale' | 'ok' | 'none';
2
+ export type FigureHealth = {
3
+ figure: string;
4
+ state: FigureState;
5
+ detail: string;
6
+ };
7
+ export type CoverageReport = {
8
+ figures: FigureHealth[];
9
+ git: boolean;
10
+ entries?: {
11
+ glob: string;
12
+ total: number;
13
+ uncovered: string[];
14
+ };
15
+ folders?: {
16
+ folder: string;
17
+ covered: number;
18
+ total: number;
19
+ }[];
20
+ };
21
+ export type CoverageOptions = {
22
+ root?: string;
23
+ figures?: string;
24
+ entries?: string;
25
+ };
26
+ export declare function coverageReport({ root, figures, entries }?: CoverageOptions): CoverageReport;
27
+ export declare function coverageLines(r: CoverageReport): string[];
@@ -0,0 +1,116 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { posix, resolve } from 'node:path';
4
+ import { langOf, readFile } from './code.js';
5
+ import { loadSpec, specOf } from './load.js';
6
+ import { detailFindings, links, SPEC_MARK, verifyReport } from './verify.js';
7
+ const globRe = (glob) => new RegExp(`^${glob
8
+ .replace(/^\.\//, '')
9
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
10
+ .replace(/\*\*\/|\*\*|\*|\?/g, (t) => (t === '**/' ? '(?:.*/)?' : t === '**' ? '.*' : t === '*' ? '[^/]*' : '[^/]'))}$`);
11
+ const isTest = (p) => /(^|\/)(__tests__|tests|src\/test)\//.test(p) || /\.(test|spec)\.[^/]+$|(^|\/)test_[^/]*\.py$|_test\.(py|go)$|Tests?\.(java|cs)$/.test(p);
12
+ const day = (t) => (t === Infinity ? 'now' : new Date(t * 1000).toISOString().slice(0, 10));
13
+ const count = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
14
+ function gitTimes(root) {
15
+ const git = (...a) => execFileSync('git', a, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
16
+ try {
17
+ const prefix = git('rev-parse', '--show-prefix').trim();
18
+ const dirty = new Set(git('status', '--porcelain', '-z', '--no-renames', '--untracked-files=all')
19
+ .split('\0')
20
+ .map((l) => l.slice(3))
21
+ .filter((p) => p && p.startsWith(prefix))
22
+ .map((p) => p.slice(prefix.length)));
23
+ const memo = new Map();
24
+ return (path) => {
25
+ if (dirty.has(path))
26
+ return Infinity;
27
+ if (!memo.has(path))
28
+ memo.set(path, Number(git('log', '-1', '--format=%ct', '--', path).trim()) || 0);
29
+ return memo.get(path);
30
+ };
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function coverageReport({ root = process.cwd(), figures = '**/*.svg', entries } = {}) {
37
+ const all = readFile.files(root).filter((p) => !p.split('/').includes('dist'));
38
+ const want = globRe(figures);
39
+ const time = gitTimes(root);
40
+ const cache = new Map();
41
+ const covered = new Set();
42
+ const out = [];
43
+ for (const figure of all.filter((p) => p.endsWith('.svg') && want.test(p))) {
44
+ const text = readFileSync(resolve(root, figure), 'utf8');
45
+ if (!text.includes(SPEC_MARK))
46
+ continue;
47
+ let fig;
48
+ try {
49
+ fig = loadSpec(JSON.parse(specOf(text, figure)), figure);
50
+ }
51
+ catch (e) {
52
+ out.push({ figure, state: 'fail', detail: `bad spec: ${e.message}` });
53
+ continue;
54
+ }
55
+ const paths = [...new Set(links(fig).map((l) => posix.normalize(l.path)))];
56
+ paths.forEach((p) => covered.add(p));
57
+ const lost = detailFindings(fig, root).length;
58
+ const lostText = lost ? `; ${lost} missing-detail` : '';
59
+ if (!paths.length) {
60
+ out.push({ figure, state: 'none', detail: `no source links${lostText}` });
61
+ continue;
62
+ }
63
+ const { findings, coverage } = verifyReport(fig, { root, cache });
64
+ const errors = findings.filter((f) => f.severity === 'error');
65
+ if (errors.length) {
66
+ out.push({
67
+ figure,
68
+ state: 'fail',
69
+ detail: `${count(errors.length, 'error')}: ${[...new Set(errors.map((f) => f.rule))].join(', ')}`,
70
+ });
71
+ continue;
72
+ }
73
+ const mine = time?.(figure) || Infinity;
74
+ const newer = time && paths.find((p) => time(p) > mine);
75
+ out.push(newer
76
+ ? { figure, state: 'stale', detail: `${newer} changed ${day(time(newer))}, figure ${day(mine)}` }
77
+ : { figure, state: 'ok', detail: `${coverage.boxesDefined} of ${coverage.boxes} boxes defined${lostText}` });
78
+ }
79
+ const code = all.filter((p) => langOf(p) != null && !isTest(p));
80
+ const report = { figures: out, git: time != null };
81
+ if (entries) {
82
+ const re = globRe(entries);
83
+ const named = entries.replace(/^\.\//, '');
84
+ const matched = all.filter((p) => langOf(p) != null && re.test(p) && (p === named || !isTest(p)));
85
+ report.entries = { glob: entries, total: matched.length, uncovered: matched.filter((p) => !covered.has(p)) };
86
+ }
87
+ else {
88
+ const by = new Map();
89
+ for (const p of code) {
90
+ const folder = p.includes('/') ? p.slice(0, p.indexOf('/')) : '.';
91
+ const row = by.get(folder) ?? { folder, covered: 0, total: 0 };
92
+ row.total++;
93
+ if (covered.has(p))
94
+ row.covered++;
95
+ by.set(folder, row);
96
+ }
97
+ report.folders = [...by.values()];
98
+ }
99
+ return report;
100
+ }
101
+ export function coverageLines(r) {
102
+ const width = Math.max(0, ...r.figures.map((f) => f.figure.length));
103
+ const tally = (s) => r.figures.filter((f) => f.state === s).length;
104
+ return [
105
+ ...(r.git ? [] : ['no git: stale is not checked']),
106
+ ...r.figures.map((f) => `${f.figure.padEnd(width + 4)}${f.state.padEnd(8)}${f.detail}`),
107
+ ...(r.entries
108
+ ? [
109
+ `uncovered ${r.entries.glob}: ${r.entries.uncovered.length} of ${r.entries.total} files have no figure`,
110
+ ...r.entries.uncovered.map((p) => ` ${p}`),
111
+ ]
112
+ : []),
113
+ ...(r.folders ?? []).map((f) => `code ${f.folder}: ${f.covered} of ${f.total} files have a figure`),
114
+ `summary: ${count(r.figures.length, 'figure')}: ${tally('ok')} ok, ${tally('stale')} stale, ${tally('fail')} fail, ${tally('none')} none`,
115
+ ];
116
+ }
package/dist/diff.d.ts CHANGED
@@ -7,3 +7,13 @@ export type Change = {
7
7
  };
8
8
  export declare function diff(before: FlowProps, after: FlowProps): Change[];
9
9
  export declare function formatDiff(changes: Change[], as: 'text' | 'md'): string;
10
+ export type DiffMark = 'added' | 'removed' | 'changed';
11
+ export type DiffMarks = {
12
+ boxes: Record<string, DiffMark>;
13
+ edges: Record<string, DiffMark>;
14
+ steps: number;
15
+ };
16
+ export declare function mergeFigures(before: FlowProps, after: FlowProps): {
17
+ figure: FlowProps;
18
+ marks: DiffMarks;
19
+ };
package/dist/diff.js CHANGED
@@ -1,4 +1,4 @@
1
- import { edgeId, isGroup, str, toBeat } from './model.js';
1
+ import { edgeId, isGroup, laneColumns, nodes, str, toBeat } from './model.js';
2
2
  const show = (v) => (v == null ? '(none)' : `"${str(v)}"`);
3
3
  const fields = (a, b, names) => names.filter((k) => str(a[k]) !== str(b[k])).map((k) => `${String(k)} ${show(a[k])} -> ${show(b[k])}`);
4
4
  function compare(kind, before, after, fieldsOf) {
@@ -29,13 +29,13 @@ const messages = (fig) => {
29
29
  })));
30
30
  return new Map(ids.map((id) => [id, id]));
31
31
  };
32
+ const boxes = (f) => {
33
+ const m = new Map();
34
+ 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 })));
35
+ walk(f.layout);
36
+ return m;
37
+ };
32
38
  export function diff(before, after) {
33
- const boxes = (f) => {
34
- const m = new Map();
35
- 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 })));
36
- walk(f.layout);
37
- return m;
38
- };
39
39
  const edges = (f) => new Map(f.edges.map((e) => [edgeId(e), e]));
40
40
  const steps = (f) => new Map((f.steps ?? []).map((s) => [str(s.label), s]));
41
41
  const out = [
@@ -60,3 +60,133 @@ export function formatDiff(changes, as) {
60
60
  })
61
61
  .join('\n');
62
62
  }
63
+ const key = (g) => g.id ?? (g.label != null ? str(g.label) : undefined);
64
+ const cloneGroup = (g) => ({ ...g, children: g.children.map((c) => (isGroup(c) ? cloneGroup(c) : c)) });
65
+ const ids = (g) => (isGroup(g) ? nodes(g).map((n) => n.id) : [g.id]);
66
+ function find(root, hit) {
67
+ for (const [i, c] of root.children.entries()) {
68
+ if (hit(c))
69
+ return { parent: root, i };
70
+ const inner = isGroup(c) ? find(c, hit) : undefined;
71
+ if (inner)
72
+ return inner;
73
+ }
74
+ }
75
+ export function mergeFigures(before, after) {
76
+ const was = boxes(before);
77
+ const now = boxes(after);
78
+ const gone = (c) => ids(c).every((id) => !now.has(id));
79
+ const layout = cloneGroup(after.layout);
80
+ const placed = new Set();
81
+ const locate = (c) => find(layout, (x) => x === c || (isGroup(c) ? isGroup(x) && key(c) != null && key(x) === key(c) : !isGroup(x) && x.id === c.id));
82
+ const put = (item, frames, sibs, at) => {
83
+ const sides = [
84
+ ...sibs
85
+ .slice(0, at)
86
+ .reverse()
87
+ .map((s) => [s, 1]),
88
+ ...sibs.slice(at + 1).map((s) => [s, 0]),
89
+ ];
90
+ const next = after.layout.auto
91
+ ? undefined
92
+ : sides
93
+ .filter(([s]) => placed.has(s) || !gone(s))
94
+ .map(([s, off]) => ({ spot: locate(s), off }))
95
+ .find((x) => x.spot);
96
+ if (next)
97
+ next.spot.parent.children.splice(next.spot.i + next.off, 0, item);
98
+ else {
99
+ const frame = frames.map(locate).find(Boolean);
100
+ (frame ? frame.parent.children[frame.i] : layout).children.push(item);
101
+ }
102
+ placed.add(item);
103
+ };
104
+ const walk = (g, frames) => g.children.forEach((c, i, sibs) => {
105
+ if (isGroup(c) && !(c.label != null && gone(c) && !locate(c)))
106
+ walk(c, [c, ...frames]);
107
+ else if (gone(c))
108
+ put(c, frames, sibs, i);
109
+ });
110
+ walk(before.layout, [before.layout]);
111
+ const marks = { boxes: {}, edges: {}, steps: diff(before, after).filter((c) => c.kind !== 'box' && c.kind !== 'edge').length };
112
+ for (const [id, n] of now) {
113
+ const old = was.get(id);
114
+ if (!old)
115
+ marks.boxes[id] = 'added';
116
+ else if (fields(old, n, ['label', 'sub', 'shape', 'source', 'group']).length)
117
+ marks.boxes[id] = 'changed';
118
+ }
119
+ for (const id of was.keys())
120
+ if (!now.has(id))
121
+ marks.boxes[id] = 'removed';
122
+ const colAt = new Map();
123
+ if (after.lanes) {
124
+ const colsAfter = laneColumns(after);
125
+ const colsBefore = laneColumns(before);
126
+ const kept = [...now.keys()].filter((id) => was.has(id));
127
+ const slots = new Map();
128
+ for (const id of was.keys()) {
129
+ if (now.has(id))
130
+ continue;
131
+ const old = colsBefore.get(id);
132
+ const prev = kept
133
+ .filter((k) => colsBefore.get(k) < old)
134
+ .sort((a, b) => colsBefore.get(b) - colsBefore.get(a) || colsAfter.get(b) - colsAfter.get(a));
135
+ slots.set(id, { p: prev.length ? colsAfter.get(prev[0]) : -1, old });
136
+ }
137
+ const groups = [...new Map([...slots.values()].map((v) => [`${v.p}:${v.old}`, v])).values()].sort((a, b) => a.p - b.p || a.old - b.old);
138
+ for (const id of now.keys())
139
+ colAt.set(id, colsAfter.get(id) + groups.filter((g) => g.p < colsAfter.get(id)).length);
140
+ for (const [id, v] of slots) {
141
+ const i = groups.findIndex((g) => g.p === v.p && g.old === v.old);
142
+ colAt.set(id, v.p + 1 + groups.filter((g) => g.p < v.p).length + groups.slice(0, i).filter((g) => g.p === v.p).length);
143
+ }
144
+ }
145
+ const paint = (g) => ({
146
+ ...g,
147
+ children: g.children.map((c) => {
148
+ if (isGroup(c))
149
+ return paint(c);
150
+ const n = { ...c };
151
+ delete n.tone;
152
+ if (after.lanes)
153
+ n.at = colAt.get(c.id);
154
+ const old = was.get(c.id);
155
+ if (marks.boxes[c.id] === 'changed' && old) {
156
+ if (str(old.label) !== str(c.label))
157
+ n.sub = `was "${str(old.label)}"`;
158
+ else if (old.group !== now.get(c.id).group)
159
+ n.sub = old.group != null ? `was in "${old.group}"` : 'moved';
160
+ }
161
+ return n;
162
+ }),
163
+ });
164
+ const oldEdges = new Map(before.edges.map((e) => [edgeId(e), e]));
165
+ const newEdges = new Map(after.edges.map((e) => [edgeId(e), e]));
166
+ const same = (a, b) => b != null && a.from === b.from && a.to === b.to;
167
+ const edges = after.edges.map((e) => {
168
+ const id = edgeId(e);
169
+ const old = oldEdges.get(id);
170
+ if (!same(e, old))
171
+ marks.edges[id] = 'added';
172
+ else if (fields(old, e, ['label', 'source']).length) {
173
+ marks.edges[id] = 'changed';
174
+ if (str(old.label) !== str(e.label) && old.label != null)
175
+ return { ...e, label: e.label != null ? `${str(e.label)} (was ${str(old.label)})` : `(was ${str(old.label)})` };
176
+ }
177
+ return e;
178
+ });
179
+ for (const [id, e] of oldEdges) {
180
+ if (same(e, newEdges.get(id)))
181
+ continue;
182
+ const mid = newEdges.has(id) ? `${id} (old)` : id;
183
+ marks.edges[mid] = 'removed';
184
+ const gone = { ...e, id: mid };
185
+ delete gone.around;
186
+ edges.push(gone);
187
+ }
188
+ const figure = { ...after, layout: paint(layout), edges };
189
+ delete figure.steps;
190
+ delete figure.rail;
191
+ return { figure, marks };
192
+ }
package/dist/edges.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { type CodeFile, type Lang, type Read } from './code.ts';
2
2
  export type EdgeResult = 'found' | 'not-found' | 'unsure' | 'not-checked';
3
3
  export declare const EDGE_LANGS: ReadonlySet<Lang>;
4
+ export declare function params(body: string, from: string | undefined): string[];
5
+ export declare function paramName(p: string, lang: Lang): string | undefined;
6
+ export declare function shadows(body: string, name: string, lang: Lang, fromName: string | undefined, whole: boolean): boolean;
4
7
  export declare function edgeResult(root: string, caller: string, callee: string | undefined, via: string | undefined, read?: Read, cache?: Map<string, CodeFile | null>): {
5
8
  result: EdgeResult;
6
9
  reason: string;
package/dist/edges.js CHANGED
@@ -90,7 +90,7 @@ function typedName(body, n) {
90
90
  }
91
91
  return false;
92
92
  }
93
- function params(body, from) {
93
+ export function params(body, from) {
94
94
  const open = body.indexOf('(', from ? Math.max(0, body.indexOf(from)) : 0);
95
95
  if (open < 0)
96
96
  return [];
@@ -110,7 +110,7 @@ function params(body, from) {
110
110
  }
111
111
  return [...parts, text.slice(last)];
112
112
  }
113
- function paramName(p, lang) {
113
+ export function paramName(p, lang) {
114
114
  const tokens = (p.split('=')[0].match(/[\w$]+/g) ?? []).filter((t) => !MODIFIERS.has(t));
115
115
  return lang === 'java' || lang === 'cs' ? tokens.at(-1) : tokens[0];
116
116
  }
@@ -123,7 +123,7 @@ function paramCall(body, lang, fromName) {
123
123
  .map((p) => paramName(p, lang))
124
124
  .find((n) => n && new RegExp(`(?<![\\w$.])${esc(n)}\\s*\\(`).test(rest));
125
125
  }
126
- function shadows(body, name, lang, fromName, whole) {
126
+ export function shadows(body, name, lang, fromName, whole) {
127
127
  const n = esc(name);
128
128
  if (!whole)
129
129
  for (const p of params(body, fromName))
@@ -30,6 +30,7 @@ export type Routed = {
30
30
  room?: number;
31
31
  /** The px an elbow detour goes outside the boxes, for each side. */
32
32
  bleed?: Partial<Record<Around, number>>;
33
+ ends?: [string, string];
33
34
  };
34
35
  /** A rect that a route keeps clear of. `box` marks a box. */
35
36
  export type Avoid = Rect & {
@@ -37,6 +38,7 @@ export type Avoid = Rect & {
37
38
  };
38
39
  export declare const avoidOf: (boxes: Rect[], extra: Rect[], lanes: boolean) => Avoid[];
39
40
  type Around = 'above' | 'below' | 'left' | 'right';
41
+ export declare const samples: (curve: [Pt, Pt, Pt, Pt]) => Pt[];
40
42
  export declare const hit: (curve: [Pt, Pt, Pt, Pt], boxes: Rect[], m: number) => Rect[];
41
43
  /** Routes edges between measured boxes as curved SVG paths. `elbow` draws right-angle paths; `stub` splits a cross-lane edge in two. */
42
44
  export declare function route(edges: {
@@ -46,6 +48,7 @@ export declare function route(edges: {
46
48
  around?: Around;
47
49
  sides?: [Side, Side];
48
50
  elbow?: boolean;
51
+ drop?: boolean;
49
52
  stub?: number[];
50
53
  bands?: [Rect | undefined, Rect | undefined];
51
54
  labelW?: number;
@@ -53,6 +56,7 @@ export declare function route(edges: {
53
56
  bands: Rect[];
54
57
  boxes: Rect[];
55
58
  }, pad?: number): Routed[];
59
+ export declare const markSide: (id: string, mark: 'start' | 'end', routed: Routed[]) => Side;
56
60
  export declare const arcRoom: (routed: Routed[], way: Around) => number;
57
61
  export declare function labelAlong(r: Routed, w: number, boxes: Rect[], placed: Rect[], fig?: Rect, lines?: Pt[]): Pt;
58
62
  /** True if segment a-b crosses c-d. The ends of a-b are trimmed by 1.5 px. */