flowfig 0.8.3 → 0.9.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 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.3
68
+ - uses: iamalvisng/flowfig@v0.9.0
69
69
  with:
70
70
  figures: 'docs/**/*.svg' # default **/*.svg
71
71
  ```
@@ -183,6 +183,8 @@ The options are in [Open and share a figure](#open-and-share-a-figure).
183
183
  - [Check a figure](#check-a-figure)
184
184
  - [Open and share a figure](#open-and-share-a-figure)
185
185
  - [Verify in CI](#verify-in-ci)
186
+ - [Trace the calls of a function](#trace-the-calls-of-a-function)
187
+ - [Find stale figures](#find-stale-figures)
186
188
  - [MCP server](#mcp-server)
187
189
  - [Use in React](#use-in-react)
188
190
  - [Use from Node](#use-from-node)
@@ -478,11 +480,97 @@ jobs:
478
480
  - uses: actions/setup-node@v4
479
481
  with:
480
482
  node-version: 22
481
- - uses: iamalvisng/flowfig@v0.8.3
483
+ - uses: iamalvisng/flowfig@v0.9.0
482
484
  with:
483
485
  figures: 'docs/**/*.svg' # default **/*.svg
484
486
  ```
485
487
 
488
+ ## Trace the calls of a function
489
+
490
+ `flowfig trace` lists the calls that one function makes, and the calls that those calls make. Ask your coding agent to run it
491
+ before it writes a figure. Then the agent reads only the lines that the trace names. Each edge passes the same check as
492
+ `verify`.
493
+
494
+ ```console
495
+ $ npx flowfig trace src/verify.ts#verify
496
+ src/verify.ts#verify -> src/verify.ts#verifyReport src/verify.ts:104
497
+ src/verify.ts#verifyReport -> src/verify.ts#emptyCoverage src/verify.ts:48
498
+ src/verify.ts#verifyReport -> src/source.ts#links src/verify.ts:49
499
+ src/verify.ts#verifyReport -> src/code.ts#outside src/verify.ts:58
500
+ src/verify.ts#verifyReport -> src/code.ts#codeFile src/verify.ts:65
501
+ src/verify.ts#verifyReport -> src/code.ts#isDefined src/verify.ts:67
502
+ src/verify.ts#verifyReport -> src/verify.ts#escape src/verify.ts:69
503
+ src/verify.ts#verifyReport -> src/verify.ts#hasHeading src/verify.ts:70
504
+ src/verify.ts#verifyReport -> src/model.ts#nodes src/verify.ts:79
505
+ src/verify.ts#verifyReport -> src/edges.ts#edgeResult src/verify.ts:81
506
+ src/verify.ts#verifyReport -> src/model.ts#edgeId src/verify.ts:91
507
+ src/verify.ts#verifyReport -> src/model.ts#toBeat src/verify.ts:95
508
+ stop depth 2: 11 symbols not followed
509
+ summary: 13 symbols, 12 found, 0 unsure, 0 open, 4 calls outside the repo, 0.1 s
510
+ ```
511
+
512
+ - An edge line gives the caller, the callee, and the file and line of the call.
513
+ - `unsure`: trace cannot name the target. An example is a call of a parameter.
514
+ - `open`: the target is a name in a string, for example a route table entry. Read that line yourself.
515
+ - `stop`: the trace reached `--depth` or `--max`. The line gives the number of functions that it did not follow.
516
+ - A call into a package or the standard library gives no line. The summary line counts these calls.
517
+
518
+ A call that trace cannot see gives no line, for example a call through an interface with no known type.
519
+ trace reads code as text. In rare cases, two names that look the same can give a wrong edge. `flowfig verify` and your review of the figure still check each edge.
520
+
521
+ | Option | Meaning | Default |
522
+ | -------------- | -------------------------------------------------- | ------------------ |
523
+ | `--depth <n>` | The number of call levels to follow. | 2 |
524
+ | `--max <n>` | The largest number of edges. Then the trace stops. | 40 |
525
+ | `--root <dir>` | The repo root. The file path is relative to it. | the current folder |
526
+ | `--json` | Print one JSON object in place of the lines. | off |
527
+
528
+ The exit code is 1 if the file or the function is not found. It is also 1 if the language is not TypeScript,
529
+ JavaScript, Python, Go, Java, C# or Rust.
530
+
531
+ ## Find stale figures
532
+
533
+ `flowfig coverage` reads each figure in the repo and gives it one state. It also shows the code that has no figure. Run
534
+ it each week, or in CI.
535
+
536
+ ```console
537
+ $ npx flowfig coverage --entries 'src/geometry.ts'
538
+ docs/cached-request.svg ok 3 of 3 boxes defined
539
+ docs/checkout-rail-only.svg ok 4 of 4 boxes defined
540
+ docs/checkout.svg ok 4 of 4 boxes defined
541
+ docs/first.svg none no source links
542
+ docs/hero.svg none no source links
543
+ docs/order-status.svg none no source links
544
+ docs/refund-process.svg ok 6 of 6 boxes defined
545
+ docs/returns-process.svg ok 8 of 8 boxes defined
546
+ docs/roadmap.svg ok 8 of 8 boxes defined
547
+ uncovered src/geometry.ts: 1 of 1 files have no figure
548
+ src/geometry.ts
549
+ summary: 9 figures: 6 ok, 0 stale, 0 fail, 3 none
550
+ ```
551
+
552
+ - `fail`: `verify` finds an error, for example a symbol that is gone.
553
+ - `stale`: `verify` finds no error, but a linked file changed in a commit after the last commit of the SVG. A linked file
554
+ with changes that are not committed counts as changed now.
555
+ - `ok`: `verify` finds no error, and no linked file changed after the SVG.
556
+ - `none`: the figure has no `source` link.
557
+
558
+ `coverage` reads the commit dates from git. If the folder is not in a git repo, `coverage` does not check `stale`. It
559
+ prints one line that says so.
560
+
561
+ A figure covers a code file if one of its links names that file. With `--entries`, `coverage` lists each matched code
562
+ file that no figure covers. With no `--entries`, it prints one count line for each top folder.
563
+
564
+ | Option | Meaning | Default |
565
+ | ------------------ | ------------------------------------------------------------- | ------------------ |
566
+ | `--figures <glob>` | The SVG files to read. Only an SVG with a figure spec counts. | `**/*.svg` |
567
+ | `--entries <glob>` | The code files that must have a figure. | none |
568
+ | `--root <dir>` | The repo root. | the current folder |
569
+ | `--strict` | Exit 1 if a figure is `fail` or `stale`. | off |
570
+ | `--json` | Print one JSON object in place of the lines. | off |
571
+
572
+ `coverage` skips `node_modules`, `dist` and each folder with a name that starts with a dot.
573
+
486
574
  ## MCP server
