flownav 0.1.2 → 0.2.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 +79 -14
- package/dist/assemble.js +1 -1
- package/dist/build.js +40 -19
- package/dist/cli.js +41 -5
- package/dist/expressions.js +47 -21
- package/dist/install.js +2 -1
- package/dist/mcp.js +12 -8
- package/dist/queries.js +4 -2
- package/dist/read.js +277 -0
- package/dist/stats.js +64 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ It is read-only against n8n. Inspired by [graphify](https://github.com/safishams
|
|
|
11
11
|
## Why
|
|
12
12
|
|
|
13
13
|
- **Token cost.** A real workflow export is large and mostly noise. `flownav explain "Node"` is about ten lines.
|
|
14
|
-
- **The rename gap.**
|
|
14
|
+
- **The rename gap.** The n8n editor tries to rewrite `$('Old Name')` references when you rename a node, but community reports describe that as imperfect, and references inside Code nodes' JavaScript are reported not to be rewritten. Renames that don't go through the editor (the API, edited JSON, an AI agent changing a workflow) don't get that rewrite step at all, and deleting a node never fixes the references to it. A broken reference fails silently. `flownav affected "Old Name" --rename` lists exactly which nodes and parameters reference the node by name, so you can check them before and after you change anything.
|
|
15
15
|
- **Cross-workflow structure.** Sub-workflows, agent tool workflows, error workflows and webhook-to-webhook calls become edges, so you can ask what calls what across your whole instance.
|
|
16
16
|
|
|
17
17
|
## Install
|
|
@@ -113,7 +113,9 @@ $ flownav affected "Normalize" --rename
|
|
|
113
113
|
wf-orders:Normalize — impact of rename
|
|
114
114
|
2 node(s) reference "Normalize" by name and will break unless their expressions are rewritten:
|
|
115
115
|
× wf-orders:Notify Sales uses {email} in text
|
|
116
|
+
text: {{ 'Big order from ' + $('Normalize').item.json.email }}
|
|
116
117
|
× wf-orders:Orphan Code uses {email} in jsCode
|
|
118
|
+
jsCode: L1: const row = $('Normalize').first().json.email;
|
|
117
119
|
```
|
|
118
120
|
|
|
119
121
|
**A path, even across workflows**
|
|
@@ -163,7 +165,7 @@ $ flownav check
|
|
|
163
165
|
INFO no-error-handler [wf-enrich] Enrich customer has no error workflow and no node-level error handling
|
|
164
166
|
```
|
|
165
167
|
|
|
166
|
-
`check` exits with code 1 when it finds an `ERROR
|
|
168
|
+
`check` exits with code 1 when it finds an `ERROR`. Use `--fail-on warn` (or `info`) to fail on less severe findings, `--fail-on none` to always exit 0, and `--only a,b` to run just some checks. See [Use it in CI](#use-it-in-ci).
|
|
167
169
|
|
|
168
170
|
## Using it with an AI agent
|
|
169
171
|
|
|
@@ -171,7 +173,7 @@ $ flownav check
|
|
|
171
173
|
flownav install claude --mcp # writes .claude/skills/flownav/SKILL.md and .mcp.json
|
|
172
174
|
```
|
|
173
175
|
|
|
174
|
-
This installs a skill that tells the agent to query flownav before reading, renaming or deleting nodes, and registers an MCP server (`flownav serve`, stdio) exposing `flownav_scan`, `explain`, `neighbors`, `path`, `find`, `affected`, `callers`, `calls`, `uses`, `check` and `report`. The server reads `.flownav/graph.json` from the working directory (or `--graph <file>`) and reloads it when the file changes. The skill works without MCP too, as long as the agent can run shell commands.
|
|
176
|
+
This installs a skill that tells the agent to query flownav before reading, renaming or deleting nodes, and registers an MCP server (`flownav serve`, stdio) exposing `flownav_scan`, `explain`, `neighbors`, `path`, `find`, `affected`, `callers`, `calls`, `uses`, `check`, `read` and `report`. The server reads `.flownav/graph.json` from the working directory (or `--graph <file>`) and reloads it when the file changes. The skill works without MCP too, as long as the agent can run shell commands.
|
|
175
177
|
|
|
176
178
|
A typical rename: the agent runs `scan` to find the workflow id, then `affected "wf-orders:Normalize"`, edits the listed expressions together with the rename using whatever tool it has for editing n8n, syncs again, and runs `check` to confirm nothing dangles. flownav does not write to n8n; it tells the agent what is safe.
|
|
177
179
|
|
|
@@ -190,6 +192,38 @@ error: "order" matches 5 nodes. Do not guess: ask the user which one they mean.
|
|
|
190
192
|
|
|
191
193
|
If a partial name matches exactly one node, it resolves but the reply starts with a `NOTE:` asking the agent to confirm with the user before changing anything. To search a family of workflows, use `flownav find --match "<part of workflow name>" --name "<part of node name>"` (`--name` searches node names only; `--text` also searches parameters and code). The installed skill tells the agent to stop and ask in these cases. How reliably an agent follows that depends on the agent.
|
|
192
194
|
|
|
195
|
+
## Reading code in small pieces
|
|
196
|
+
|
|
197
|
+
flownav normally returns structure (cards, paths, references), not code. When an agent (or you) needs to read a large Code node, `read` returns it in small, capped slices instead of the whole node:
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
$ flownav read "Orphan Code" # default: an outline
|
|
201
|
+
wf-orders:Orphan Code · jsCode · 3 line(s), 127 chars
|
|
202
|
+
longest line: 46 chars
|
|
203
|
+
functions: none found
|
|
204
|
+
node references:
|
|
205
|
+
Normalize line 1 {email}
|
|
206
|
+
Deleted Step line 2 {x}
|
|
207
|
+
next: grep for a name, or read a range with lines
|
|
208
|
+
|
|
209
|
+
$ flownav read "Orphan Code" --grep normalize
|
|
210
|
+
wf-orders:Orphan Code · jsCode · 3 line(s), 127 chars
|
|
211
|
+
> 1: const row = $('Normalize').first().json.email;
|
|
212
|
+
2: const gone = $('Deleted Step').first().json.x;
|
|
213
|
+
3: return [{ json: { row, gone } }];
|
|
214
|
+
1 of 1 matching line(s) shown
|
|
215
|
+
|
|
216
|
+
$ flownav read "Orphan Code" --lines 2-3
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **Outline** (the default): size, the longest line, function names, and which nodes the code references, all with line numbers, so you can pick a slice without reading blindly.
|
|
220
|
+
- **`--grep <pattern>`**: matching lines with context (`--context`, default 2, up to 10), overlapping windows merged, up to `--max` matches (default 20). Literal and case-insensitive by default; `--regex` and `--case` change that.
|
|
221
|
+
- **`--lines 120-180`** (also `120` or `120-`): numbered lines, with a hint for the next slice. An open-ended range returns 60 lines.
|
|
222
|
+
- **Long lines:** a line over 240 characters is shown windowed, and `--cols 241-480` reads inside it, which is what you need for minified or generated code.
|
|
223
|
+
- **`--param`** reads another text parameter (for example an HTML field); without it, `read` uses `jsCode`, `pythonCode`, `functionCode` or `code`.
|
|
224
|
+
|
|
225
|
+
Every reply is capped at about 6,000 characters whatever you ask for, and secrets in the code are redacted (key-like assignments such as `apiKey = "..."`, and token shapes such as `Bearer ...`; this is best-effort, not a guarantee). `read` takes the code from your exported files, or does one read-only `GET` of that workflow from your instance (it needs the API key in the environment variable named by your profile, `N8N_API_KEY` by default). `--from <folder>` reads from a folder of exports instead. If the workflow changed since the graph was built, the reply starts with a warning and shows the current source. The MCP tool `flownav_read` does the same and cannot be pointed at other folders.
|
|
226
|
+
|
|
193
227
|
## Seeing the graph
|
|
194
228
|
|
|
195
229
|
```bash
|
|
@@ -250,7 +284,11 @@ flownav path <A> <B> [--max N --kinds a,b]
|
|
|
250
284
|
flownav find [--type --cred --text --name --resource --workflow --match --disabled --trigger]
|
|
251
285
|
flownav affected <node> --rename|--delete|--schema|--behavior
|
|
252
286
|
flownav callers <workflow> | calls <workflow> | uses --cred <c>
|
|
253
|
-
flownav
|
|
287
|
+
flownav read <node> [--outline | --grep p [--regex --case --context n --max n] | --lines a-b]
|
|
288
|
+
[--param p --cols a-b --from path] read part of a Code node, capped and redacted
|
|
289
|
+
flownav stats [--from path] measure raw JSON tokens against flownav replies on your workflows
|
|
290
|
+
flownav check [--only a,b] [--fail-on error|warn|info|none]
|
|
291
|
+
checks: fragile-reads, dead-refs, cycles, unreachable, dead-ends, no-error-handler, webhook-no-respond
|
|
254
292
|
flownav view [workflow] [--match text --collapse --focus <node> --depth N --out file.html --open]
|
|
255
293
|
flownav diagram [workflow] [--match text --collapse --focus <node> --depth N --no-reads --dir LR|TD --out file.md]
|
|
256
294
|
flownav diff old.json new.json what changed between two graphs
|
|
@@ -260,12 +298,39 @@ flownav serve MCP server on stdio
|
|
|
260
298
|
|
|
261
299
|
Global options: `--graph <file>` and `--format text|json`. Nodes are addressed as `Name` or `workflow:Name` (workflow id or name).
|
|
262
300
|
|
|
301
|
+
## Use it in CI
|
|
302
|
+
|
|
303
|
+
`flownav check` exits with code 1 when it finds an error, and `--only dead-refs` limits it to broken references (a node that reads a node that no longer exists, a connection to a missing node, a call to a missing workflow). Two ways to feed it, as a GitHub Actions example:
|
|
304
|
+
|
|
305
|
+
```yaml
|
|
306
|
+
name: n8n workflow check
|
|
307
|
+
on: [pull_request]
|
|
308
|
+
jobs:
|
|
309
|
+
flownav:
|
|
310
|
+
runs-on: ubuntu-latest
|
|
311
|
+
steps:
|
|
312
|
+
- uses: actions/checkout@v4
|
|
313
|
+
- uses: actions/setup-node@v4
|
|
314
|
+
with:
|
|
315
|
+
node-version: 22
|
|
316
|
+
# Option A: workflow JSON exported into the repo (e.g. ./workflows)
|
|
317
|
+
- run: npx -y flownav build ./workflows
|
|
318
|
+
# Option B: a live instance with a read-only API key stored as a secret
|
|
319
|
+
# - run: npx -y flownav sync --url ${{ secrets.N8N_URL }}
|
|
320
|
+
# env:
|
|
321
|
+
# N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
|
|
322
|
+
- run: npx -y flownav check --only dead-refs
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
For a manual "before I touch this" step, run `flownav affected "Node" --rename` instead. flownav is static analysis: it can only gate on what it can see, so references built at runtime are reported as unresolved and are not failures.
|
|
326
|
+
|
|
263
327
|
## Privacy and safety
|
|
264
328
|
|
|
265
329
|
- Read-only against n8n: the only network method is `GET`.
|
|
266
330
|
- `pinData` (sample execution data) is dropped on load and never stored.
|
|
267
331
|
- String parameters are redacted before being kept for search: values under secret-looking keys (`apiKey`, `token`, `password`, `Authorization`, ...) and known token shapes (Bearer, `sk-...`, GitHub, Slack, AWS, JWT).
|
|
268
332
|
- Credentials are recorded by id and type only.
|
|
333
|
+
- `flownav read` is the one command that returns code. It redacts key-like assignments and token shapes (best-effort), caps every reply, and for live graphs makes a single read-only `GET` of the one workflow it needs.
|
|
269
334
|
- Execution data is not fetched; `--executions` records only the last status per workflow.
|
|
270
335
|
- `.flownav/` holds your graph and is git-ignored by this repo. Don't commit it or share the HTML views if your workflows are sensitive.
|
|
271
336
|
|
|
@@ -274,7 +339,7 @@ Global options: `--graph <file>` and `--format text|json`. Nodes are addressed a
|
|
|
274
339
|
This is a back-of-the-envelope model, not a measurement of real agent sessions. Tokens are estimated as characters ÷ 4.
|
|
275
340
|
|
|
276
341
|
- **n**: nodes the agent would otherwise read; **τ**: tokens per node in raw n8n JSON (assumed 300, it varies a lot: Code and HTML nodes are far heavier than a Set node); **W = n·τ**.
|
|
277
|
-
- **S ≈ 1,
|
|
342
|
+
- **S ≈ 1,600 tokens**: flownav's fixed overhead per session (the 12 MCP tool definitions, about 1,000 tokens, plus the skill, about 550).
|
|
278
343
|
- **r ≈ 100 tokens**: a typical reply. `explain` and `affected` replies are tens of tokens; a `scan` of a dozen workflows is a few hundred.
|
|
279
344
|
- With prompt caching, text read once costs about 1.25× the first time and about 0.1× on each later turn. For Q questions:
|
|
280
345
|
- raw: `C_raw ≈ W · (1.25 + 0.1·(Q − 1))`
|
|
@@ -287,25 +352,25 @@ Raw reading grows with the workflow; a flownav reply depends only on a node's lo
|
|
|
287
352
|
|
|
288
353
|
| workflow size | raw tokens | 1 question | 10 questions |
|
|
289
354
|
|---|---|---|---|
|
|
290
|
-
| 5 nodes | 1,500 |
|
|
291
|
-
| 10 nodes | 3,000 |
|
|
292
|
-
| 30 nodes | 9,000 |
|
|
293
|
-
| 132 nodes | 40,000 |
|
|
294
|
-
| whole instance (443 nodes) | 133,000 | 99% |
|
|
355
|
+
| 5 nodes | 1,500 | −12% (costs more) | −38% (costs more) |
|
|
356
|
+
| 10 nodes | 3,000 | 44% | 31% |
|
|
357
|
+
| 30 nodes | 9,000 | 81% | 77% |
|
|
358
|
+
| 132 nodes | 40,000 | 96% | 95% |
|
|
359
|
+
| whole instance (443 nodes) | 133,000 | 99% | 98% |
|
|
295
360
|
|
|
296
361
|
**Saving when the agent searches and reads only a fraction of a 132-node workflow:**
|
|
297
362
|
|
|
298
363
|
| fraction read | 1 question | 10 questions |
|
|
299
364
|
|---|---|---|
|
|
300
|
-
| 30% |
|
|
301
|
-
| 20% |
|
|
302
|
-
| 10% |
|
|
365
|
+
| 30% | 86% | 83% |
|
|
366
|
+
| 20% | 79% | 74% |
|
|
367
|
+
| 10% | 58% | 48% |
|
|
303
368
|
|
|
304
369
|
What to take from it:
|
|
305
370
|
|
|
306
371
|
- Below roughly 6 nodes, reading the JSON is cheaper than loading the tools. Around 10 to 30 nodes flownav saves about half to four fifths; on large workflows the saving approaches 100%.
|
|
307
372
|
- Most agents search instead of reading everything, so for large workflows a realistic range is about 55 to 90%; the 96% rows are the best case.
|
|
308
|
-
- τ is the main unknown
|
|
373
|
+
- τ is the main unknown, and `flownav stats` measures it on your own workflows (the bundled examples come out at about 130 tokens per node, well below the 300 assumed here, because they are small and hand-written; real Code and HTML nodes are far heavier). You can also run the same task with and without flownav and compare the usage Claude reports, several times, or measure the size of your own exports, or run the same task with and without flownav and compare the usage Claude reports, several times, since agent runs vary.
|
|
309
374
|
- Tokens are not the only benefit: `affected` finds `$('Name')` references in expressions and Code nodes that a skimming agent misses.
|
|
310
375
|
|
|
311
376
|
## Limitations
|
package/dist/assemble.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CROSS_KINDS } from './types.js';
|
|
2
2
|
import { shortType } from './util.js';
|
|
3
3
|
/** Bump whenever extraction logic changes so cached per-workflow results are not reused. */
|
|
4
|
-
export const BUILDER_VERSION =
|
|
4
|
+
export const BUILDER_VERSION = 5;
|
|
5
5
|
export const wfOf = (g, id) => {
|
|
6
6
|
if (id.startsWith('wf:'))
|
|
7
7
|
return id.slice(3);
|
package/dist/build.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { expressionBlocks, extractRefs } from './expressions.js';
|
|
2
2
|
import { redactString, rlValue, sha, shortType, stable, trunc, walkStrings } from './util.js';
|
|
3
|
+
const MAX_SNIPPETS = 3;
|
|
4
|
+
const clip = (s, n = 140) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
5
|
+
/** `L12: <the line>` for a reference at offset `at` of a code string. */
|
|
6
|
+
function codeSnippet(raw, at) {
|
|
7
|
+
const line = raw.slice(0, at).split('\n').length;
|
|
8
|
+
const start = raw.lastIndexOf('\n', at - 1) + 1;
|
|
9
|
+
let end = raw.indexOf('\n', at);
|
|
10
|
+
if (end < 0)
|
|
11
|
+
end = raw.length;
|
|
12
|
+
return `L${line}: ${clip(raw.slice(start, end).trim())}`;
|
|
13
|
+
}
|
|
3
14
|
const CODE_KEYS = new Set(['jsCode', 'pythonCode', 'functionCode', 'code']);
|
|
4
15
|
const CODE_TYPES = new Set(['code', 'function', 'functionItem']);
|
|
5
16
|
const TRIGGER_NAMES = new Set(['webhook', 'manualTrigger', 'start', 'cron', 'interval']);
|
|
@@ -254,26 +265,35 @@ export function buildWorkflow(raw, id, file) {
|
|
|
254
265
|
const envs = new Set();
|
|
255
266
|
let dynamic = 0;
|
|
256
267
|
walkStrings(n.parameters, (s, path, key) => {
|
|
257
|
-
let body;
|
|
258
268
|
// Only {{ }} blocks are expressions; the rest of an "=" string is literal text
|
|
259
269
|
// (e.g. an HTML page whose browser JS contains $('form')).
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
for (const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
270
|
+
const units = [];
|
|
271
|
+
if (s.startsWith('=')) {
|
|
272
|
+
for (const b of expressionBlocks(s)) {
|
|
273
|
+
const whole = clip(s.slice(b.start, b.end).replace(/\s+/g, ' ').trim());
|
|
274
|
+
units.push({ body: b.body, snip: () => whole });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else if (isCodeNode && CODE_KEYS.has(key)) {
|
|
278
|
+
units.push({ body: s, snip: (at) => codeSnippet(s, at) });
|
|
279
|
+
}
|
|
280
|
+
for (const u of units) {
|
|
281
|
+
const ex = extractRefs(u.body);
|
|
282
|
+
dynamic += ex.dynamic;
|
|
283
|
+
ex.env.forEach((e) => envs.add(`res:env:${e}`));
|
|
284
|
+
ex.vars.forEach((e) => envs.add(`res:var:${e}`));
|
|
285
|
+
for (const name of ex.inferred)
|
|
286
|
+
inf.set(name, new Set([...(inf.get(name) ?? []), path]));
|
|
287
|
+
for (const r of ex.refs) {
|
|
288
|
+
const a = acc.get(r.name) ?? { fields: new Set(), params: new Set(), snips: new Map() };
|
|
289
|
+
r.fields.forEach((f) => a.fields.add(f));
|
|
290
|
+
a.params.add(path);
|
|
291
|
+
for (const at of r.at) {
|
|
292
|
+
const text = u.snip(at);
|
|
293
|
+
a.snips.set(`${path}\u0000${text}`, { param: path, text });
|
|
294
|
+
}
|
|
295
|
+
acc.set(r.name, a);
|
|
296
|
+
}
|
|
277
297
|
}
|
|
278
298
|
});
|
|
279
299
|
for (const [name, a] of acc) {
|
|
@@ -286,6 +306,7 @@ export function buildWorkflow(raw, id, file) {
|
|
|
286
306
|
edges.push({
|
|
287
307
|
from: nid(name), to: nid(n.name), kind: 'reads', confidence: 'EXTRACTED',
|
|
288
308
|
fields: [...a.fields].sort(), params: [...a.params].sort(),
|
|
309
|
+
snippets: [...a.snips.values()].slice(0, MAX_SNIPPETS),
|
|
289
310
|
});
|
|
290
311
|
}
|
|
291
312
|
// Names reached via a loop variable over a literal list: real dependencies, but only INFERRED.
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,9 @@ import { G } from './graph.js';
|
|
|
10
10
|
import { installClaude } from './install.js';
|
|
11
11
|
import { serve } from './mcp.js';
|
|
12
12
|
import { affected, callers, calls, explain, find, neighbors, paths, uses } from './queries.js';
|
|
13
|
+
import { readNode } from './read.js';
|
|
13
14
|
import { report, scan } from './report.js';
|
|
15
|
+
import { stats } from './stats.js';
|
|
14
16
|
import { buildFromFiles, N8nApi, syncApi } from './source.js';
|
|
15
17
|
import { DEFAULT_DIR, graphPath, loadGraph, loadProfiles, saveGraph, saveProfiles, writeText } from './store.js';
|
|
16
18
|
import { FLOW_KINDS } from './types.js';
|
|
@@ -172,16 +174,50 @@ program.command('uses').requiredOption('--cred <c>').action((o) => run(() => emi
|
|
|
172
174
|
program
|
|
173
175
|
.command('check')
|
|
174
176
|
.description(`Health checks (${ALL_CHECKS.join(', ')})`)
|
|
175
|
-
.option('--only <
|
|
177
|
+
.option('--only <names>', 'run only these checks (comma separated)')
|
|
178
|
+
.option('--fail-on <level>', 'exit 1 when a finding is at least this severe: error | warn | info | none', 'error')
|
|
176
179
|
.action((o) => run(() => {
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
const only = o.only ? String(o.only).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
181
|
+
const bad = (only ?? []).filter((x) => !ALL_CHECKS.includes(x));
|
|
182
|
+
if (bad.length)
|
|
183
|
+
throw new Error(`Unknown check "${bad.join(', ')}". Choose from: ${ALL_CHECKS.join(', ')}`);
|
|
184
|
+
const rank = { error: 0, warn: 1, info: 2 };
|
|
185
|
+
const failOn = String(o.failOn);
|
|
186
|
+
if (failOn !== 'none' && !(failOn in rank))
|
|
187
|
+
throw new Error('--fail-on must be one of: error, warn, info, none');
|
|
179
188
|
const g = load();
|
|
180
|
-
const f = runChecks(g,
|
|
189
|
+
const f = runChecks(g, only);
|
|
181
190
|
emit(formatFindings(g, f));
|
|
182
|
-
if (f.some((x) => x.severity
|
|
191
|
+
if (failOn !== 'none' && f.some((x) => rank[x.severity] <= rank[failOn]))
|
|
183
192
|
process.exitCode = 1;
|
|
184
193
|
})());
|
|
194
|
+
program
|
|
195
|
+
.command('read <node>')
|
|
196
|
+
.description("Read part of a node's code in small pieces: outline (default), --grep, or --lines")
|
|
197
|
+
.option('--param <name>', 'parameter to read (default: the code parameter)')
|
|
198
|
+
.option('--lines <range>', '"120-180", "120" or "120-"')
|
|
199
|
+
.option('--grep <pattern>', 'lines matching a pattern, with context')
|
|
200
|
+
.option('--regex', 'treat the grep pattern as a regular expression')
|
|
201
|
+
.option('--case', 'case-sensitive grep')
|
|
202
|
+
.option('--context <n>', 'lines around each grep match', '2')
|
|
203
|
+
.option('--max <n>', 'grep matches to show', '20')
|
|
204
|
+
.option('--outline', 'size, functions and node references with line numbers')
|
|
205
|
+
.option('--cols <range>', 'character window per line, e.g. 241-480')
|
|
206
|
+
.option('--from <path>', 'read the source from this folder of exported workflows')
|
|
207
|
+
.action((n, o) => run(async () => {
|
|
208
|
+
emit(await readNode(load(), {
|
|
209
|
+
node: n, param: o.param, lines: o.lines, grep: o.grep, regex: o.regex, caseSensitive: o.case,
|
|
210
|
+
context: Number(o.context), max: Number(o.max), outline: o.outline, cols: o.cols, from: o.from,
|
|
211
|
+
dir: dirname(opts().graph ?? graphPath()),
|
|
212
|
+
}));
|
|
213
|
+
})());
|
|
214
|
+
program
|
|
215
|
+
.command('stats')
|
|
216
|
+
.description('Measure raw workflow JSON size against flownav replies on your own workflows')
|
|
217
|
+
.option('--from <path>', 'read the workflows from this folder of exports')
|
|
218
|
+
.action((o) => run(async () => {
|
|
219
|
+
emit(await stats(load(), { from: o.from, dir: dirname(opts().graph ?? graphPath()) }));
|
|
220
|
+
})());
|
|
185
221
|
program
|
|
186
222
|
.command('diagram [workflow]')
|
|
187
223
|
.description('Mermaid flowchart of a workflow, everything, or --focus <node>')
|
package/dist/expressions.js
CHANGED
|
@@ -24,35 +24,51 @@ function field(tail, needAccessor) {
|
|
|
24
24
|
.join('.');
|
|
25
25
|
}
|
|
26
26
|
const unescape = (s) => s.replace(/\\(.)/g, '$1');
|
|
27
|
-
/**
|
|
28
|
-
export function
|
|
27
|
+
/** `{{ ... }}` blocks of an expression string: the inner code and where the whole block sits. */
|
|
28
|
+
export function expressionBlocks(s) {
|
|
29
29
|
const out = [];
|
|
30
30
|
const re = /\{\{([\s\S]*?)\}\}/g;
|
|
31
31
|
let m;
|
|
32
32
|
while ((m = re.exec(s)))
|
|
33
|
-
out.push(m[1]);
|
|
34
|
-
return out
|
|
33
|
+
out.push({ body: m[1], start: m.index, end: m.index + m[0].length });
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** Text inside {{ ... }} blocks of an expression string. */
|
|
37
|
+
export function expressionBodies(s) {
|
|
38
|
+
return expressionBlocks(s).map((b) => b.body).join('\n');
|
|
35
39
|
}
|
|
36
40
|
// String literals worth keeping: the argument of $('X') / $items('X') / $node['X'] and ["field"] accesses.
|
|
37
41
|
const KEEP_BEFORE = /(?:\$\(|\$items\(|\$node\[|[\w$\])]\[)\s*$/;
|
|
38
42
|
/**
|
|
39
43
|
* Blank out comments and string-literal contents so text such as an HTML page held in a
|
|
40
44
|
* string (`"...$('form')..."`) is not mistaken for a node reference. Template-literal
|
|
41
|
-
* `${...}` code is kept and scanned.
|
|
45
|
+
* `${...}` code is kept and scanned. The result has the same length as the input (removed
|
|
46
|
+
* characters become spaces, newlines are kept), so offsets and line numbers still line up.
|
|
42
47
|
*/
|
|
43
48
|
export function stripLiterals(src) {
|
|
44
|
-
|
|
49
|
+
const a = src.split('');
|
|
50
|
+
const blank = (from, to) => {
|
|
51
|
+
for (let k = from; k < to; k++)
|
|
52
|
+
if (a[k] !== '\n')
|
|
53
|
+
a[k] = ' ';
|
|
54
|
+
};
|
|
55
|
+
const before = (i) => a.slice(Math.max(0, i - 24), i).join('');
|
|
45
56
|
let i = 0;
|
|
46
57
|
while (i < src.length) {
|
|
47
58
|
const c = src[i];
|
|
48
59
|
if (c === '/' && src[i + 1] === '/') {
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
let j = i;
|
|
61
|
+
while (j < src.length && src[j] !== '\n')
|
|
62
|
+
j++;
|
|
63
|
+
blank(i, j);
|
|
64
|
+
i = j;
|
|
51
65
|
continue;
|
|
52
66
|
}
|
|
53
67
|
if (c === '/' && src[i + 1] === '*') {
|
|
54
68
|
const e = src.indexOf('*/', i + 2);
|
|
55
|
-
|
|
69
|
+
const j = e < 0 ? src.length : e + 2;
|
|
70
|
+
blank(i, j);
|
|
71
|
+
i = j;
|
|
56
72
|
continue;
|
|
57
73
|
}
|
|
58
74
|
if (c === '"' || c === "'") {
|
|
@@ -62,13 +78,16 @@ export function stripLiterals(src) {
|
|
|
62
78
|
j++;
|
|
63
79
|
j++;
|
|
64
80
|
}
|
|
65
|
-
|
|
81
|
+
if (!KEEP_BEFORE.test(before(i)))
|
|
82
|
+
blank(i + 1, Math.min(j, src.length));
|
|
66
83
|
i = j + 1;
|
|
67
84
|
continue;
|
|
68
85
|
}
|
|
69
86
|
if (c === '`') {
|
|
87
|
+
const keep = KEEP_BEFORE.test(before(i));
|
|
88
|
+
const texts = [];
|
|
89
|
+
let segStart = i + 1;
|
|
70
90
|
let j = i + 1;
|
|
71
|
-
let code = '';
|
|
72
91
|
while (j < src.length && src[j] !== '`') {
|
|
73
92
|
if (src[j] === '\\') {
|
|
74
93
|
j += 2;
|
|
@@ -84,20 +103,26 @@ export function stripLiterals(src) {
|
|
|
84
103
|
depth--;
|
|
85
104
|
k++;
|
|
86
105
|
}
|
|
87
|
-
|
|
106
|
+
texts.push([segStart, j]);
|
|
107
|
+
const inner = stripLiterals(src.slice(j + 2, k - 1));
|
|
108
|
+
for (let m = 0; m < inner.length; m++)
|
|
109
|
+
a[j + 2 + m] = inner[m];
|
|
110
|
+
segStart = k;
|
|
88
111
|
j = k;
|
|
89
112
|
continue;
|
|
90
113
|
}
|
|
91
114
|
j++;
|
|
92
115
|
}
|
|
93
|
-
|
|
116
|
+
texts.push([segStart, Math.min(j, src.length)]);
|
|
117
|
+
if (!keep)
|
|
118
|
+
for (const [s, e] of texts)
|
|
119
|
+
blank(s, e);
|
|
94
120
|
i = j + 1;
|
|
95
121
|
continue;
|
|
96
122
|
}
|
|
97
|
-
out += c;
|
|
98
123
|
i++;
|
|
99
124
|
}
|
|
100
|
-
return
|
|
125
|
+
return a.join('');
|
|
101
126
|
}
|
|
102
127
|
const STR = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g;
|
|
103
128
|
/** Names in an array literal made only of string literals, else undefined. */
|
|
@@ -139,17 +164,18 @@ const RE_VARUSE = /\$(?:\(|items\()\s*([A-Za-z_$][\w$]*)\s*[,)]|\$node\s*\[\s*([
|
|
|
139
164
|
export function extractRefs(raw) {
|
|
140
165
|
const src = stripLiterals(raw);
|
|
141
166
|
const byName = new Map();
|
|
142
|
-
const add = (name, f) => {
|
|
143
|
-
const
|
|
167
|
+
const add = (name, at, f) => {
|
|
168
|
+
const cur = byName.get(name) ?? { fields: new Set(), at: [] };
|
|
144
169
|
if (f)
|
|
145
|
-
|
|
146
|
-
|
|
170
|
+
cur.fields.add(f);
|
|
171
|
+
cur.at.push(at);
|
|
172
|
+
byName.set(name, cur);
|
|
147
173
|
};
|
|
148
174
|
const scan = (re, needAccessor) => {
|
|
149
175
|
re.lastIndex = 0;
|
|
150
176
|
let m;
|
|
151
177
|
while ((m = re.exec(src))) {
|
|
152
|
-
add(unescape(m[2]), field(src.slice(re.lastIndex, re.lastIndex + 240), needAccessor));
|
|
178
|
+
add(unescape(m[2]), m.index, field(src.slice(re.lastIndex, re.lastIndex + 240), needAccessor));
|
|
153
179
|
}
|
|
154
180
|
};
|
|
155
181
|
scan(RE_PAREN, true);
|
|
@@ -175,7 +201,7 @@ export function extractRefs(raw) {
|
|
|
175
201
|
hit.forEach((n) => inferred.add(n));
|
|
176
202
|
}
|
|
177
203
|
return {
|
|
178
|
-
refs: [...byName].map(([name,
|
|
204
|
+
refs: [...byName].map(([name, v]) => ({ name, fields: [...v.fields].sort(), at: v.at.sort((x, y) => x - y) })),
|
|
179
205
|
inferred: [...inferred],
|
|
180
206
|
env: names(RE_ENV),
|
|
181
207
|
vars: names(RE_VARS),
|
package/dist/install.js
CHANGED
|
@@ -16,9 +16,10 @@ Then query the graph; do not open workflow JSON unless the graph cannot answer.
|
|
|
16
16
|
- \`flownav path A B\` ordered paths with edge kinds and labels
|
|
17
17
|
- \`flownav neighbors "<node>" --depth 2\`
|
|
18
18
|
- \`flownav find --type httpRequest --cred slack --text "api.example"\`
|
|
19
|
-
- \`flownav affected "<node>" --rename|--delete|--schema|--behavior\` **run before renaming or deleting a node**:
|
|
19
|
+
- \`flownav affected "<node>" --rename|--delete|--schema|--behavior\` **run before renaming or deleting a node**: references like \`$('Old Name')\` are not reliably rewritten (reportedly not inside Code nodes, and not for renames made through the API or by editing JSON), so list them first
|
|
20
20
|
- \`flownav callers <workflow>\` / \`calls <workflow>\` cross-workflow links
|
|
21
21
|
- \`flownav check\` fragile reads, dead refs, unreachable nodes, cycles, missing error handling
|
|
22
|
+
- \`flownav read "<node>"\` read part of a Code node: start with the default outline (size, functions, node references with line numbers), then \`--grep "text"\` or \`--lines 120-180\`. Never read a whole large node at once; every reply is capped and secrets are redacted
|
|
22
23
|
|
|
23
24
|
## When the request is not specific
|
|
24
25
|
|
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { statSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
2
3
|
import { createInterface } from 'node:readline';
|
|
3
4
|
import { ALL_CHECKS, formatFindings, runChecks } from './checks.js';
|
|
4
5
|
import { G } from './graph.js';
|
|
5
6
|
import { affected, callers, calls, explain, find, neighbors, paths, uses } from './queries.js';
|
|
7
|
+
import { readNode } from './read.js';
|
|
6
8
|
import { report, scan } from './report.js';
|
|
7
9
|
import { graphPath, loadGraph } from './store.js';
|
|
8
10
|
import { VERSION } from './version.js';
|
|
@@ -17,7 +19,13 @@ const TOOLS = [
|
|
|
17
19
|
{ name: 'flownav_callers', description: 'Which workflows/nodes call this workflow (Execute Workflow, tool workflows, error workflow, webhook calls).', schema: { workflow: str }, run: (g, a) => callers(g, a.workflow) },
|
|
18
20
|
{ name: 'flownav_calls', description: 'What this workflow calls, plus unresolved dynamic calls.', schema: { workflow: str }, run: (g, a) => calls(g, a.workflow) },
|
|
19
21
|
{ name: 'flownav_uses', description: 'Nodes that use a credential (id, name or type).', schema: { credential: str }, run: (g, a) => uses(g, a.credential) },
|
|
20
|
-
{ name: 'flownav_check', description: `Health checks: ${ALL_CHECKS.join(', ')}.`, schema: { only: str }, run: (g, a) => formatFindings(g, runChecks(g, a.only ?
|
|
22
|
+
{ name: 'flownav_check', description: `Health checks: ${ALL_CHECKS.join(', ')}. Pass only="a,b" to run some.`, schema: { only: str }, run: (g, a) => formatFindings(g, runChecks(g, a.only ? String(a.only).split(',').map((x) => x.trim()) : undefined)) },
|
|
23
|
+
{
|
|
24
|
+
name: 'flownav_read',
|
|
25
|
+
description: 'Read PART of one node\'s code (or other long text) instead of the whole node. Start with the default outline (size, functions, which nodes the code references, with line numbers), then grep for a name or read a line range. Replies are capped at about 6,000 characters and secrets are redacted. Use cols to read inside very long (minified) lines. If the reply says the workflow changed since the graph was built, ask the user to sync. If the name is ambiguous or only a partial match, ask the user which node before acting.',
|
|
26
|
+
schema: { node: str, param: str, lines: str, grep: str, regex: { type: 'boolean' }, caseSensitive: { type: 'boolean' }, context: { type: 'number' }, max: { type: 'number' }, outline: { type: 'boolean' }, cols: str },
|
|
27
|
+
run: (g, a, ctx) => readNode(g, { node: a.node, param: a.param, lines: a.lines, grep: a.grep, regex: a.regex, caseSensitive: a.caseSensitive, context: a.context, max: a.max, outline: a.outline, cols: a.cols, dir: ctx.dir }),
|
|
28
|
+
},
|
|
21
29
|
{ name: 'flownav_report', description: 'Full markdown workflow report.', schema: {}, run: (g) => report(g) },
|
|
22
30
|
];
|
|
23
31
|
export function serve(graphFile) {
|
|
@@ -63,13 +71,9 @@ export function serve(graphFile) {
|
|
|
63
71
|
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
64
72
|
if (!tool)
|
|
65
73
|
return void fail(-32602, `Unknown tool ${params?.name}`);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
reply({ content: [{ type: 'text', text: typeof r === 'string' ? r : r.text }] });
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
reply({ isError: true, content: [{ type: 'text', text: err.message }] });
|
|
72
|
-
}
|
|
74
|
+
Promise.resolve()
|
|
75
|
+
.then(() => tool.run(getG(), params.arguments ?? {}, { dir: dirname(file) }))
|
|
76
|
+
.then((r) => reply({ content: [{ type: 'text', text: typeof r === 'string' ? r : r.text }] }), (err) => reply({ isError: true, content: [{ type: 'text', text: err.message }] }));
|
|
73
77
|
}
|
|
74
78
|
else if (id !== undefined) {
|
|
75
79
|
fail(-32601, `Method not found: ${method}`);
|
package/dist/queries.js
CHANGED
|
@@ -173,13 +173,15 @@ export function affected(g, q, mode = 'rename') {
|
|
|
173
173
|
const readers = g.outE(n.id, ['reads']);
|
|
174
174
|
const lines = [`${g.label(n.id)} — impact of ${mode}`];
|
|
175
175
|
const data = { node: n.id, mode };
|
|
176
|
+
/** the reader line, then the exact expression or code line(s) under it */
|
|
177
|
+
const withSnips = (head, e) => [head, ...(e.snippets ?? []).map((s) => ` ${s.param}: ${s.text}`)];
|
|
176
178
|
const fmtReader = (e) => `${g.label(e.to)}${e.fields?.length ? ` uses {${e.fields.join(', ')}}` : ''} in ${(e.params ?? []).join(', ')}${e.confidence === 'INFERRED' ? ' (inferred from a literal name list)' : ''}`;
|
|
177
179
|
if (mode === 'rename' || mode === 'delete') {
|
|
178
180
|
lines.push(readers.length
|
|
179
181
|
? `${readers.length} node(s) reference "${n.name}" by name and will ${mode === 'rename' ? 'break unless their expressions are rewritten' : 'read a missing node'}:`
|
|
180
182
|
: `No expression references "${n.name}" by name.${mode === 'rename' ? " Safe to rename (connections are keyed by name; update them with the rename)." : ''}`);
|
|
181
183
|
for (const e of readers)
|
|
182
|
-
lines.push(` × ${fmtReader(e)}
|
|
184
|
+
lines.push(...withSnips(` × ${fmtReader(e)}`, e));
|
|
183
185
|
data.breaks = readers;
|
|
184
186
|
}
|
|
185
187
|
if (mode === 'delete') {
|
|
@@ -212,7 +214,7 @@ export function affected(g, q, mode = 'rename') {
|
|
|
212
214
|
if (mode === 'schema') {
|
|
213
215
|
lines.push(readers.length ? 'nodes reading fields from this node:' : 'No node reads this node by name.');
|
|
214
216
|
for (const e of readers)
|
|
215
|
-
lines.push(` ! ${fmtReader(e)}
|
|
217
|
+
lines.push(...withSnips(` ! ${fmtReader(e)}`, e));
|
|
216
218
|
const succ = g.outE(n.id, ['main']);
|
|
217
219
|
if (succ.length) {
|
|
218
220
|
lines.push('direct successors (may use $json / $input, field use is not tracked):');
|
package/dist/read.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { buildWorkflow } from './build.js';
|
|
3
|
+
import { extractRefs } from './expressions.js';
|
|
4
|
+
import { extractWorkflows, loadFiles, N8nApi } from './source.js';
|
|
5
|
+
import { loadProfiles } from './store.js';
|
|
6
|
+
import { redactString } from './util.js';
|
|
7
|
+
/** Hard cap on what one call returns, whatever the request asks for. */
|
|
8
|
+
export const MAX_CHARS = 6000;
|
|
9
|
+
const DEFAULT_WIDTH = 240;
|
|
10
|
+
const MAX_LINE_SCAN = 5000;
|
|
11
|
+
const CODE_PARAMS = ['jsCode', 'pythonCode', 'functionCode', 'code'];
|
|
12
|
+
/* ---------------- source ---------------- */
|
|
13
|
+
export async function loadRaw(g, wfId, o) {
|
|
14
|
+
const info = g.g.workflows[wfId];
|
|
15
|
+
const pick = (wfs) => wfs.find((w) => w.id != null && String(w.id) === wfId) ??
|
|
16
|
+
wfs.find((w) => w.name === info.name) ??
|
|
17
|
+
(wfs.length === 1 ? wfs[0] : undefined);
|
|
18
|
+
if (o.from) {
|
|
19
|
+
const hit = pick(loadFiles(o.from).items.map((i) => i.raw));
|
|
20
|
+
if (!hit)
|
|
21
|
+
throw new Error(`Workflow "${info.name}" (${wfId}) was not found under ${o.from}.`);
|
|
22
|
+
return hit;
|
|
23
|
+
}
|
|
24
|
+
if (g.g.source.type === 'files') {
|
|
25
|
+
if (!info.file)
|
|
26
|
+
throw new Error(`The graph does not record a source file for ${wfId}. Rebuild it, or pass a folder of exports.`);
|
|
27
|
+
let wfs;
|
|
28
|
+
try {
|
|
29
|
+
wfs = extractWorkflows(JSON.parse(readFileSync(info.file, 'utf8')));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error(`Cannot read ${info.file}. Pass the folder of exports to read from.`);
|
|
33
|
+
}
|
|
34
|
+
const hit = pick(wfs);
|
|
35
|
+
if (!hit)
|
|
36
|
+
throw new Error(`Workflow "${info.name}" is not in ${info.file} any more.`);
|
|
37
|
+
delete hit.pinData;
|
|
38
|
+
return hit;
|
|
39
|
+
}
|
|
40
|
+
const profile = g.g.source.profile ? loadProfiles(o.dir)[g.g.source.profile] : undefined;
|
|
41
|
+
const keyEnv = profile?.keyEnv ?? 'N8N_API_KEY';
|
|
42
|
+
const key = process.env[keyEnv];
|
|
43
|
+
if (!key) {
|
|
44
|
+
throw new Error(`Reading code from the live instance needs the API key in the ${keyEnv} environment variable of this process (read-only GET). Set it, or read from exported files instead.`);
|
|
45
|
+
}
|
|
46
|
+
const wf = await new N8nApi(g.g.source.ref, key).get(`/workflows/${encodeURIComponent(wfId)}`);
|
|
47
|
+
delete wf.pinData;
|
|
48
|
+
return wf;
|
|
49
|
+
}
|
|
50
|
+
/* ---------------- parameter + redaction ---------------- */
|
|
51
|
+
function getPath(obj, path) {
|
|
52
|
+
// accepts the paths this module prints, e.g. "assignments.assignments[0].value"
|
|
53
|
+
return path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean).reduce((a, k) => (a == null ? undefined : a[k]), obj);
|
|
54
|
+
}
|
|
55
|
+
function textParams(v, prefix = '', out = [], depth = 0) {
|
|
56
|
+
if (depth > 8 || v == null)
|
|
57
|
+
return out;
|
|
58
|
+
if (typeof v === 'string') {
|
|
59
|
+
if (v.length)
|
|
60
|
+
out.push({ path: prefix, len: v.length });
|
|
61
|
+
}
|
|
62
|
+
else if (Array.isArray(v)) {
|
|
63
|
+
v.forEach((x, i) => textParams(x, `${prefix}[${i}]`, out, depth + 1));
|
|
64
|
+
}
|
|
65
|
+
else if (typeof v === 'object') {
|
|
66
|
+
for (const [k, x] of Object.entries(v))
|
|
67
|
+
textParams(x, prefix ? `${prefix}.${k}` : k, out, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
function listText(node) {
|
|
72
|
+
const rows = textParams(node.parameters).sort((a, b) => b.len - a.len).slice(0, 8);
|
|
73
|
+
return rows.length ? `Text parameters: ${rows.map((r) => `${r.path} (${r.len.toLocaleString('en-US')} chars)`).join(', ')}.` : 'It has no text parameters.';
|
|
74
|
+
}
|
|
75
|
+
function pickParam(node, wanted) {
|
|
76
|
+
const p = (node.parameters ?? {});
|
|
77
|
+
if (wanted) {
|
|
78
|
+
const v = getPath(p, wanted);
|
|
79
|
+
if (typeof v !== 'string')
|
|
80
|
+
throw new Error(`Parameter "${wanted}" of ${node.name} is not text. ${listText(node)}`);
|
|
81
|
+
return { name: wanted, text: v };
|
|
82
|
+
}
|
|
83
|
+
for (const k of CODE_PARAMS)
|
|
84
|
+
if (typeof p[k] === 'string')
|
|
85
|
+
return { name: k, text: p[k] };
|
|
86
|
+
throw new Error(`${node.name} has no code parameter; pass param. ${listText(node)}`);
|
|
87
|
+
}
|
|
88
|
+
const KEYED = /((?:api[-_]?key|token|secret|passw(?:or)?d|authorization|private[-_]?key|client[-_]?secret)["']?\s*[:=]\s*)(["'`])([^"'`\n]{6,})\2/gi;
|
|
89
|
+
/** Best-effort redaction of a line of code: key-like assignments and known token shapes. */
|
|
90
|
+
export function redactCode(line) {
|
|
91
|
+
return redactString(line.replace(KEYED, (_m, pre, q) => `${pre}${q}[REDACTED]${q}`));
|
|
92
|
+
}
|
|
93
|
+
/* ---------------- formatting ---------------- */
|
|
94
|
+
function parseRange(s, what) {
|
|
95
|
+
const m = /^\s*(\d*)\s*(-?)\s*(\d*)\s*$/.exec(s ?? '');
|
|
96
|
+
if (!s || !m || (!m[1] && !m[3]))
|
|
97
|
+
throw new Error(`${what} must look like "120-180", "120" or "120-".`);
|
|
98
|
+
const a = m[1] ? Number(m[1]) : 1;
|
|
99
|
+
const b = m[2] ? (m[3] ? Number(m[3]) : undefined) : a;
|
|
100
|
+
if (a < 1 || (b !== undefined && b < a))
|
|
101
|
+
throw new Error(`${what} "${s}" is not a valid range.`);
|
|
102
|
+
return [a, b];
|
|
103
|
+
}
|
|
104
|
+
/** One line for display: windowed and marked when it is longer than the window. */
|
|
105
|
+
function show(line, cols, focus) {
|
|
106
|
+
let start = 0;
|
|
107
|
+
let end = line.length;
|
|
108
|
+
if (cols) {
|
|
109
|
+
start = cols[0] - 1;
|
|
110
|
+
end = cols[1] ?? start + DEFAULT_WIDTH;
|
|
111
|
+
}
|
|
112
|
+
else if (line.length > DEFAULT_WIDTH) {
|
|
113
|
+
start = focus != null && focus > DEFAULT_WIDTH - 40 ? Math.max(0, focus - 80) : 0;
|
|
114
|
+
end = start + DEFAULT_WIDTH;
|
|
115
|
+
}
|
|
116
|
+
if (start >= line.length && line.length > 0)
|
|
117
|
+
return `[line has ${line.length} chars; the window starts at column ${start + 1}]`;
|
|
118
|
+
const body = line.slice(start, end);
|
|
119
|
+
return `${start > 0 ? '…' : ''}${body}${end < line.length ? `… [+${(line.length - end).toLocaleString('en-US')} chars, ${line.length.toLocaleString('en-US')} total]` : ''}`;
|
|
120
|
+
}
|
|
121
|
+
const lineIndexOf = (text, at) => text.slice(0, at).split('\n').length;
|
|
122
|
+
function outlineOf(text, lines) {
|
|
123
|
+
const out = [];
|
|
124
|
+
const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
125
|
+
out.push(`longest line: ${longest.toLocaleString('en-US')} chars${longest > 1000 ? ' (minified or generated: use cols to read inside long lines)' : ''}`);
|
|
126
|
+
const fns = [];
|
|
127
|
+
lines.forEach((l, i) => {
|
|
128
|
+
const m = /^\s*(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/.exec(l) ??
|
|
129
|
+
/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.exec(l) ??
|
|
130
|
+
/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?function/.exec(l) ??
|
|
131
|
+
/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/.exec(l);
|
|
132
|
+
if (m)
|
|
133
|
+
fns.push(`L${i + 1} ${m[1]}`);
|
|
134
|
+
});
|
|
135
|
+
out.push(fns.length ? `functions (${fns.length}): ${fns.slice(0, 30).join(', ')}${fns.length > 30 ? ', …' : ''}` : 'functions: none found');
|
|
136
|
+
const ex = extractRefs(text);
|
|
137
|
+
if (ex.refs.length || ex.inferred.length) {
|
|
138
|
+
out.push('node references:');
|
|
139
|
+
for (const r of ex.refs) {
|
|
140
|
+
const ls = [...new Set(r.at.map((a) => lineIndexOf(text, a)))];
|
|
141
|
+
out.push(` ${r.name} line${ls.length > 1 ? 's' : ''} ${ls.slice(0, 12).join(', ')}${ls.length > 12 ? ', …' : ''}${r.fields.length ? ` {${r.fields.join(', ')}}` : ''}`);
|
|
142
|
+
}
|
|
143
|
+
if (ex.inferred.length)
|
|
144
|
+
out.push(` via a literal name list (inferred): ${ex.inferred.join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
out.push('node references: none');
|
|
148
|
+
}
|
|
149
|
+
if (ex.dynamic)
|
|
150
|
+
out.push(`computed-name references: ${ex.dynamic} (cannot be resolved statically)`);
|
|
151
|
+
if (ex.env.length)
|
|
152
|
+
out.push(`env vars: ${ex.env.join(', ')}`);
|
|
153
|
+
if (ex.vars.length)
|
|
154
|
+
out.push(`vars: ${ex.vars.join(', ')}`);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/* ---------------- main ---------------- */
|
|
158
|
+
export async function readNode(g, o) {
|
|
159
|
+
const modes = [o.lines != null, o.grep != null, o.outline === true].filter(Boolean).length;
|
|
160
|
+
if (modes > 1)
|
|
161
|
+
throw new Error('Give only one of: lines, grep, outline.');
|
|
162
|
+
const { node: n, fuzzy } = g.resolveNodeEx(o.node);
|
|
163
|
+
const wf = await loadRaw(g, n.wf, o);
|
|
164
|
+
const notes = [];
|
|
165
|
+
if (fuzzy)
|
|
166
|
+
notes.push(`NOTE: "${fuzzy}" was only a partial name and matched just ${g.label(n.id)}. Confirm with the user that this is the node they mean before changing anything.`);
|
|
167
|
+
if (buildWorkflow(wf, n.wf).info.hash !== g.g.workflows[n.wf].hash) {
|
|
168
|
+
notes.push('WARNING: this workflow changed since the graph was built, so line numbers and references may not match the graph. Run flownav sync (or build) to refresh it. Showing the current source.');
|
|
169
|
+
}
|
|
170
|
+
const raw = wf.nodes?.find((x) => x.name === n.name);
|
|
171
|
+
if (!raw)
|
|
172
|
+
throw new Error(`Node "${n.name}" is no longer in the current source of ${g.wfName(n.wf)}. The graph is stale: run flownav sync (or build).`);
|
|
173
|
+
const { name: param, text } = pickParam(raw, o.param);
|
|
174
|
+
const lines = text.split(/\r?\n/);
|
|
175
|
+
const total = lines.length;
|
|
176
|
+
const safe = lines.map(redactCode);
|
|
177
|
+
const cols = o.cols ? parseRange(o.cols, 'cols') : undefined;
|
|
178
|
+
const head = `${g.label(n.id)} · ${param} · ${total.toLocaleString('en-US')} line(s), ${text.length.toLocaleString('en-US')} chars`;
|
|
179
|
+
const out = [...notes, head];
|
|
180
|
+
let used = out.join('\n').length;
|
|
181
|
+
const data = { node: n.id, param, totalLines: total, chars: text.length };
|
|
182
|
+
const push = (s) => {
|
|
183
|
+
if (used + s.length + 1 > MAX_CHARS)
|
|
184
|
+
return false;
|
|
185
|
+
out.push(s);
|
|
186
|
+
used += s.length + 1;
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
if (!text.length) {
|
|
190
|
+
out.push('(empty)');
|
|
191
|
+
return { text: out.join('\n'), data };
|
|
192
|
+
}
|
|
193
|
+
if (o.lines != null) {
|
|
194
|
+
const [a, b] = parseRange(o.lines, 'lines');
|
|
195
|
+
if (a > total)
|
|
196
|
+
throw new Error(`lines "${o.lines}" starts past the end: ${param} has ${total} line(s).`);
|
|
197
|
+
const end = Math.min(b ?? a + 59, total, a + 199);
|
|
198
|
+
const w = String(end).length;
|
|
199
|
+
let shown = a - 1;
|
|
200
|
+
for (let i = a; i <= end; i++) {
|
|
201
|
+
if (!push(` ${String(i).padStart(w)}: ${show(safe[i - 1], cols)}`))
|
|
202
|
+
break;
|
|
203
|
+
shown = i;
|
|
204
|
+
}
|
|
205
|
+
const next = shown < total ? `; next: lines ${shown + 1}-${Math.min(total, shown + 60)}` : ' (end)';
|
|
206
|
+
out.push(`showing ${a}-${shown} of ${total}${shown < end ? ` (output capped at ${MAX_CHARS} chars)` : ''}${next}`);
|
|
207
|
+
Object.assign(data, { mode: 'lines', from: a, to: shown });
|
|
208
|
+
return { text: out.join('\n'), data };
|
|
209
|
+
}
|
|
210
|
+
if (o.grep != null) {
|
|
211
|
+
const pattern = o.grep;
|
|
212
|
+
if (!pattern)
|
|
213
|
+
throw new Error('grep needs a pattern.');
|
|
214
|
+
if (o.regex && pattern.length > 200)
|
|
215
|
+
throw new Error('A regex pattern may be at most 200 characters.');
|
|
216
|
+
let re;
|
|
217
|
+
try {
|
|
218
|
+
re = new RegExp(o.regex ? pattern : pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), o.caseSensitive ? '' : 'i');
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
throw new Error(`Invalid regex: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
const ctx = Math.min(Math.max(o.context ?? 2, 0), 10);
|
|
224
|
+
const max = Math.min(Math.max(o.max ?? 20, 1), 100);
|
|
225
|
+
const hits = [];
|
|
226
|
+
safe.forEach((l, i) => {
|
|
227
|
+
const m = re.exec(l.slice(0, MAX_LINE_SCAN));
|
|
228
|
+
if (m)
|
|
229
|
+
hits.push({ i, col: m.index });
|
|
230
|
+
});
|
|
231
|
+
if (!hits.length) {
|
|
232
|
+
out.push(`no match for ${o.regex ? 'regex ' : ''}"${pattern}"${o.caseSensitive ? '' : ' (case-insensitive)'}. Try a shorter pattern, regex=true, or outline.`);
|
|
233
|
+
Object.assign(data, { mode: 'grep', matches: 0 });
|
|
234
|
+
return { text: out.join('\n'), data };
|
|
235
|
+
}
|
|
236
|
+
const use = hits.slice(0, max);
|
|
237
|
+
const focus = new Map(use.map((h) => [h.i, h.col]));
|
|
238
|
+
const groups = [];
|
|
239
|
+
for (const h of use) {
|
|
240
|
+
const from = Math.max(0, h.i - ctx);
|
|
241
|
+
const to = Math.min(total - 1, h.i + ctx);
|
|
242
|
+
const last = groups[groups.length - 1];
|
|
243
|
+
if (last && from <= last[1] + 1)
|
|
244
|
+
last[1] = Math.max(last[1], to);
|
|
245
|
+
else
|
|
246
|
+
groups.push([from, to]);
|
|
247
|
+
}
|
|
248
|
+
const w = String(total).length;
|
|
249
|
+
let shownMatches = 0;
|
|
250
|
+
let capped = false;
|
|
251
|
+
outer: for (let gi = 0; gi < groups.length; gi++) {
|
|
252
|
+
if (gi > 0 && !push(' --')) {
|
|
253
|
+
capped = true;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
for (let i = groups[gi][0]; i <= groups[gi][1]; i++) {
|
|
257
|
+
const isHit = focus.has(i);
|
|
258
|
+
if (!push(`${isHit ? '>' : ' '} ${String(i + 1).padStart(w)}: ${show(safe[i], cols, focus.get(i))}`)) {
|
|
259
|
+
capped = true;
|
|
260
|
+
break outer;
|
|
261
|
+
}
|
|
262
|
+
if (isHit)
|
|
263
|
+
shownMatches++;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
out.push(`${shownMatches} of ${hits.length} matching line(s) shown${capped ? ` (output capped at ${MAX_CHARS} chars)` : ''}${hits.length > shownMatches ? '; narrow the pattern, raise max, or read a range with lines' : ''}`);
|
|
267
|
+
Object.assign(data, { mode: 'grep', matches: hits.length, shown: shownMatches });
|
|
268
|
+
return { text: out.join('\n'), data };
|
|
269
|
+
}
|
|
270
|
+
// default and explicit outline
|
|
271
|
+
for (const l of outlineOf(text, lines))
|
|
272
|
+
if (!push(l))
|
|
273
|
+
break;
|
|
274
|
+
out.push('next: grep for a name, or read a range with lines');
|
|
275
|
+
Object.assign(data, { mode: 'outline' });
|
|
276
|
+
return { text: out.join('\n'), data };
|
|
277
|
+
}
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { formatFindings, runChecks } from './checks.js';
|
|
2
|
+
import { affected, explain } from './queries.js';
|
|
3
|
+
import { loadRaw } from './read.js';
|
|
4
|
+
import { scan } from './report.js';
|
|
5
|
+
import { trunc } from './util.js';
|
|
6
|
+
const tok = (s) => Math.round(s.length / 4);
|
|
7
|
+
const fmt = (n) => n.toLocaleString('en-US');
|
|
8
|
+
function median(xs) {
|
|
9
|
+
if (!xs.length)
|
|
10
|
+
return 0;
|
|
11
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
12
|
+
const m = Math.floor(s.length / 2);
|
|
13
|
+
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
|
|
14
|
+
}
|
|
15
|
+
const pct = (small, big) => (big > 0 ? `${Math.max(0, Math.round((1 - small / big) * 100))}% fewer` : 'n/a');
|
|
16
|
+
/**
|
|
17
|
+
* Measure, on your own workflows, how much an agent would read without flownav (the whole
|
|
18
|
+
* workflow JSON) against what flownav replies with. Tokens are estimated as characters / 4.
|
|
19
|
+
*/
|
|
20
|
+
export async function stats(g, o) {
|
|
21
|
+
const wfs = Object.values(g.g.workflows);
|
|
22
|
+
const rows = [];
|
|
23
|
+
for (const w of wfs) {
|
|
24
|
+
const raw = await loadRaw(g, w.id, o);
|
|
25
|
+
delete raw.pinData;
|
|
26
|
+
const rawTokens = tok(JSON.stringify(raw, null, 2));
|
|
27
|
+
rows.push({ id: w.id, name: w.name, nodes: w.nodeCount, rawTokens, perNode: w.nodeCount ? Math.round(rawTokens / w.nodeCount) : 0 });
|
|
28
|
+
}
|
|
29
|
+
rows.sort((a, b) => b.rawTokens - a.rawTokens);
|
|
30
|
+
const explainToks = g.g.nodes.map((n) => tok(explain(g, n.id).text));
|
|
31
|
+
const withReaders = g.g.nodes.filter((n) => g.outE(n.id, ['reads']).length);
|
|
32
|
+
const affectedToks = withReaders.map((n) => tok(affected(g, n.id, 'rename').text));
|
|
33
|
+
const scanTok = tok(scan(g).text);
|
|
34
|
+
const checkTok = tok(formatFindings(g, runChecks(g)).text);
|
|
35
|
+
const totalRaw = rows.reduce((a, r) => a + r.rawTokens, 0);
|
|
36
|
+
const totalNodes = rows.reduce((a, r) => a + r.nodes, 0);
|
|
37
|
+
const medRaw = median(rows.map((r) => r.rawTokens));
|
|
38
|
+
const medExplain = median(explainToks);
|
|
39
|
+
const medAffected = median(affectedToks);
|
|
40
|
+
const tau = totalNodes ? Math.round(totalRaw / totalNodes) : 0;
|
|
41
|
+
const w = Math.min(36, Math.max(...rows.map((r) => r.name.length), 8));
|
|
42
|
+
const L = [];
|
|
43
|
+
L.push(`${'workflow'.padEnd(w)} ${'nodes'.padStart(5)} ${'raw tokens'.padStart(10)} ${'per node'.padStart(8)}`);
|
|
44
|
+
for (const r of rows)
|
|
45
|
+
L.push(`${trunc(r.name, w).padEnd(w)} ${String(r.nodes).padStart(5)} ${fmt(r.rawTokens).padStart(10)} ${String(r.perNode).padStart(8)}`);
|
|
46
|
+
L.push(`${'total'.padEnd(w)} ${String(totalNodes).padStart(5)} ${fmt(totalRaw).padStart(10)} ${String(tau).padStart(8)}`);
|
|
47
|
+
L.push('');
|
|
48
|
+
L.push('Cost of one question about one workflow (median):');
|
|
49
|
+
L.push(` read the whole workflow JSON ${fmt(medRaw).padStart(8)} tokens`);
|
|
50
|
+
L.push(` flownav explain ${fmt(medExplain).padStart(8)} tokens ${pct(medExplain, medRaw)}`);
|
|
51
|
+
L.push(` flownav affected --rename ${fmt(medAffected).padStart(8)} tokens ${pct(medAffected, medRaw)}${withReaders.length ? '' : ' (no references found)'}`);
|
|
52
|
+
L.push('');
|
|
53
|
+
L.push('Whole instance:');
|
|
54
|
+
L.push(` read every workflow ${fmt(totalRaw).padStart(8)} tokens`);
|
|
55
|
+
L.push(` flownav scan ${fmt(scanTok).padStart(8)} tokens ${pct(scanTok, totalRaw)}`);
|
|
56
|
+
L.push(` flownav check (all findings) ${fmt(checkTok).padStart(8)} tokens ${pct(checkTok, totalRaw)}`);
|
|
57
|
+
L.push('');
|
|
58
|
+
L.push(`Tokens are estimated as characters / 4 on each workflow's JSON re-printed with 2-space indentation (about ${tau} tokens per node here).`);
|
|
59
|
+
L.push('These compare against an agent that reads the whole workflow. An agent that searches first reads less, so treat the percentages as upper bounds.');
|
|
60
|
+
return {
|
|
61
|
+
text: L.join('\n'),
|
|
62
|
+
data: { workflows: rows, totalRawTokens: totalRaw, tokensPerNode: tau, median: { workflow: medRaw, explain: medExplain, affected: medAffected }, scan: scanTok, check: checkTok },
|
|
63
|
+
};
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flownav",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "See what references an n8n node before you rename it. A typed graph of your workflows with a CLI and an MCP server, so AI agents read a few lines instead of the whole workflow JSON.",
|
|
5
5
|
"author": "Omer Turkenits",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -32,8 +32,15 @@
|
|
|
32
32
|
"keywords": [
|
|
33
33
|
"n8n",
|
|
34
34
|
"workflow",
|
|
35
|
+
"workflow-automation",
|
|
35
36
|
"graph",
|
|
36
37
|
"mcp",
|
|
38
|
+
"model-context-protocol",
|
|
39
|
+
"ai",
|
|
40
|
+
"ai-agents",
|
|
41
|
+
"claude",
|
|
42
|
+
"rename",
|
|
43
|
+
"static-analysis",
|
|
37
44
|
"cli"
|
|
38
45
|
],
|
|
39
46
|
"dependencies": {
|