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 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.** When you rename or delete a node, n8n updates the connections but not `$('Old Name')` inside other nodes' expressions or Code nodes. Those silently break. `flownav affected "Old Name" --rename` lists exactly which nodes and parameters will break, before you change anything.
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`, so it can run in CI.
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 check [--only fragile-reads|dead-refs|cycles|unreachable|dead-ends|no-error-handler|webhook-no-respond]
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,300 tokens**: flownav's fixed overhead per session (the 11 MCP tool definitions, about 800 tokens, plus the skill, about 500).
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 | 8% | −18% (costs more) |
291
- | 10 nodes | 3,000 | 54% | 41% |
292
- | 30 nodes | 9,000 | 85% | 80% |
293
- | 132 nodes | 40,000 | 97% | 96% |
294
- | whole instance (443 nodes) | 133,000 | 99% | 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% | 89% | 85% |
301
- | 20% | 83% | 78% |
302
- | 10% | 66% | 56% |
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. To get a real figure, 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.
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;
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 { expressionBodies, extractRefs } from './expressions.js';
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
- if (s.startsWith('='))
261
- body = expressionBodies(s);
262
- else if (isCodeNode && CODE_KEYS.has(key))
263
- body = s;
264
- if (!body)
265
- return;
266
- const ex = extractRefs(body);
267
- dynamic += ex.dynamic;
268
- ex.env.forEach((e) => envs.add(`res:env:${e}`));
269
- ex.vars.forEach((e) => envs.add(`res:var:${e}`));
270
- for (const name of ex.inferred)
271
- inf.set(name, new Set([...(inf.get(name) ?? []), path]));
272
- for (const r of ex.refs) {
273
- const a = acc.get(r.name) ?? { fields: new Set(), params: new Set() };
274
- r.fields.forEach((f) => a.fields.add(f));
275
- a.params.add(path);
276
- acc.set(r.name, a);
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 <name>', 'run a single check')
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
- if (o.only && !ALL_CHECKS.includes(o.only))
178
- throw new Error(`Unknown check. Choose: ${ALL_CHECKS.join(', ')}`);
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, o.only ? [o.only] : undefined);
189
+ const f = runChecks(g, only);
181
190
  emit(formatFindings(g, f));
182
- if (f.some((x) => x.severity === 'error'))
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>')
@@ -24,35 +24,51 @@ function field(tail, needAccessor) {
24
24
  .join('.');
25
25
  }
26
26
  const unescape = (s) => s.replace(/\\(.)/g, '$1');
27
- /** Text inside {{ ... }} blocks of an expression string. */
28
- export function expressionBodies(s) {
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.join('\n');
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
- let out = '';
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
- while (i < src.length && src[i] !== '\n')
50
- i++;
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
- i = e < 0 ? src.length : e + 2;
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
- out += KEEP_BEFORE.test(out) ? src.slice(i, j + 1) : c + c;
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
- code += ` ${stripLiterals(src.slice(j + 2, k - 1))} `;
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
- out += KEEP_BEFORE.test(out) ? src.slice(i, j + 1) : '``' + code;
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 out;
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 set = byName.get(name) ?? new Set();
167
+ const add = (name, at, f) => {
168
+ const cur = byName.get(name) ?? { fields: new Set(), at: [] };
144
169
  if (f)
145
- set.add(f);
146
- byName.set(name, set);
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, f]) => ({ name, fields: [...f].sort() })),
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**: expressions like \`$('Old Name')\` are not updated by n8n when connections are
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 ? [a.only] : undefined)) },
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
- try {
67
- const r = tool.run(getG(), params.arguments ?? {});
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.1.2",
4
- "description": "Typed node graph for n8n workflows: explain, path, affected (rename/delete safety), checks, and an MCP server so agents navigate workflows with far fewer tokens.",
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": {