487
575
 
488
576
  `npx flowfig mcp` serves the tools `docs`, `check`, `render`, `verify` and `diff` over stdio, for an agent with no shell.
package/dist/cli.js CHANGED
@@ -25,12 +25,16 @@ import { diff, formatDiff } from './diff.js';
25
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
- import { coverageLine, links, unsureLines, verifyReport } from './verify.js';
28
+ import { coverageLine, links, parseSource, unsureLines, verifyReport } from './verify.js';
29
+ import { trace, traceLines } from './trace.js';
30
+ import { coverageLines, coverageReport } from './coverage.js';
29
31
  const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] render a figure; a spec on stdin with -
30
32
  flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
31
33
  flowfig --spec figure.svg print the spec the SVG carries
32
34
  flowfig verify <input>... [--root <dir>] [--json] [--strict] check the code links of one or more figures
33
35
  flowfig diff <old> <new> [--json|--md] list the spec changes between two figures
36
+ flowfig trace <file#symbol> [--depth <n>] [--max <n>] [--root <dir>] [--json] list the calls under a symbol, each one checked
37
+ flowfig coverage [--figures <glob>] [--entries <glob>] [--root <dir>] [--strict] [--json] list failing and stale figures, and code with no figure
34
38
  flowfig docs [topic] print the guide, or one topic (Markdown)
35
39
  flowfig mcp serve check, render, verify, diff and docs over MCP (stdio)
36
40
  flowfig init [dir] [--agents <ids>] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
@@ -54,6 +58,10 @@ const value = (name) => {
54
58
  const n = Number(args.splice(i, 2)[1] || NaN);
55
59
  return n > 0 && Number.isFinite(n) ? n : usage(`${name} needs a positive number`);
56
60
  };
61
+ const option = (name) => {
62
+ const i = args.indexOf(name);
63
+ return i === -1 ? undefined : (args.splice(i, 2)[1] ?? usage(`${name} needs a value`));
64
+ };
57
65
  if (!args[0] || ['help', '--help', '-h'].includes(args[0])) {
58
66
  if (args[0])
59
67
  console.log(USAGE);
@@ -148,6 +156,44 @@ if (args[0] === 'verify') {
148
156
  }
149
157
  process.exit(errors ? 1 : 0);
150
158
  }
