flowfig 0.8.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/trace.js ADDED
@@ -0,0 +1,278 @@
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 file = (id) => id.slice(0, id.indexOf('#'));
262
+ const lineNo = (e) => (e.at.startsWith(`${file(e.from)}:`) ? e.at.slice(file(e.from).length + 1) : e.at);
263
+ const width = Math.max(0, ...r.edges.map((e) => lineNo(e).length));
264
+ const symbols = new Set([r.start, ...r.edges.map((e) => e.to)]).size;
265
+ let last = '';
266
+ return [
267
+ ...r.edges.flatMap((e) => {
268
+ const head = e.from === last ? [] : [e.from];
269
+ last = e.from;
270
+ const to = file(e.to) === file(e.from) ? e.to.slice(e.to.indexOf('#') + 1) : e.to;
271
+ return [...head, ` ${lineNo(e).padEnd(width)} ${to}`];
272
+ }),
273
+ ...r.unsure.map((u) => `unsure ${u.at} ${u.reason}`),
274
+ ...r.open.map((o) => `open ${o.at} ${o.reason}`),
275
+ ...r.stops.map((s) => `stop ${s.reason} ${s.reason === 'depth' ? depth : max}: ${count(s.count, 'symbol')} not followed`),
276
+ `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`,
277
+ ];
278
+ }
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;
@@ -16,10 +16,13 @@ export type Coverage = {
16
16
  };
17
17
  export declare const coverageLine: (figure: string, c: Coverage) => string;
18
18
  export declare const unsureLines: (c: Coverage) => string[];
19
+ export declare const SPEC_MARK = "<metadata id=\"figure-spec\">";
20
+ export declare function detailFindings(fig: FlowProps, root: string, read?: Read): Finding[];
19
21
  /** 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 }?: {
22
+ export declare function verifyReport(fig: FlowProps, { root, read, cache, }?: {
21
23
  root?: string;
22
24
  read?: Read;
25
+ cache?: Map<string, CodeFile | null>;
23
26
  }): {
24
27
  findings: Finding[];
25
28
  coverage: Coverage;
package/dist/verify.js CHANGED
@@ -3,8 +3,25 @@ import { edgeId, nodes, toBeat } from './model.js';
3
3
  import { headingSlug, links } from './source.js';
4
4
  import { codeFile, isDefined, outside, readFile } from './code.js';
5
5
  import { edgeResult } from './edges.js';
6
+ import { reexports } from './imports.js';
6
7
  export { parseSource, headingSlug, owners, links } from './source.js';
7
8
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ const defines = (root, file, symbol, read, cache, depth = 0) => {
10
+ if (!file || depth > 5)
11
+ return null;
12
+ if (isDefined(file, symbol))
13
+ return true;
14
+ let doubt = false;
15
+ for (const [path, inner] of reexports(root, file, symbol, read)) {
16
+ const target = path == null || inner === '?' ? null : codeFile(root, path, read, cache);
17
+ const r = inner === '*' ? (target ? true : null) : defines(root, target, inner, read, cache, depth + 1);
18
+ if (r)
19
+ return true;
20
+ if (r == null)
21
+ doubt = true;
22
+ }
23
+ return doubt ? null : false;
24
+ };
8
25
  const hasHeading = (text, slug) => text.split('\n').some((line) => {
9
26
  const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
10
27
  return m != null && headingSlug(m[1]) === slug;
@@ -15,32 +32,47 @@ export const coverageLine = (figure, c) => `${figure}: ${c.boxesDefined} of ${c.
15
32
  ? `; edges: ${c.found} found, ${c.notFound} not found, ${c.unsure} unsure, ${c.notChecked} not checked`
16
33
  : '');
17
34
  export const unsureLines = (c) => c.unsureEdges.map((e) => `unsure edge "${e.id}": ${e.reason}`);
35
+ export const SPEC_MARK = '<metadata id="figure-spec">';
36
+ export function detailFindings(fig, root, read = readFile) {
37
+ return nodes(fig.layout).flatMap((n) => {
38
+ if (typeof n.detail !== 'string')
39
+ return [];
40
+ const text = outside(root, n.detail) ? null : read(resolve(root, n.detail));
41
+ const why = text == null ? 'file not found' : text.includes(SPEC_MARK) ? '' : 'no figure spec in the file';
42
+ return why
43
+ ? [{ rule: 'missing-detail', severity: 'warning', ids: [n.id], message: `box "${n.id}" -> ${n.detail}: ${why}` }]
44
+ : [];
45
+ });
46
+ }
18
47
  /** 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 } = {}) {
48
+ export function verifyReport(fig, { root = process.cwd(), read = readFile, cache = new Map(), } = {}) {
20
49
  const findings = [];
21
50
  const coverage = emptyCoverage();
51
+ findings.push(...detailFindings(fig, root, read));
22
52
  const all = links(fig);
23
53
  if (!all.length)
24
54
  return {
25
- findings: [{ rule: 'no-source', severity: 'warning', ids: [], message: 'no box, edge or hop has a source' }],
55
+ findings: [...findings, { rule: 'no-source', severity: 'warning', ids: [], message: 'no box, edge or hop has a source' }],
26
56
  coverage,
27
57
  };
28
58
  const files = new Map();
29
- const parsed = new Map();
30
59
  for (const l of all) {
31
60
  const full = resolve(root, l.path);
32
61
  if (!files.has(full))
33
62
  files.set(full, outside(root, l.path) ? null : read(full));
34
63
  const text = files.get(full);
35
64
  const before = findings.length;
65
+ let doubt = false;
36
66
  const fail = (rule, what) => findings.push({ rule, severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: ${what}` });
37
67
  if (text == null)
38
68
  fail('missing-file', 'file not found');
39
69
  else if (l.symbol) {
40
- const code = codeFile(root, l.path, read, parsed);
70
+ const code = codeFile(root, l.path, read, cache);
41
71
  if (code) {
42
- if (!isDefined(code, l.symbol))
72
+ const r = defines(root, code, l.symbol, read, cache);
73
+ if (r === false)
43
74
  fail('missing-symbol', 'symbol not defined');
75
+ doubt = r == null;
44
76
  }
45
77
  else if (!new RegExp(`(^|[^\\w$])${escape(l.symbol)}(?![\\w$])`).test(text) &&
46
78
  !(/\.(md|markdown)$/i.test(l.path) && hasHeading(text, l.symbol)))
@@ -48,13 +80,13 @@ export function verifyReport(fig, { root = process.cwd(), read = readFile } = {}
48
80
  }
49
81
  if (l.owner.startsWith('box "')) {
50
82
  coverage.boxes++;
51
- if (findings.length === before)
83
+ if (findings.length === before && !doubt)
52
84
  coverage.boxesDefined++;
53
85
  }
54
86
  }
55
87
  const source = new Map(nodes(fig.layout).map((n) => [n.id, n.source]));
56
88
  const check = (who, id, caller, callee, via) => {
57
- const { result, reason } = caller ? edgeResult(root, caller, callee, via, read, parsed) : { result: 'not-checked', reason: '' };
89
+ const { result, reason } = caller ? edgeResult(root, caller, callee, via, read, cache) : { result: 'not-checked', reason: '' };
58
90
  if (result === 'found')
59
91
  coverage.found++;
60
92
  else if (result === 'not-found') {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.8.4";
1
+ export declare const VERSION = "0.10.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.4';
2
+ export const VERSION = '0.10.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.8.4",
3
+ "version": "0.10.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": {