flownav 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omer Turkenits
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,331 @@
1
- # Temporary Holding Version
1
+ # flownav
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/OmerTurkenits/flownav/actions/workflows/ci.yml/badge.svg)](https://github.com/OmerTurkenits/flownav/actions/workflows/ci.yml)
4
+
5
+ **A typed node graph for n8n workflows, so an AI agent (or you) can navigate a workflow without reading raw JSON.**
6
+
7
+ flownav turns exported workflow JSON, or a live n8n instance, into a queryable graph: connections, AI agent tools and models, expression references between nodes, cross-workflow calls, credentials and shared resources. An agent asks for one node's card or "what breaks if I rename this?" and gets a few lines back instead of thousands of lines of JSON.
8
+
9
+ It is read-only against n8n. Inspired by [graphify](https://github.com/safishamsi/graphify), which does the same for code.
10
+
11
+ ## Why
12
+
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.
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
+
17
+ ## Install
18
+
19
+ Needs Node 22.12 or newer.
20
+
21
+ ```bash
22
+ npm install -g flownav # then use: flownav ... (or the short alias: fnav)
23
+ # or without installing:
24
+ npx flownav --help
25
+ ```
26
+
27
+ ## Try it in a minute
28
+
29
+ The repo ships three small example workflows in [`examples/`](examples). They contain deliberately planted problems.
30
+
31
+ ```bash
32
+ git clone https://github.com/OmerTurkenits/flownav && cd flownav
33
+ flownav build examples # -> .flownav/graph.json and .flownav/WORKFLOW_REPORT.md
34
+ flownav check
35
+ flownav view --open # interactive graph in your browser
36
+ ```
37
+
38
+ ## An example graph
39
+
40
+ `examples/orders.json` is a webhook that routes orders, calls a sub-workflow, has an AI agent with a tool, a loop, and a Code node with problems. This is what `flownav diagram wf-orders` draws (GitHub renders it):
41
+
42
+ ```mermaid
43
+ flowchart LR
44
+ n0(["Order Webhook<br/>Webhook POST /orders (responseNode)"])
45
+ n1["Normalize"]
46
+ n2{"Is Large Order"}
47
+ n3["Notify Sales"]
48
+ n4["Save Order"]
49
+ n5["Enrich"]
50
+ n6["Reply"]
51
+ n7{{"Support Agent"}}
52
+ n8["Chat Model"]
53
+ n9["Lookup Order"]
54
+ n10[/"Loop Orders"/]
55
+ n11["Ping Self"]
56
+ n12["Orphan Code"]
57
+ n13(["Chat Trigger"])
58
+ n14(["Nightly"])
59
+ n0 --> n1
60
+ n1 --> n2
61
+ n2 -->|"true"| n3
62
+ n2 -->|"false"| n4
63
+ n3 --> n6
64
+ n4 --> n5
65
+ n5 --> n6
66
+ n8 -.->|"ai_languageModel"| n7
67
+ n9 -.->|"ai_tool"| n7
68
+ n10 -->|"loop"| n11
69
+ n11 --> n10
70
+ n13 --> n7
71
+ n14 --> n10
72
+ n1 -.->|"reads email"| n3
73
+ n5 -.->|"reads customerId"| n6
74
+ n3 -.->|"reads ok"| n6
75
+ n1 -.->|"reads email"| n12
76
+ n11 ==>|"self"| n0
77
+ ```
78
+
79
+ Solid arrows are connections (with branch labels such as `true`/`false`/`loop`). Dotted arrows are AI sub-nodes and expression reads (`reads email` means the node uses the `email` field of another node by name). Thick arrows are cross-workflow or webhook calls. Triggers are rounded, IF/Switch are diamonds, agents are hexagons.
80
+
81
+ With `--collapse` you get one box per workflow, which is the readable view for many workflows (`flownav diagram --collapse`):
82
+
83
+ ```mermaid
84
+ flowchart LR
85
+ w0["Enrich customer<br/>3 nodes"]
86
+ w1["Error handler<br/>2 nodes"]
87
+ w2["Order intake<br/>15 nodes"]
88
+ w2 ==>|"2× calls"| w0
89
+ w2 ==>|"on error"| w1
90
+ ```
91
+
92
+ ## Example queries
93
+
94
+ Everything below is real output from the examples.
95
+
96
+ **A node's card**
97
+
98
+ ```text
99
+ $ flownav explain "Is Large Order"
100
+ wf-orders:Is Large Order [if v2] (group "Intake")
101
+ IF 1 condition(s)
102
+ inputs:
103
+ ← wf-orders:Normalize
104
+ outputs:
105
+ [0 true] → wf-orders:Notify Sales
106
+ [1 false] → wf-orders:Save Order
107
+ ```
108
+
109
+ **What breaks if I rename this node?**
110
+
111
+ ```text
112
+ $ flownav affected "Normalize" --rename
113
+ wf-orders:Normalize — impact of rename
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
117
+ ```
118
+
119
+ **A path, even across workflows**
120
+
121
+ ```text
122
+ $ flownav path "Save Order" "Shape"
123
+ 1 shortest path(s), 4 hop(s):
124
+ #1 wf-orders:Save Order —main→ wf-orders:Enrich —calls EXTRACTED→ wf-enrich:When called —main→ wf-enrich:Fetch CRM —main→ wf-enrich:Shape
125
+ ```
126
+
127
+ **An AI agent as a card**
128
+
129
+ ```text
130
+ $ flownav explain "Support Agent"
131
+ wf-orders:Support Agent [agent v1.7]
132
+ AI Agent
133
+ inputs:
134
+ ← wf-orders:Chat Model [ai_languageModel] (sub-node)
135
+ ← wf-orders:Lookup Order [ai_tool] (sub-node)
136
+ ← wf-orders:Chat Trigger
137
+ outputs:
138
+ (none)
139
+ agent card:
140
+ ai_languageModel: wf-orders:Chat Model
141
+ ai_tool: wf-orders:Lookup Order
142
+ ```
143
+
144
+ **Who calls this workflow?**
145
+
146
+ ```text
147
+ $ flownav callers wf-enrich
148
+ Enrich customer (wf-enrich) is called by 2 edge(s):
149
+ wf-orders:Enrich (calls EXTRACTED)
150
+ wf-orders:Lookup Order (calls:tool EXTRACTED)
151
+ ```
152
+
153
+ **Health checks**
154
+
155
+ ```text
156
+ $ flownav check
157
+ 6 finding(s)
158
+ ERROR dead-refs [wf-orders] Orphan Code reads "Deleted Step", which does not exist
159
+ WARN fragile-reads [wf-orders] Orphan Code reads $('Normalize') but Normalize is not upstream via main; it may not have run
160
+ 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
162
+ INFO dead-ends [wf-orders] Loop Orders output [0 done] is not connected
163
+ INFO no-error-handler [wf-enrich] Enrich customer has no error workflow and no node-level error handling
164
+ ```
165
+
166
+ `check` exits with code 1 when it finds an `ERROR`, so it can run in CI.
167
+
168
+ ## Using it with an AI agent
169
+
170
+ ```bash
171
+ flownav install claude --mcp # writes .claude/skills/flownav/SKILL.md and .mcp.json
172
+ ```
173
+
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.
175
+
176
+ 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
+
178
+ ### When the request is not specific
179
+
180
+ Tool replies are written for an agent to act on. If a name matches several nodes or workflows, flownav refuses to pick one and says so:
181
+
182
+ ```text
183
+ $ flownav explain order
184
+ error: "order" matches 5 nodes. Do not guess: ask the user which one they mean. Then use the workflow:node form.
185
+ 1. Order intake › Order Webhook [webhook] use: wf-orders:Order Webhook
186
+ 2. Order intake › Is Large Order [if] use: wf-orders:Is Large Order
187
+ 3. Order intake › Save Order [googleSheets] use: wf-orders:Save Order
188
+ ...
189
+ ```
190
+
191
+ 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
+
193
+ ## Seeing the graph
194
+
195
+ ```bash
196
+ flownav view --open # interactive HTML: pan, zoom, search, click a node for its card
197
+ flownav view "My workflow" --open # one workflow
198
+ flownav view --match Order --collapse # every workflow whose name contains "Order", one box each
199
+ flownav view --focus "Some node" --depth 2
200
+ flownav diagram --match Order --out orders.md # Mermaid text instead
201
+ ```
202
+
203
+ `view` writes a single self-contained HTML file (no CDN, no network requests, no external fonts). Click a node to dim everything else and see its card and connections; tick or untick edge kinds to declutter. `--collapse` draws one box per workflow with the calls between them. The files contain your node names and webhook paths, so treat them like the workflows themselves.
204
+
205
+ ## Live n8n
206
+
207
+ ```bash
208
+ export N8N_API_KEY=... # read from the environment, never written to disk or logs
209
+ flownav profile add prod --url https://n8n.example.com
210
+ flownav sync --profile prod --executions
211
+ flownav status --check # is the graph stale?
212
+ ```
213
+
214
+ Only `GET /api/v1/...` requests are ever sent (`X-N8N-API-KEY` header, cursor pagination). Syncs are incremental: a workflow whose `updatedAt` and `versionId` are unchanged is reused, and `--full` forces a refetch. A read-only API key is enough. Profiles store the URL and the *name* of the environment variable holding the key, never the key.
215
+
216
+ ## What it models
217
+
218
+ Edges point in the direction of data or control flow and carry a `kind`, an optional `label`, optional `fields`, and a `confidence`.
219
+
220
+ | kind | meaning | confidence |
221
+ |---|---|---|
222
+ | `main` | connection; labels `true`/`false`, `case N` or the rule's output name, `loop`/`done`, `error` | EXPLICIT |
223
+ | `capability` | `ai_*` sub-node (model, tool, memory) into its agent | EXPLICIT |
224
+ | `reads` | an expression or Code node reads another node, with field names (`$('X')`, `$node["X"]`, `$items("X")`) | EXTRACTED |
225
+ | `calls` | Execute Workflow / agent tool workflow into the callee's trigger | EXTRACTED by id, INFERRED by name |
226
+ | `on_error` | `settings.errorWorkflow` | EXTRACTED |
227
+ | `webhook_calls` | HTTP Request to `/webhook/<path>` matched to a Webhook node | INFERRED |
228
+ | `responds_via` | Webhook to a reachable Respond to Webhook | EXTRACTED |
229
+ | `uses` | node to credential (by id, never the secret) | EXPLICIT |
230
+ | `reads_resource` / `writes_resource` / `uses_resource` | same sheet, table, channel, `$env` or `$vars` across nodes | INFERRED / EXTRACTED |
231
+
232
+ Details that matter:
233
+
234
+ - Only `{{ }}` blocks inside `=` strings, and the source of Code nodes, are scanned for references. String literals and comments inside code are ignored, so text such as browser JavaScript held in a string (`$('form')`) is not mistaken for a node reference.
235
+ - In Code nodes, `$(n)` where `n` iterates a literal list (`for (const n of ["A","B"])`, `.forEach`, `const n = "A"`) yields INFERRED `reads` edges to each name.
236
+ - Other dynamic references (computed node names, runtime workflow ids, embedded or URL sub-workflows) are recorded as *unresolved* and listed in the report. They are never guessed.
237
+ - Sticky notes are not nodes; nodes inside a sticky's rectangle get its title as their group.
238
+
239
+ ## Commands
240
+
241
+ ```text
242
+ flownav build <path> files -> graph + report (--out dir, --no-report)
243
+ flownav sync [--profile p | --url u] live n8n, incremental (--active --tags --name --project --executions --full)
244
+ flownav status [--check] what is loaded; --check compares with the instance
245
+ flownav profile add|list|remove base URL + the NAME of the env var holding the key
246
+ flownav report | scan full report | compact inventory
247
+ flownav explain <node>
248
+ flownav neighbors <node> [--depth N --dir in|out|both --kinds a,b]
249
+ flownav path <A> <B> [--max N --kinds a,b]
250
+ flownav find [--type --cred --text --name --resource --workflow --match --disabled --trigger]
251
+ flownav affected <node> --rename|--delete|--schema|--behavior
252
+ 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]
254
+ flownav view [workflow] [--match text --collapse --focus <node> --depth N --out file.html --open]
255
+ flownav diagram [workflow] [--match text --collapse --focus <node> --depth N --no-reads --dir LR|TD --out file.md]
256
+ flownav diff old.json new.json what changed between two graphs
257
+ flownav install claude [--global] [--mcp]
258
+ flownav serve MCP server on stdio
259
+ ```
260
+
261
+ Global options: `--graph <file>` and `--format text|json`. Nodes are addressed as `Name` or `workflow:Name` (workflow id or name).
262
+
263
+ ## Privacy and safety
264
+
265
+ - Read-only against n8n: the only network method is `GET`.
266
+ - `pinData` (sample execution data) is dropped on load and never stored.
267
+ - 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
+ - Credentials are recorded by id and type only.
269
+ - Execution data is not fetched; `--executions` records only the last status per workflow.
270
+ - `.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
+
272
+ ## Token savings (an estimate, not a benchmark)
273
+
274
+ This is a back-of-the-envelope model, not a measurement of real agent sessions. Tokens are estimated as characters ÷ 4.
275
+
276
+ - **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).
278
+ - **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
+ - 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
+ - raw: `C_raw ≈ W · (1.25 + 0.1·(Q − 1))`
281
+ - flownav: `C_flow ≈ S · (1.25 + 0.1·(Q − 1)) + Q · r`
282
+ - Saving = `1 − C_flow / C_raw`.
283
+
284
+ Raw reading grows with the workflow; a flownav reply depends only on a node's local connections.
285
+
286
+ **Saving when the agent reads the whole workflow:**
287
+
288
+ | workflow size | raw tokens | 1 question | 10 questions |
289
+ |---|---|---|---|
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% |
295
+
296
+ **Saving when the agent searches and reads only a fraction of a 132-node workflow:**
297
+
298
+ | fraction read | 1 question | 10 questions |
299
+ |---|---|---|
300
+ | 30% | 89% | 85% |
301
+ | 20% | 83% | 78% |
302
+ | 10% | 66% | 56% |
303
+
304
+ What to take from it:
305
+
306
+ - 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
+ - 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.
309
+ - Tokens are not the only benefit: `affected` finds `$('Name')` references in expressions and Code nodes that a skimming agent misses.
310
+
311
+ ## Limitations
312
+
313
+ - It is static analysis. It does not run workflows and cannot see references built at runtime; those are listed as unresolved.
314
+ - `fragile-reads` uses upstream-via-main reachability, not dominators, so a read placed after a Merge of both branches is treated as safe.
315
+ - Edges observed in real executions (an OBSERVED confidence level) are not implemented.
316
+ - The n8n export format is not formally specified. It has been exercised on the examples here and on one real instance of about a dozen workflows; fields may differ on other n8n versions. Please open an issue with a (sanitized) export if something is misparsed.
317
+ - The interactive viewer uses a simple layered layout, so edges can cross or overlap in dense workflows.
318
+ - flownav reads and checks; it does not edit workflows. Pair it with whatever tool you use to edit them.
319
+
320
+ ## Development
321
+
322
+ ```bash
323
+ npm install
324
+ npm test # builds, then runs node --test on test/
325
+ ```
326
+
327
+ Source is TypeScript under `src/`; the CLI entry is `src/cli.ts`. `examples/` holds the fixtures used by the tests and this README.
328
+
329
+ ## License
330
+
331
+ MIT
@@ -0,0 +1,127 @@
1
+ import { CROSS_KINDS } from './types.js';
2
+ import { shortType } from './util.js';
3
+ /** Bump whenever extraction logic changes so cached per-workflow results are not reused. */
4
+ export const BUILDER_VERSION = 4;
5
+ export const wfOf = (g, id) => {
6
+ if (id.startsWith('wf:'))
7
+ return id.slice(3);
8
+ const i = id.indexOf('::');
9
+ return i > 0 && g.workflows[id.slice(0, i)] ? id.slice(0, i) : undefined;
10
+ };
11
+ /** Recompute all cross-workflow edges (calls, on_error, webhook_calls) from node data. */
12
+ export function link(workflows, nodes) {
13
+ const edges = [];
14
+ const unresolved = [];
15
+ const byName = new Map();
16
+ for (const w of Object.values(workflows))
17
+ byName.set(w.name, [...(byName.get(w.name) ?? []), w.id]);
18
+ const entryOf = (wf, type) => nodes.find((n) => n.wf === wf && shortType(n.type) === type)?.id;
19
+ const resolve = (idOrName, name) => {
20
+ if (idOrName && workflows[idOrName])
21
+ return { id: idOrName, inferred: false };
22
+ for (const cand of [name, idOrName]) {
23
+ const hit = cand ? byName.get(cand) : undefined;
24
+ if (hit?.length === 1)
25
+ return { id: hit[0], inferred: true };
26
+ }
27
+ return { inferred: false };
28
+ };
29
+ for (const n of nodes) {
30
+ for (const c of n.calls ?? []) {
31
+ if (c.external) {
32
+ unresolved.push({ scope: 'cross', wf: n.wf, node: n.name, kind: 'external-subworkflow', detail: `${n.name} runs a sub-workflow from "${c.external}"` });
33
+ }
34
+ else if (c.dynamic) {
35
+ unresolved.push({ scope: 'cross', wf: n.wf, node: n.name, kind: 'dynamic-call', detail: `${n.name} calls a workflow chosen at runtime` });
36
+ }
37
+ else {
38
+ const r = resolve(c.targetId, c.targetName);
39
+ if (!r.id) {
40
+ unresolved.push({ scope: 'cross', wf: n.wf, node: n.name, kind: 'missing-workflow', detail: `${n.name} calls workflow ${c.targetId ?? c.targetName}, which is not in the graph` });
41
+ }
42
+ else {
43
+ edges.push({
44
+ from: n.id,
45
+ to: entryOf(r.id, 'executeWorkflowTrigger') ?? `wf:${r.id}`,
46
+ kind: 'calls',
47
+ label: c.via === 'toolWorkflow' ? 'tool' : undefined,
48
+ confidence: r.inferred ? 'INFERRED' : 'EXTRACTED',
49
+ });
50
+ }
51
+ }
52
+ }
53
+ }
54
+ for (const w of Object.values(workflows)) {
55
+ if (!w.errorWorkflow)
56
+ continue;
57
+ const r = resolve(w.errorWorkflow);
58
+ if (!r.id) {
59
+ unresolved.push({ scope: 'cross', wf: w.id, kind: 'missing-workflow', detail: `error workflow ${w.errorWorkflow} is not in the graph` });
60
+ }
61
+ else {
62
+ edges.push({ from: `wf:${w.id}`, to: entryOf(r.id, 'errorTrigger') ?? `wf:${r.id}`, kind: 'on_error', confidence: 'EXTRACTED' });
63
+ }
64
+ }
65
+ // HTTP Request to /webhook/<path> matched against Webhook node paths.
66
+ const hooks = nodes
67
+ .filter((n) => n.webhook?.path)
68
+ .map((n) => ({
69
+ n,
70
+ re: new RegExp(`^${n.webhook.path.split('/').map((s) => (s.startsWith(':') ? '[^/]+' : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/')}$`),
71
+ }));
72
+ for (const n of nodes) {
73
+ for (const url of n.httpUrls ?? []) {
74
+ const m = /\/webhook(-test)?\/([^\s?#"'{}]+)/.exec(url);
75
+ if (!m)
76
+ continue;
77
+ const path = m[2].replace(/\/+$/, '');
78
+ for (const h of hooks) {
79
+ if (h.n.wf !== n.wf && h.re.test(path)) {
80
+ edges.push({ from: n.id, to: h.n.id, kind: 'webhook_calls', label: m[1] ? 'test' : undefined, confidence: 'INFERRED' });
81
+ }
82
+ else if (h.n.wf === n.wf && h.re.test(path)) {
83
+ edges.push({ from: n.id, to: h.n.id, kind: 'webhook_calls', label: 'self', confidence: 'INFERRED' });
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return { edges, unresolved };
89
+ }
90
+ export function assemble(results, source) {
91
+ const workflows = {};
92
+ const nodes = [];
93
+ const edges = [];
94
+ const unresolved = [];
95
+ for (const r of results) {
96
+ workflows[r.info.id] = r.info;
97
+ nodes.push(...r.nodes);
98
+ edges.push(...r.edges.filter((e) => !CROSS_KINDS.includes(e.kind)));
99
+ unresolved.push(...r.unresolved.filter((u) => u.scope === 'local'));
100
+ }
101
+ const cross = link(workflows, nodes);
102
+ edges.push(...cross.edges);
103
+ unresolved.push(...cross.unresolved);
104
+ return { version: 1, builder: BUILDER_VERSION, builtAt: new Date().toISOString(), source, workflows, nodes, edges, unresolved };
105
+ }
106
+ /** Split a stored graph back into per-workflow results (cross edges dropped) for incremental sync. */
107
+ export function splitGraph(g) {
108
+ const owner = new Map();
109
+ for (const n of g.nodes)
110
+ owner.set(n.id, n.wf);
111
+ const res = new Map();
112
+ for (const info of Object.values(g.workflows))
113
+ res.set(info.id, { info, nodes: [], edges: [], unresolved: [] });
114
+ for (const n of g.nodes)
115
+ res.get(n.wf)?.nodes.push(n);
116
+ for (const e of g.edges) {
117
+ if (CROSS_KINDS.includes(e.kind))
118
+ continue;
119
+ const wf = owner.get(e.from) ?? owner.get(e.to);
120
+ if (wf)
121
+ res.get(wf)?.edges.push(e);
122
+ }
123
+ for (const u of g.unresolved)
124
+ if (u.scope === 'local')
125
+ res.get(u.wf)?.unresolved.push(u);
126
+ return res;
127
+ }