159
+ if (args[0] === 'trace') {
160
+ args.shift();
161
+ const json = flag('--json');
162
+ const depth = value('--depth') ?? 2, max = value('--max') ?? 40;
163
+ if (!Number.isInteger(depth) || !Number.isInteger(max))
164
+ usage('--depth and --max need a whole number');
165
+ const root = resolve(option('--root') ?? '.');
166
+ const unknown = args.find((a) => a.startsWith('-'));
167
+ if (unknown)
168
+ usage(`unknown flag ${unknown}`);
169
+ if (args.length !== 1 || !parseSource(args[0])?.symbol)
170
+ usage('usage: flowfig trace <file#symbol> [--depth <n>] [--max <n>] [--root <dir>] [--json]');
171
+ try {
172
+ const r = trace(args[0], { root, depth, max });
173
+ console.log(json ? JSON.stringify(r, null, 2) : traceLines(r, depth, max).join('\n'));
174
+ }
175
+ catch (e) {
176
+ console.error(`trace: ${e.message}`);
177
+ process.exit(1);
178
+ }
179
+ process.exit(0);
180
+ }
181
+ if (args[0] === 'coverage') {
182
+ args.shift();
183
+ const json = flag('--json'), strict = flag('--strict');
184
+ const root = resolve(option('--root') ?? '.');
185
+ const figures = option('--figures'), entries = option('--entries');
186
+ const unknown = args.find((a) => a.startsWith('-'));
187
+ if (unknown)
188
+ usage(`unknown flag ${unknown}`);
189
+ if (args.length)
190
+ usage('usage: flowfig coverage [--figures <glob>] [--entries <glob>] [--root <dir>] [--strict] [--json]');
191
+ if (!existsSync(root))
192
+ usage(`${root}: the folder does not exist`);
193
+ const r = coverageReport({ root, figures, entries });
194
+ console.log(json ? JSON.stringify(r, null, 2) : coverageLines(r).join('\n'));
195
+ process.exit(strict && r.figures.some((f) => f.state === 'fail' || f.state === 'stale') ? 1 : 0);
196
+ }
151
197
  if (args[0] === 'diff') {
152
198
  args.shift();
153
199
  const json = flag('--json'), md = flag('--md');
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,113 @@
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 { links, verifyReport } from './verify.js';
7
+ const MARK = '<metadata id="figure-spec">';
8
+ const globRe = (glob) => new RegExp(`^${glob
9
+ .replace(/^\.\//, '')
10
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
11
+ .replace(/\*\*\/|\*\*|\*|\?/g, (t) => (t === '**/' ? '(?:.*/)?' : t === '**' ? '.*' : t === '*' ? '[^/]*' : '[^/]'))}$`);
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(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
+ if (!paths.length) {
58
+ out.push({ figure, state: 'none', detail: 'no source links' });
59
+ continue;
60
+ }
61
+ const { findings, coverage } = verifyReport(fig, { root, cache });
62
+ const errors = findings.filter((f) => f.severity === 'error');
63
+ if (errors.length) {
64
+ out.push({
65
+ figure,
66
+ state: 'fail',
67
+ detail: `${count(errors.length, 'error')}: ${[...new Set(errors.map((f) => f.rule))].join(', ')}`,
68
+ });
69
+ continue;
70
+ }
71
+ const mine = time?.(figure) || Infinity;
72
+ const newer = time && paths.find((p) => time(p) > mine);
73
+ out.push(newer
74
+ ? { figure, state: 'stale', detail: `${newer} changed ${day(time(newer))}, figure ${day(mine)}` }
75
+ : { figure, state: 'ok', detail: `${coverage.boxesDefined} of ${coverage.boxes} boxes defined` });
76
+ }
77
+ const code = all.filter((p) => langOf(p) != null);
78
+ const report = { figures: out, git: time != null };
79
+ if (entries) {
80
+ const re = globRe(entries);
81
+ const matched = code.filter((p) => re.test(p));
82
+ report.entries = { glob: entries, total: matched.length, uncovered: matched.filter((p) => !covered.has(p)) };
83
+ }
84
+ else {
85
+ const by = new Map();
86
+ for (const p of code) {
87
+ const folder = p.includes('/') ? p.slice(0, p.indexOf('/')) : '.';
88
+ const row = by.get(folder) ?? { folder, covered: 0, total: 0 };
89
+ row.total++;
90
+ if (covered.has(p))
91
+ row.covered++;
92
+ by.set(folder, row);
93
+ }
94
+ report.folders = [...by.values()];
95
+ }
96
+ return report;
97
+ }
98
+ export function coverageLines(r) {
99
+ const width = Math.max(0, ...r.figures.map((f) => f.figure.length));
100
+ const tally = (s) => r.figures.filter((f) => f.state === s).length;
101
+ return [
102
+ ...(r.git ? [] : ['no git: stale is not checked']),
103
+ ...r.figures.map((f) => `${f.figure.padEnd(width + 4)}${f.state.padEnd(8)}${f.detail}`),
104
+ ...(r.entries
105
+ ? [
106
+ `uncovered ${r.entries.glob}: ${r.entries.uncovered.length} of ${r.entries.total} files have no figure`,
107
+ ...r.entries.uncovered.map((p) => ` ${p}`),
108
+ ]
109
+ : []),
110
+ ...(r.folders ?? []).map((f) => `code ${f.folder}: ${f.covered} of ${f.total} files have a figure`),
111
+ `summary: ${count(r.figures.length, 'figure')}: ${tally('ok')} ok, ${tally('stale')} stale, ${tally('fail')} fail, ${tally('none')} none`,
112
+ ];
113
+ }
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.js CHANGED
@@ -505,13 +505,16 @@ export function labelAlong(r, w, boxes, placed, fig, lines = []) {
505
505
  y: Math.max(fig.y + 9, Math.min(at.y, fig.y + fig.h - 9)),
506
506
  }
507
507
  : at;
508
- const free = (at, strict) => {
508
+ const free = (at, strict, room = 1) => {
509
509
  const pill = { x: at.x - w / 2, y: at.y - 9, w, h: 18 };
510
510
  return (inFig(at) &&
511
- !boxes.some((b) => overlaps(pill, b, 1)) &&
511
+ !boxes.some((b) => overlaps(pill, b, room)) &&
512
512
  !placed.some((b) => overlaps(pill, b, 1)) &&
513
513
  (!strict || !lines.some((q) => q.x > pill.x - 2 && q.x < pill.x + w + 2 && q.y > pill.y - 2 && q.y < pill.y + 20)));
514
514
  };
515
+ for (const at of spots)
516
+ if (free(at, true, 6))
517
+ return at;
515
518
  for (const strict of [true, false])
516
519
  for (const at of spots)
517
520
  if (free(at, strict))
package/dist/imports.d.ts CHANGED
@@ -8,4 +8,5 @@ export declare const OUTSIDE = "#outside";
8
8
  export declare function resolveTs(root: string, from: string, spec: string, read: Read): string | null;
9
9
  export declare function resolvePy(root: string, from: string, mod: string, read: Read, top?: string): string | null;
10
10
  export declare function importsOf(root: string, file: CodeFile, read: Read): Import[];
11
+ export declare function reexports(root: string, file: CodeFile, name: string, read: Read): [string | null, string][];
11
12
  export declare function leadsTo(root: string, fromPath: string, toPath: string, name: string, read?: Read, cache?: Map<string, CodeFile | null>, depth?: number): string | null;
package/dist/imports.js CHANGED
@@ -172,7 +172,7 @@ export function importsOf(root, file, read) {
172
172
  }
173
173
  return out;
174
174
  }
175
- function reexports(root, file, name, read) {
175
+ export function reexports(root, file, name, read) {
176
176
  const N = esc(name);
177
177
  const out = [];
178
178
  if (name === '*')
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Fragment, useEffect, useId, useMemo, useRef, useState } from 'react';
4
4
  import { arcRoom, avoidOf, route } from './geometry.js';
5
5
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
6
6
  import { textWidth } from './text.js';
7
7
  import { checkScene, checkSpec, checkTheme } from './check.js';
8
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, labelLines, fitCap, LABEL_LINE, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, edgeTip, } from './model.js';
8
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, altText, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, labelLines, fitCap, LABEL_LINE, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, edgeTip, } from './model.js';
9
9
  export { LIGHT, DARK } from './model.js';
10
10
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
11
11
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
@@ -42,12 +42,22 @@ const cardBody = (c) => {
42
42
  return (r.tag?.length ?? 0) > 2 ? (_jsxs("div", { children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 5, marginBottom: 1 }, children: [tag, mark] }), text] }, i)) : (_jsxs("div", { style: { display: 'flex', gap: 5, alignItems: 'baseline' }, children: [tag, text, mark] }, i));
