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/README.md +375 -41
- package/dist/atlas.d.ts +6 -0
- package/dist/atlas.js +122 -0
- package/dist/auto.d.ts +3 -0
- package/dist/auto.js +302 -0
- package/dist/browser.js +3 -1
- package/dist/check.js +54 -17
- package/dist/cli.js +69 -6
- package/dist/code.d.ts +2 -0
- package/dist/code.js +9 -4
- package/dist/coverage.d.ts +27 -0
- package/dist/coverage.js +116 -0
- package/dist/diff.d.ts +10 -0
- package/dist/diff.js +137 -7
- package/dist/edges.d.ts +3 -0
- package/dist/edges.js +3 -3
- package/dist/geometry.d.ts +4 -0
- package/dist/geometry.js +45 -17
- package/dist/imports.d.ts +1 -0
- package/dist/imports.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +161 -81
- package/dist/load.js +6 -5
- package/dist/mermaid.d.ts +19 -0
- package/dist/mermaid.js +617 -0
- package/dist/model.d.ts +15 -3
- package/dist/model.js +56 -1
- package/dist/open.d.ts +1 -0
- package/dist/open.js +11 -8
- package/dist/page.d.ts +2 -1
- package/dist/page.js +5 -4
- package/dist/svg.d.ts +7 -2
- package/dist/svg.js +73 -22
- package/dist/trace.d.ts +30 -0
- package/dist/trace.js +278 -0
- package/dist/verify.d.ts +5 -2
- package/dist/verify.js +39 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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[];
|
package/dist/coverage.js
ADDED
|
@@ -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))
|
package/dist/geometry.d.ts
CHANGED
|
@@ -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. */
|