flownav 0.1.1 → 0.1.3

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
@@ -112,8 +112,10 @@ outputs:
112
112
  $ 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
- ✗ wf-orders:Notify Sales uses {email} in text
116
- ✗ wf-orders:Orphan Code uses {email} in jsCode
115
+ × wf-orders:Notify Sales uses {email} in text
116
+ text: {{ 'Big order from ' + $('Normalize').item.json.email }}
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**
@@ -158,12 +160,12 @@ $ flownav check
158
160
  ERROR dead-refs [wf-orders] Orphan Code reads "Deleted Step", which does not exist
159
161
  WARN fragile-reads [wf-orders] Orphan Code reads $('Normalize') but Normalize is not upstream via main; it may not have run
160
162
  WARN unreachable [wf-orders] Orphan Code is not reachable from any trigger
161
- INFO cycles [wf-orders] loop (Loop Over Items): Ping Self ⇄ Loop Orders
163
+ INFO cycles [wf-orders] loop (Loop Over Items): Ping Self <-> Loop Orders
162
164
  INFO dead-ends [wf-orders] Loop Orders output [0 done] is not connected
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
 
@@ -250,7 +252,8 @@ flownav path <A> <B> [--max N --kinds a,b]
250
252
  flownav find [--type --cred --text --name --resource --workflow --match --disabled --trigger]
251
253
  flownav affected <node> --rename|--delete|--schema|--behavior
252
254
  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]
255
+ flownav check [--only a,b] [--fail-on error|warn|info|none]
256
+ checks: fragile-reads, dead-refs, cycles, unreachable, dead-ends, no-error-handler, webhook-no-respond
254
257
  flownav view [workflow] [--match text --collapse --focus <node> --depth N --out file.html --open]
255
258
  flownav diagram [workflow] [--match text --collapse --focus <node> --depth N --no-reads --dir LR|TD --out file.md]
256
259
  flownav diff old.json new.json what changed between two graphs
@@ -260,6 +263,32 @@ flownav serve MCP server on stdio
260
263
 
261
264
  Global options: `--graph <file>` and `--format text|json`. Nodes are addressed as `Name` or `workflow:Name` (workflow id or name).
262
265
 
266
+ ## Use it in CI
267
+
268
+ `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:
269
+
270
+ ```yaml
271
+ name: n8n workflow check
272
+ on: [pull_request]
273
+ jobs:
274
+ flownav:
275
+ runs-on: ubuntu-latest
276
+ steps:
277
+ - uses: actions/checkout@v4
278
+ - uses: actions/setup-node@v4
279
+ with:
280
+ node-version: 22
281
+ # Option A: workflow JSON exported into the repo (e.g. ./workflows)
282
+ - run: npx -y flownav build ./workflows
283
+ # Option B: a live instance with a read-only API key stored as a secret
284
+ # - run: npx -y flownav sync --url ${{ secrets.N8N_URL }}
285
+ # env:
286
+ # N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
287
+ - run: npx -y flownav check --only dead-refs
288
+ ```
289
+
290
+ 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.
291
+
263
292
  ## Privacy and safety
264
293
 
265
294
  - Read-only against n8n: the only network method is `GET`.
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/checks.js CHANGED
@@ -126,7 +126,7 @@ export function runChecks(g, only) {
126
126
  : undefined;
127
127
  f.push({
128
128
  check: 'cycles', severity: kind ? 'info' : 'warn', wf: w.id,
129
- message: `${kind ?? 'cycle without Loop Over Items or Wait'}: ${comp.map((id) => g.nodeById.get(id).name).join(' ⇄ ')}`,
129
+ message: `${kind ?? 'cycle without Loop Over Items or Wait'}: ${comp.map((id) => g.nodeById.get(id).name).join(' <-> ')}`,
130
130
  });
131
131
  }
132
132
  }
package/dist/cli.js CHANGED
@@ -172,14 +172,21 @@ program.command('uses').requiredOption('--cred <c>').action((o) => run(() => emi
172
172
  program
173
173
  .command('check')
174
174
  .description(`Health checks (${ALL_CHECKS.join(', ')})`)
175
- .option('--only <name>', 'run a single check')
175
+ .option('--only <names>', 'run only these checks (comma separated)')
176
+ .option('--fail-on <level>', 'exit 1 when a finding is at least this severe: error | warn | info | none', 'error')
176
177
  .action((o) => run(() => {
177
- if (o.only && !ALL_CHECKS.includes(o.only))
178
- throw new Error(`Unknown check. Choose: ${ALL_CHECKS.join(', ')}`);
178
+ const only = o.only ? String(o.only).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
179
+ const bad = (only ?? []).filter((x) => !ALL_CHECKS.includes(x));
180
+ if (bad.length)
181
+ throw new Error(`Unknown check "${bad.join(', ')}". Choose from: ${ALL_CHECKS.join(', ')}`);
182
+ const rank = { error: 0, warn: 1, info: 2 };
183
+ const failOn = String(o.failOn);
184
+ if (failOn !== 'none' && !(failOn in rank))
185
+ throw new Error('--fail-on must be one of: error, warn, info, none');
179
186
  const g = load();
180
- const f = runChecks(g, o.only ? [o.only] : undefined);
187
+ const f = runChecks(g, only);
181
188
  emit(formatFindings(g, f));
182
- if (f.some((x) => x.severity === 'error'))
189
+ if (failOn !== 'none' && f.some((x) => rank[x.severity] <= rank[failOn]))
183
190
  process.exitCode = 1;
184
191
  })());
185
192
  program
@@ -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,7 +16,7 @@ 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
22
 
package/dist/mcp.js CHANGED
@@ -17,7 +17,7 @@ const TOOLS = [
17
17
  { 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
18
  { name: 'flownav_calls', description: 'What this workflow calls, plus unresolved dynamic calls.', schema: { workflow: str }, run: (g, a) => calls(g, a.workflow) },
19
19
  { 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)) },
20
+ { 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)) },
21
21
  { name: 'flownav_report', description: 'Full markdown workflow report.', schema: {}, run: (g) => report(g) },
22
22
  ];
23
23
  export function serve(graphFile) {
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') {
@@ -192,7 +194,7 @@ export function affected(g, q, mode = 'rename') {
192
194
  if (orphaned.length) {
193
195
  lines.push('lose their only input (become unreachable):');
194
196
  for (const d of orphaned)
195
- lines.push(` ✗ ${g.label(d)}`);
197
+ lines.push(` × ${g.label(d)}`);
196
198
  }
197
199
  const heads = g.outE(n.id, ['capability']);
198
200
  if (heads.length) {
@@ -204,7 +206,7 @@ export function affected(g, q, mode = 'rename') {
204
206
  if (callers.length) {
205
207
  lines.push('callers that will fail:');
206
208
  for (const e of callers)
207
- lines.push(` ✗ ${g.label(e.from)} (${edgeTag(e)})`);
209
+ lines.push(` × ${g.label(e.from)} (${edgeTag(e)})`);
208
210
  }
209
211
  data.orphaned = orphaned;
210
212
  data.callers = callers;
@@ -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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flownav",
3
- "version": "0.1.1",
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.1.3",
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": {