43
43
  }) }));
44
44
  };
45
+ const HIDDEN = {
46
+ position: 'absolute',
47
+ width: 1,
48
+ height: 1,
49
+ overflow: 'hidden',
50
+ clip: 'rect(0 0 0 0)',
51
+ whiteSpace: 'pre-line',
52
+ };
45
53
  /**
46
54
  * The interactive player for one figure. Give it a `FlowProps` spec.
47
55
  * Use `toSvg` from `flowfig/svg` for a static animated SVG.
48
56
  */
49
57
  export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, }) {
50
58
  const tl = timeline && isLanesLayout(layout);
59
+ const alt = useMemo(() => altText({ layout, edges, steps: stepsIn }), [layout, edges, stepsIn]);
60
+ const descId = useId();
51
61
  const synthetic = tl && !stepsIn.length;
52
62
  const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
53
63
  const [axisW, setAxisW] = useState(TL_AXIS_W);
@@ -747,7 +757,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
747
757
  background: v('bg'),
748
758
  color: v('muted'),
749
759
  };
750
- return (_jsxs("figure", { ref: figure, className: "flowfig", style: {
760
+ return (_jsxs("figure", { ref: figure, className: "flowfig", "aria-label": alt.title, "aria-describedby": descId, style: {
751
761
  ...vars,
752
762
  position: full ? 'fixed' : 'relative',
753
763
  inset: full ? 0 : undefined,
@@ -760,7 +770,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
760
770
  background: v('bg'),
761
771
  fontFamily: v('font'),
762
772
  color: v('fg'),
763
- }, children: [_jsx("button", { type: "button", "aria-label": full ? 'Close full screen' : 'Full screen', title: full ? 'Close (Esc)' : 'Full screen', onClick: () => setFull((f) => !f), style: {
773
+ }, children: [_jsx("div", { id: descId, style: HIDDEN, children: alt.desc }), _jsx("button", { type: "button", "aria-label": full ? 'Close full screen' : 'Full screen', title: full ? 'Close (Esc)' : 'Full screen', onClick: () => setFull((f) => !f), style: {
764
774
  ...iconBtn,
765
775
  position: 'absolute',
766
776
  top: 10,
package/dist/load.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { counts, toBeat } from './model.js';
2
+ import { altText, counts, toBeat } from './model.js';
3
3
  import { coverageLine, owners, unsureLines, verifyReport } from './verify.js';
4
4
  import { toSvg } from './svg.js';
5
5
  const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
@@ -65,7 +65,7 @@ export function summaryLines(props) {
65
65
  const hops = s.flow.reduce((k, b) => k + toBeat(b).hops.length, 0);
66
66
  return `step "${text(s.label)}": ${hops} ${hops === 1 ? 'hop' : 'hops'}`;
67
67
  });
68
- return [...edges, ...steps];
68
+ return [...edges, ...steps, `alt: ${altText(props).title}`];
69
69
  }
70
70
  export function verifyLines(props, figure, root = process.cwd()) {
71
71
  if (!owners(props).some(([, source, via]) => source != null || via != null))
package/dist/model.d.ts CHANGED
@@ -351,3 +351,7 @@ export declare const counts: (p: FlowProps) => {
351
351
  messages: number;
352
352
  };
353
353
  export declare function groupGap(g: FigGroup, edges: FigEdge[]): number;
354
+ export declare const altText: (fig: FlowProps) => {
355
+ title: string;
356
+ desc: string;
357
+ };
package/dist/model.js CHANGED
@@ -452,22 +452,54 @@ export const counts = (p) => {
452
452
  };
453
453
  };
454
454
  const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
455
+ const LINE_CLEAR = 16;
455
456
  export function groupGap(g, edges) {
456
457
  const side = new Map();
457
458
  g.children.forEach((c, i) => idsIn(c).forEach((id) => side.set(id, i)));
458
459
  const spans = edges.filter((e) => e.label != null && side.has(e.from) && side.has(e.to) && side.get(e.from) !== side.get(e.to) && !(g.gap != null && e.around));
459
460
  if (g.gap != null && !spans.length)
460
461
  return g.gap;
461
- const need = Math.max(0, ...spans.map((e) => labelPillW(str(e.label)) + 16));
462
+ const need = Math.max(0, ...spans.map((e) => labelPillW(str(e.label)) + 2 * LINE_CLEAR));
462
463
  if (g.gap != null && g.direction !== 'column')
463
- return Math.max(g.gap, need - 16);
464
+ return Math.max(g.gap, need);
464
465
  let auto;
465
466
  if (g.direction === 'column') {
466
467
  const ends = spans.map((e) => [side.get(e.from), side.get(e.to)].sort((p, q) => p - q));
467
468
  const most = Math.max(0, ...g.children.map((_, k) => ends.filter(([lo, hi]) => lo <= k && k < hi).length));
468
- auto = most > 1 ? Math.min(most, 3) * 22 + 16 : 28;
469
+ auto = most ? Math.min(most, 3) * 22 + 2 * LINE_CLEAR - 4 : 28;
469
470
  }
470
471
  else
471
472
  auto = Math.max(56, need);
472
473
  return Math.max(g.gap ?? 0, auto);
473
474
  }
475
+ export const altText = (fig) => {
476
+ const names = new Map();
477
+ const walk = (g) => g.children.forEach((c) => {
478
+ if (c.id)
479
+ names.set(c.id, str(c.label) || c.id);
480
+ if (isGroup(c))
481
+ walk(c);
482
+ });
483
+ walk(fig.layout);
484
+ const name = (id) => names.get(id) ?? id;
485
+ const ends = new Map(fig.edges.map((e) => [edgeId(e), e]));
486
+ const boxes = nodes(fig.layout).map((n) => name(n.id));
487
+ const steps = fig.steps ?? [];
488
+ const lines = steps.flatMap((s) => [
489
+ `${str(s.label)}.`,
490
+ ...s.flow.map(toBeat).flatMap((b) => {
491
+ if (str(b.say))
492
+ return [str(b.say)];
493
+ return b.hops.flatMap((h) => {
494
+ const e = ends.get(h.edge);
495
+ if (!e)
496
+ return [];
497
+ return [h.back ? `${name(e.to)} to ${name(e.from)}` : `${name(e.from)} to ${name(e.to)}`];
498
+ });
499
+ }),
500
+ ]);
501
+ return {
502
+ title: `Flow figure: ${boxes.join(', ')}.${steps.length ? ` Steps: ${steps.map((s) => str(s.label)).join(', ')}.` : ''}`,
503
+ desc: [`The figure has these boxes: ${boxes.join(', ')}.`, ...lines].join('\n'),
504
+ };
505
+ };
package/dist/svg.js CHANGED
@@ -5,7 +5,7 @@ import { textWidth, wrap } from './text.js';
5
5
  import { checkRendered, planFor } from './check.js';
6
6
  export { parseSource, links } from './source.js';
7
7
  export { diff, formatDiff } from './diff.js';
8
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, STUB_ROOM, laneBlock, laneEnd, tightCopies, LABEL_LINE, itemWidth, labelLines, fitCap, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, edgeTip, } from './model.js';
8
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, STUB_ROOM, laneBlock, laneEnd, tightCopies, LABEL_LINE, itemWidth, labelLines, fitCap, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, altText, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, edgeTip, } from './model.js';
9
9
  export { LIGHT, DARK } from './model.js';
10
10
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
11
11
  const SUB_LINE = 15;
@@ -782,7 +782,11 @@ export function render(fig, opts = {}) {
782
782
  return `<g transform="translate(${n2(railX)} 0)">${parts.join('')}</g>`;
783
783
  })();
784
784
  const { font, ...t0 } = { ...LIGHT, ...fig.theme, ...opts.theme };
785
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${n2(W)}" height="${n2(H)}" viewBox="0 0 ${n2(W)} ${n2(H)}" font-family="${esc(font ?? SYSTEM_FONT)}">
785
+ const alt = altText(fig);
786
+ const holdAt = beats[0]?.length ? n2(segs[beats[0].length - 1].t1 - 0.1) : 0;
787
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${n2(W)}" height="${n2(H)}" viewBox="0 0 ${n2(W)} ${n2(H)}" font-family="${esc(font ?? SYSTEM_FONT)}" role="img">
788
+ <title>${esc(alt.title)}</title>
789
+ <desc>${esc(alt.desc)}</desc>
786
790
  <style>
787
791
  svg { ${vars(t0, '#eef5fd')} }
788
792
  @media (prefers-color-scheme: dark) { svg { ${vars(DARK, '#1d2733')} } }
@@ -806,6 +810,7 @@ svg { ${vars(t0, '#eef5fd')} }
806
810
  .railphase.muted { fill: var(--muted); }
807
811
  .end { text-anchor: end; }
808
812
  ${css.join('\n')}
813
+ @media (prefers-reduced-motion: reduce) { * { animation-play-state: paused !important; animation-delay: -${holdAt}s !important; } }
809
814
  </style>
810
815
  <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="context-stroke"/></marker></defs>
811
816
  <rect width="100%" height="100%" fill="var(--bg)"/>
@@ -0,0 +1,30 @@
1
+ import { type Read } from './code.ts';
2
+ export type TraceEdge = {
3
+ from: string;
4
+ to: string;
5
+ at: string;
6
+ };
7
+ export type TraceNote = {
8
+ at: string;
9
+ reason: string;
10
+ };
11
+ export type TraceResult = {
12
+ start: string;
13
+ edges: TraceEdge[];
14
+ unsure: TraceNote[];
15
+ open: TraceNote[];
16
+ stops: {
17
+ reason: 'depth' | 'max';
18
+ count: number;
19
+ }[];
20
+ outside: number;
21
+ ms: number;
22
+ };
23
+ export type TraceOptions = {
24
+ root?: string;
25
+ depth?: number;
26
+ max?: number;
27
+ read?: Read;
28
+ };
29
+ export declare function trace(start: string, { root, depth, max, read }?: TraceOptions): TraceResult;
30
+ export declare function traceLines(r: TraceResult, depth: number, max: number): string[];
package/dist/trace.js ADDED
@@ -0,0 +1,271 @@
1
+ import { posix } from 'node:path';
2
+ import { codeFile, definitions, depthAt, esc, langOf, locate, matchClose, pyBody, readFile } from './code.js';
3
+ import { EDGE_LANGS, edgeResult, paramName, params, shadows } from './edges.js';
4
+ import { OUTSIDE, importsOf, reexports } from './imports.js';
5
+ import { ancestors, declIn, defines, findType, isUpper, makeCtx, receiver, receiverChain } from './types.js';
6
+ import { goDirFiles } from './type-index.js';
7
+ import { parseSource } from './source.js';
8
+ const CALL = /(?<![\w$])([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>\s*)?\(/g;
9
+ const REF = /[(,]\s*([A-Za-z_$][\w$]*)\s*(?=[,)])/g;
10
+ const ROUTE = /\(\s*(['"`])\/[^'"`]*\1\s*,\s*(['"`])([A-Za-z_$][\w$]*(?:(?:\.|@|::|#)[A-Za-z_$][\w$]*)+)\2/g;
11
+ const DEFAULT = /\bexport\s+default\s+(?:async\s+)?(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/;
12
+ const KEYWORDS = new Set('if for while switch catch return typeof await function super sizeof using lock foreach elif assert yield throw not and or in with match when constructor'.split(' '));
13
+ const innerParam = (body, n) => {
14
+ const N = `(?<![\\w$.])${esc(n)}(?![\\w$])`;
15
+ return new RegExp(`(?:\\([^()]*${N}[^()]*\\)|${N})\\s*(?::[^=)]+)?=>|\\bfunc(?:tion\\s*[\\w$]*)?\\s*\\((?:[^()]|\\([^()]*\\))*?${N}|\\blambda\\b[^:]*?${N}`).test(body);
16
+ };
17
+ const topLevel = (f, sym) => {
18
+ const s = locate(f, sym);
19
+ if (!s)
20
+ return false;
21
+ if (f.lang === 'go')
22
+ return depthAt(f.code, s.start, 0) === 0 && !/^func\s*\(/.test(f.code.slice(s.start, s.end));
23
+ return !/[ \t]/.test(f.code[f.code.lastIndexOf('\n', s.start - 1) + 1]);
24
+ };
25
+ const defined = (cx, from, to) => {
26
+ const [path, sym] = to.split('#');
27
+ const f = codeFile(cx.root, path, cx.read, cx.cache);
28
+ // A Java or C# constructor has the name of its type.
29
+ if (f.lang === 'java' || f.lang === 'cs')
30
+ return 1;
31
+ return definitions(f, sym) + (path === from ? 0 : reexports(cx.root, f, sym, cx.read).length);
32
+ };
33
+ const CONTROL = /^(?:if|for|while|switch|catch|with|foreach|else|match|elif|except|try)\b/;
34
+ const enclosing = (f, pos) => {
35
+ const { code } = f;
36
+ const out = [];
37
+ if (f.lang === 'py') {
38
+ let ind = /^[ \t]*/.exec(code.slice(code.lastIndexOf('\n', pos - 1) + 1))[0].length;
39
+ for (let ls = code.lastIndexOf('\n', pos - 1); ls >= 0 && ind > 0; ls = code.lastIndexOf('\n', ls - 1)) {
40
+ const line = code.slice(ls + 1, code.indexOf('\n', ls + 1) < 0 ? undefined : code.indexOf('\n', ls + 1));
41
+ const w = /^[ \t]*/.exec(line)[0].length;
42
+ if (!line.trim() || w >= ind)
43
+ continue;
44
+ ind = w;
45
+ if (/^\s*(async\s+)?def\b/.test(line))
46
+ out.push(pyBody(code, ls + 1 + w));
47
+ }
48
+ return out;
49
+ }
50
+ if (f.lang === 'java' || f.lang === 'cs')
51
+ return out;
52
+ for (let i = pos, d = 0; i >= 0; i--) {
53
+ if (code[i] === '}')
54
+ d++;
55
+ else if (code[i] === '{' && d-- === 0) {
56
+ const head = code.slice(code.slice(0, i).search(/[;{}][^;{}]*$/) + 1, i).trim();
57
+ if (!CONTROL.test(head) && /(?:\)\s*(?::[^;{}]*)?|=>)$/.test(head))
58
+ out.push([i, matchClose(code, i)]);
59
+ d = 0;
60
+ }
61
+ }
62
+ return out;
63
+ };
64
+ const lineOf = (code, pos) => code.slice(0, pos).split('\n').length;
65
+ function forward(cx, path, name, depth = 0) {
66
+ if (langOf(path) == null) {
67
+ const f = goDirFiles(cx, path).find((g) => topLevel(g, name));
68
+ return f ? { to: `${f.path}#${name}` } : null;
69
+ }
70
+ const f = codeFile(cx.root, path, cx.read, cx.cache);
71
+ if (!f)
72
+ return null;
73
+ const named = name === 'default' ? DEFAULT.exec(f.code)?.[1] : undefined;
74
+ if (named && locate(f, named))
75
+ return { to: `${path}#${named}` };
76
+ if (locate(f, name))
77
+ return { to: `${path}#${name}` };
78
+ if (depth >= 5)
79
+ return null;
80
+ let star = false;
81
+ for (const [p, inner] of reexports(cx.root, f, name, cx.read)) {
82
+ if (inner === '?')
83
+ star = true;
84
+ if (p == null || inner === '?')
85
+ continue;
86
+ const t = forward(cx, p, inner, depth + 1);
87
+ if (t)
88
+ return t;
89
+ }
90
+ return star ? { unsure: `star import: ${name}()` } : null;
91
+ }
92
+ function method(cx, d, name) {
93
+ for (const x of [d, ...ancestors(cx, d).flatMap((a) => (a.d ? [a.d] : []))]) {
94
+ const sym = `${x.name}.${name}`;
95
+ const files = x.info.lang === 'go' ? goDirFiles(cx, posix.dirname(x.info.path)) : [x.info];
96
+ const f = files.find((g) => locate(g, sym));
97
+ if (f)
98
+ return `${f.path}#${sym}`;
99
+ }
100
+ return null;
101
+ }
102
+ function resolve(cx, fi, body, n, rc, container, decl, imps) {
103
+ const gone = (p) => p == null || p === OUTSIDE;
104
+ if (!rc) {
105
+ const bind = imps.find((i) => i.local === n);
106
+ if (bind)
107
+ return gone(bind.path) ? 'outside' : bind.name === '*' ? null : forward(cx, bind.path, bind.name);
108
+ if (fi.lang === 'java' || fi.lang === 'cs') {
109
+ const m = decl && method(cx, decl, n);
110
+ if (m) {
111
+ const own = [decl, ...ancestors(cx, decl).flatMap((a) => (a.d ? [a.d] : []))].filter((x) => defines(cx, x, n));
112
+ return own.length > 1 ? { unsure: `${n} has two definitions` } : { to: m };
113
+ }
114
+ }
115
+ else if (topLevel(fi, n))
116
+ return { to: `${fi.path}#${n}` };
117
+ if (fi.lang === 'go') {
118
+ const f = goDirFiles(cx, posix.dirname(fi.path)).find((g) => topLevel(g, n));
119
+ if (f)
120
+ return { to: `${f.path}#${n}` };
121
+ }
122
+ if ((fi.lang === 'java' || fi.lang === 'cs' || fi.lang === 'rs') && isUpper(n)) {
123
+ const d = findType(cx, fi, n);
124
+ if (d)
125
+ return { to: `${d.info.path}#${d.name}` };
126
+ }
127
+ for (const i of imps.filter((x) => x.local === '*' || x.local === '.')) {
128
+ if (i.path == null)
129
+ return { unsure: `star import: ${n}()` };
130
+ const t = i.path === OUTSIDE ? null : forward(cx, i.path, n);
131
+ if (t)
132
+ return t;
133
+ }
134
+ return null;
135
+ }
136
+ if (rc.complex)
137
+ return null;
138
+ const head = imps.find((i) => i.local === rc.chain[0]);
139
+ if (head && gone(head.path))
140
+ return 'outside';
141
+ const rt = receiver(cx, fi, body, rc.chain, container, decl);
142
+ if (rt.name === '#external')
143
+ return 'outside';
144
+ if (rt.kind === 'module' && rt.mod)
145
+ return forward(cx, rt.mod, n);
146
+ if (rt.kind !== 'type' || rt.name === '#top')
147
+ return null;
148
+ const d = rt.decl ?? findType(cx, rt.ctx, rt.name, rt.qual);
149
+ const sym = d && method(cx, d, n);
150
+ return sym ? { to: sym } : null;
151
+ }
152
+ function candidates(body, own) {
153
+ const open = body.indexOf(own);
154
+ const sig = open >= 0 && /^\s*(?:<[^<>]*>\s*)?\(/.test(body.slice(open + own.length)) ? body.indexOf('(', open) : -1;
155
+ const from = sig < 0 ? 0 : matchClose(body, sig) + 1;
156
+ const out = [];
157
+ for (const m of body.matchAll(CALL)) {
158
+ const pre = body.slice(body.lastIndexOf('\n', m.index) + 1, m.index);
159
+ if (m.index < from || KEYWORDS.has(m[1]) || /^\s*@/.test(pre) || /\b(?:def|func|fn|function)\s+$/.test(pre))
160
+ continue;
161
+ out.push({ name: m[1], idx: m.index, call: true });
162
+ }
163
+ for (const m of body.matchAll(REF))
164
+ if (m.index >= from)
165
+ out.push({ name: m[1], idx: m.index + m[0].indexOf(m[1], 1), call: false });
166
+ return out.sort((a, b) => a.idx - b.idx);
167
+ }
168
+ function calls(cx, caller, r) {
169
+ const { path, symbol } = parseSource(caller);
170
+ const fi = codeFile(cx.root, path, cx.read, cx.cache);
171
+ const span = fi && locate(fi, symbol);
172
+ if (!fi || !span)
173
+ return [];
174
+ const body = fi.code.slice(span.start, span.end);
175
+ const parts = symbol.split('.');
176
+ const own = parts.at(-1);
177
+ const container = parts.length > 1 ? parts.at(-2) : null;
178
+ const decl = container ? (declIn(fi, container) ?? (fi.lang === 'go' ? findType(cx, fi, container) : null)) : null;
179
+ const imps = importsOf(cx.root, fi, cx.read);
180
+ const ps = new Set(params(body, own).map((p) => paramName(p, fi.lang)));
181
+ const at = (i) => `${path}:${lineOf(fi.code, span.start + i)}`;
182
+ for (const m of fi.keep.slice(span.start, span.end).matchAll(ROUTE))
183
+ r.open.push({ at: at(m.index), reason: `string route "${m[3]}"` });
184
+ const outer = enclosing(fi, span.start);
185
+ const targets = new Map();
186
+ for (const c of candidates(body, own)) {
187
+ const rc = receiverChain(body, c.idx, fi.lang);
188
+ if (c.call && !rc && ps.has(c.name)) {
189
+ r.unsure.push({ at: at(c.idx), reason: `parameter call: ${c.name}()` });
190
+ continue;
191
+ }
192
+ if (!rc && (shadows(body, c.name, fi.lang, own, false) || innerParam(body, c.name)))
193
+ continue;
194
+ const twice = { unsure: `${c.name} has two definitions` };
195
+ const t = !rc &&
196
+ [[span.start, span.end], ...outer].some(([a, b]) => definitions(fi, c.name, a, b) > (c.name === own ? 1 : 0))
197
+ ? twice
198
+ : resolve(cx, fi, body, c.name, rc, container, decl, imps);
199
+ if (t === 'outside')
200
+ r.outside += c.call ? 1 : 0;
201
+ else if (t && 'unsure' in t)
202
+ r.unsure.push({ at: at(c.idx), reason: t.unsure });
203
+ else if (t && !rc && defined(cx, path, t.to) > 1)
204
+ r.unsure.push({ at: at(c.idx), reason: twice.unsure });
205
+ else if (t && t.to !== caller && !targets.has(t.to))
206
+ targets.set(t.to, at(c.idx));
207
+ }
208
+ const found = [];
209
+ for (const [to, where] of targets) {
210
+ const { result, reason } = edgeResult(cx.root, caller, to, undefined, cx.read, cx.cache);
211
+ if (result === 'found')
212
+ found.push({ from: caller, to, at: where });
213
+ else if (result === 'unsure')
214
+ r.unsure.push({ at: where, reason });
215
+ else if (result === 'not-checked')
216
+ r.open.push({ at: where, reason });
217
+ }
218
+ return found;
219
+ }
220
+ export function trace(start, { root = process.cwd(), depth = 2, max = 40, read = readFile } = {}) {
221
+ const t0 = performance.now();
222
+ const src = parseSource(start);
223
+ if (!src?.symbol)
224
+ throw new Error(`${start}: expected file#symbol`);
225
+ const lang = langOf(src.path);
226
+ if (!lang || !EDGE_LANGS.has(lang))
227
+ throw new Error(`${src.path}: trace does not read this language`);
228
+ const cache = new Map();
229
+ const fi = codeFile(root, src.path, read, cache);
230
+ if (!fi)
231
+ throw new Error(`${src.path}: file not found`);
232
+ if (!locate(fi, src.symbol))
233
+ throw new Error(`${start}: symbol not defined`);
234
+ const cx = makeCtx(root, read, cache);
235
+ const r = { start, edges: [], unsure: [], open: [], stops: [], outside: 0, ms: 0 };
236
+ const seen = new Set([start]);
237
+ let level = [start];
238
+ for (let d = 0; d < depth && level.length; d++) {
239
+ const next = [];
240
+ for (const [k, caller] of level.entries())
241
+ for (const e of calls(cx, caller, r)) {
242
+ r.edges.push(e);
243
+ if (!seen.has(e.to))
244
+ next.push(e.to);
245
+ seen.add(e.to);
246
+ if (r.edges.length >= max) {
247
+ r.stops.push({ reason: 'max', count: level.length - k - 1 + next.length });
248
+ r.ms = Math.round(performance.now() - t0);
249
+ return r;
250
+ }
251
+ }
252
+ level = next;
253
+ }
254
+ if (level.length)
255
+ r.stops.push({ reason: 'depth', count: level.length });
256
+ r.ms = Math.round(performance.now() - t0);
257
+ return r;
258
+ }
259
+ const count = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
260
+ export function traceLines(r, depth, max) {
261
+ const pairs = r.edges.map((e) => `${e.from} -> ${e.to}`);
262
+ const width = Math.max(0, ...pairs.map((p) => p.length));
263
+ const symbols = new Set([r.start, ...r.edges.map((e) => e.to)]).size;
264
+ return [
265
+ ...r.edges.map((e, i) => `${pairs[i].padEnd(width)} ${e.at}`),
266
+ ...r.unsure.map((u) => `unsure ${u.at} ${u.reason}`),
267
+ ...r.open.map((o) => `open ${o.at} ${o.reason}`),
268
+ ...r.stops.map((s) => `stop ${s.reason} ${s.reason === 'depth' ? depth : max}: ${count(s.count, 'symbol')} not followed`),
269
+ `summary: ${count(symbols, 'symbol')}, ${r.edges.length} found, ${r.unsure.length} unsure, ${r.open.length} open, ${count(r.outside, 'call')} outside the repo, ${(r.ms / 1000).toFixed(1)} s`,
270
+ ];
271
+ }
package/dist/verify.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type FlowProps } from './model.ts';
2
2
  import type { Finding } from './scene.ts';
3
- import { type Read } from './code.ts';
3
+ import { type CodeFile, type Read } from './code.ts';
4
4
  export { parseSource, headingSlug, owners, links, type Link } from './source.ts';
5
5
  export type Coverage = {
6
6
  boxes: number;
@@ -17,9 +17,10 @@ export type Coverage = {
17
17
  export declare const coverageLine: (figure: string, c: Coverage) => string;
18
18
  export declare const unsureLines: (c: Coverage) => string[];
19
19
  /** The links against the files under `root`. A path outside `root` counts as a missing file. */
20
- export declare function verifyReport(fig: FlowProps, { root, read }?: {
20
+ export declare function verifyReport(fig: FlowProps, { root, read, cache, }?: {
21
21
  root?: string;
22
22
  read?: Read;
23
+ cache?: Map<string, CodeFile | null>;
23
24
  }): {
24
25
  findings: Finding[];
25
26
  coverage: Coverage;
package/dist/verify.js CHANGED
@@ -16,7 +16,7 @@ export const coverageLine = (figure, c) => `${figure}: ${c.boxesDefined} of ${c.
16
16
  : '');
17
17
  export const unsureLines = (c) => c.unsureEdges.map((e) => `unsure edge "${e.id}": ${e.reason}`);
18
18
  /** The links against the files under `root`. A path outside `root` counts as a missing file. */
19
- export function verifyReport(fig, { root = process.cwd(), read = readFile } = {}) {
19
+ export function verifyReport(fig, { root = process.cwd(), read = readFile, cache = new Map(), } = {}) {
20
20
  const findings = [];
21
21
  const coverage = emptyCoverage();
22
22
  const all = links(fig);
@@ -26,7 +26,6 @@ export function verifyReport(fig, { root = process.cwd(), read = readFile } = {}
26
26
  coverage,
27
27
  };
28
28
  const files = new Map();
29
- const parsed = new Map();
30
29
  for (const l of all) {
31
30
  const full = resolve(root, l.path);
32
31
  if (!files.has(full))
@@ -37,7 +36,7 @@ export function verifyReport(fig, { root = process.cwd(), read = readFile } = {}
37
36
  if (text == null)
38
37
  fail('missing-file', 'file not found');
39
38
  else if (l.symbol) {
40
- const code = codeFile(root, l.path, read, parsed);
39
+ const code = codeFile(root, l.path, read, cache);
41
40
  if (code) {
42
41
  if (!isDefined(code, l.symbol))
43
42
  fail('missing-symbol', 'symbol not defined');
@@ -54,7 +53,7 @@ export function verifyReport(fig, { root = process.cwd(), read = readFile } = {}
54
53
  }
55
54
  const source = new Map(nodes(fig.layout).map((n) => [n.id, n.source]));
56
55
  const check = (who, id, caller, callee, via) => {
57
- const { result, reason } = caller ? edgeResult(root, caller, callee, via, read, parsed) : { result: 'not-checked', reason: '' };
56
+ const { result, reason } = caller ? edgeResult(root, caller, callee, via, read, cache) : { result: 'not-checked', reason: '' };
58
57
  if (result === 'found')
59
58
  coverage.found++;
60
59
  else if (result === 'not-found') {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.8.3";
1
+ export declare const VERSION = "0.9.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The release list bumps this with package.json.
2
- export const VERSION = '0.8.3';
2
+ export const VERSION = '0.9.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Your coding agent draws animated diagrams from your code, and flowfig checks each box and arrow against that code. CI fails when a diagram drifts from the code. The output is one animated SVG with no script, for a README, PR or issue. Works with Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro.",
5
5
  "license": "MIT",
6
6
  "repository": {