flowfig 0.8.4 → 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 +90 -2
- package/dist/cli.js +47 -1
- package/dist/code.d.ts +2 -0
- package/dist/code.js +9 -4
- package/dist/coverage.d.ts +27 -0
- package/dist/coverage.js +113 -0
- package/dist/edges.d.ts +3 -0
- package/dist/edges.js +3 -3
- package/dist/imports.d.ts +1 -0
- package/dist/imports.js +1 -1
- package/dist/index.js +14 -4
- package/dist/load.js +2 -2
- package/dist/model.d.ts +4 -0
- package/dist/model.js +31 -0
- package/dist/svg.js +7 -2
- package/dist/trace.d.ts +30 -0
- package/dist/trace.js +271 -0
- package/dist/verify.d.ts +3 -2
- package/dist/verify.js +3 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ jobs:
|
|
|
65
65
|
- uses: actions/setup-node@v4
|
|
66
66
|
with:
|
|
67
67
|
node-version: 22
|
|
68
|
-
- uses: iamalvisng/flowfig@v0.
|
|
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.
|
|
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
|
|
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,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/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
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
package/dist/model.js
CHANGED
|
@@ -472,3 +472,34 @@ export function groupGap(g, edges) {
|
|
|
472
472
|
auto = Math.max(56, need);
|
|
473
473
|
return Math.max(g.gap ?? 0, auto);
|
|
474
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
|
|
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)"/>
|
package/dist/trace.d.ts
ADDED
|
@@ -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,
|
|
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,
|
|
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.
|
|
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.
|
|
2
|
+
export const VERSION = '0.9.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "0.
|
|
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": {
|