flownav 0.1.2 → 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 +32 -3
- package/dist/assemble.js +1 -1
- package/dist/build.js +40 -19
- package/dist/cli.js +12 -5
- package/dist/expressions.js +47 -21
- package/dist/install.js +1 -1
- package/dist/mcp.js +1 -1
- package/dist/queries.js +4 -2
- 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
|
|
|
@@ -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
|
|
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
|
+
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
|
@@ -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 <
|
|
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
|
-
|
|
178
|
-
|
|
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,
|
|
187
|
+
const f = runChecks(g, only);
|
|
181
188
|
emit(formatFindings(g, f));
|
|
182
|
-
if (f.some((x) => x.severity
|
|
189
|
+
if (failOn !== 'none' && f.some((x) => rank[x.severity] <= rank[failOn]))
|
|
183
190
|
process.exitCode = 1;
|
|
184
191
|
})());
|
|
185
192
|
program
|
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,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**:
|
|
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 ?
|
|
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') {
|
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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": {
|