flowforge-map 0.1.0__py3-none-any.whl
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.
- flowforge/__init__.py +0 -0
- flowforge/_classic.html +289 -0
- flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
- flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
- flowforge/_dashboard/favicon.svg +1 -0
- flowforge/_dashboard/index.html +16 -0
- flowforge/_example/trip-planner/.env.example +3 -0
- flowforge/_example/trip-planner/.gitignore +2 -0
- flowforge/_example/trip-planner/.mcp.json +20 -0
- flowforge/_example/trip-planner/README.md +48 -0
- flowforge/_example/trip-planner/config.py +27 -0
- flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
- flowforge/_example/trip-planner/planner.py +113 -0
- flowforge/_example/trip-planner/requirements.txt +3 -0
- flowforge/_example/trip-planner/services/__init__.py +0 -0
- flowforge/_example/trip-planner/services/geo.py +49 -0
- flowforge/_example/trip-planner/services/llm.py +18 -0
- flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
- flowforge/_example/trip-planner/services/weather.py +25 -0
- flowforge/_example/trip-planner/tools/packing_list.py +24 -0
- flowforge/_example/trip-planner/trips/.gitkeep +0 -0
- flowforge/_workflows/diamond_mock.json +71 -0
- flowforge/_workflows/stripe_to_razorpay.json +38 -0
- flowforge/agent/__init__.py +5 -0
- flowforge/agent/server.py +333 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/agent.py +79 -0
- flowforge/api/checks.py +255 -0
- flowforge/api/connectors.py +289 -0
- flowforge/api/flowmap.py +174 -0
- flowforge/api/runs.py +284 -0
- flowforge/api/scan.py +117 -0
- flowforge/api/security.py +31 -0
- flowforge/api/stats.py +80 -0
- flowforge/checks/__init__.py +13 -0
- flowforge/checks/models.py +41 -0
- flowforge/checks/probes.py +359 -0
- flowforge/cli.py +108 -0
- flowforge/connectors/__init__.py +0 -0
- flowforge/connectors/catalog.py +84 -0
- flowforge/connectors/models.py +149 -0
- flowforge/connectors/presets.py +192 -0
- flowforge/connectors/registry.py +85 -0
- flowforge/connectors/runtime.py +47 -0
- flowforge/connectors/secrets.py +53 -0
- flowforge/example_data.py +247 -0
- flowforge/flow/__init__.py +328 -0
- flowforge/flow/advice.py +132 -0
- flowforge/flow/curly.py +884 -0
- flowforge/flow/index.py +116 -0
- flowforge/flow/order.py +181 -0
- flowforge/flow/reach.py +145 -0
- flowforge/flow/rewrite.py +146 -0
- flowforge/flowmap/__init__.py +1 -0
- flowforge/flowmap/diff.py +145 -0
- flowforge/flowmap/fixture.py +283 -0
- flowforge/flowmap/models.py +258 -0
- flowforge/flowmap/planner.py +131 -0
- flowforge/flowmap/service.py +379 -0
- flowforge/flowmap/validate.py +228 -0
- flowforge/home.py +58 -0
- flowforge/main.py +137 -0
- flowforge/nodes/__init__.py +3 -0
- flowforge/nodes/base.py +74 -0
- flowforge/nodes/check_node.py +50 -0
- flowforge/nodes/http_node.py +161 -0
- flowforge/nodes/llm_node.py +158 -0
- flowforge/nodes/local_node.py +164 -0
- flowforge/nodes/mcp_node.py +243 -0
- flowforge/nodes/mock_node.py +40 -0
- flowforge/scan/__init__.py +6 -0
- flowforge/scan/facts.py +47 -0
- flowforge/scan/files.py +112 -0
- flowforge/scan/manifests.py +65 -0
- flowforge/scan/mcp_config.py +75 -0
- flowforge/scan/models.py +73 -0
- flowforge/scan/patterns.py +86 -0
- flowforge/scan/python_ast.py +246 -0
- flowforge/scan/scanner.py +344 -0
- flowforge/scan/services.py +58 -0
- flowforge/scan/urls.py +60 -0
- flowforge/scheduler/__init__.py +0 -0
- flowforge/scheduler/cache.py +76 -0
- flowforge/scheduler/critical_path.py +65 -0
- flowforge/scheduler/durations.py +35 -0
- flowforge/scheduler/executor.py +390 -0
- flowforge/scheduler/graph.py +108 -0
- flowforge/scheduler/rate_limit.py +36 -0
- flowforge/schema.py +81 -0
- flowforge/security/__init__.py +0 -0
- flowforge/security/finders.py +127 -0
- flowforge/security/findings.py +169 -0
- flowforge/security/logfilter.py +129 -0
- flowforge/spa.py +52 -0
- flowforge/state.py +73 -0
- flowforge/storage.py +481 -0
- flowforge/templating.py +60 -0
- flowforge_map-0.1.0.dist-info/METADATA +335 -0
- flowforge_map-0.1.0.dist-info/RECORD +113 -0
- flowforge_map-0.1.0.dist-info/WHEEL +4 -0
- flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
- flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""`scan(root)`: a repo's dependencies with evidence, from plain code (D20, D21, D22).
|
|
2
|
+
|
|
3
|
+
Read-only: nothing in the repo is imported, run or installed, and nothing is sent anywhere. Per-language
|
|
4
|
+
readers produce `FileFacts`; this module merges them into one `Dependency` per stable id.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from flowforge.scan import manifests, mcp_config, patterns, python_ast, services
|
|
16
|
+
from flowforge.scan.facts import FileFacts, Ref
|
|
17
|
+
from flowforge.scan.files import language, list_files
|
|
18
|
+
from flowforge.scan.models import Dependency, EnvName, ScanEvidence, ScanResult
|
|
19
|
+
from flowforge.scan.urls import api_shaped, clean_url, host_of, is_local, is_not_a_service
|
|
20
|
+
from flowforge.security.findings import ConfigKey, findings_for
|
|
21
|
+
from flowforge.security.logfilter import Redactor, redact_code
|
|
22
|
+
|
|
23
|
+
EXCERPT_RADIUS = 2
|
|
24
|
+
MAX_LINE = 160
|
|
25
|
+
MAX_EVIDENCE = 10 # per dependency; `places` counts them all
|
|
26
|
+
_redactor = Redactor()
|
|
27
|
+
|
|
28
|
+
Constants = dict[str, tuple[str, str, int]] # NAME -> (value, path, line), from every Python file
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _slug(text: str) -> str:
|
|
32
|
+
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:56]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _shared_target(urls: list[str]) -> str:
|
|
36
|
+
"""The address to check (D23): on the host most of the URLs use, the one URL or the longest path they share,
|
|
37
|
+
up to a `/`."""
|
|
38
|
+
hosts = [urlsplit(u).netloc for u in urls]
|
|
39
|
+
host = max(hosts, key=hosts.count) # the first one on a tie
|
|
40
|
+
same_host = [u for u, h in zip(urls, hosts, strict=True) if h == host]
|
|
41
|
+
if len(same_host) == 1:
|
|
42
|
+
return same_host[0]
|
|
43
|
+
first = urlsplit(same_host[0])
|
|
44
|
+
root = f"{first.scheme}://{host}/"
|
|
45
|
+
shared = os.path.commonprefix(same_host)
|
|
46
|
+
shared = shared[:shared.rfind("/") + 1]
|
|
47
|
+
return shared if len(shared) > len(root) else root
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class _Dep:
|
|
52
|
+
kind: str
|
|
53
|
+
name: str
|
|
54
|
+
catalog: str | None = None
|
|
55
|
+
target: str | None = None
|
|
56
|
+
targets: list[str] = field(default_factory=list)
|
|
57
|
+
model: str | None = None
|
|
58
|
+
command: list[str] | None = None
|
|
59
|
+
scope: str = "app"
|
|
60
|
+
vias: set[str] = field(default_factory=set) # MCP servers this host was reached through
|
|
61
|
+
direct: bool = False # …and whether the code also reaches it itself
|
|
62
|
+
env_names: list[str] = field(default_factory=list)
|
|
63
|
+
confirmed: bool = False
|
|
64
|
+
places: list[tuple[str, int, str]] = field(default_factory=list) # (path, line, what it shows)
|
|
65
|
+
|
|
66
|
+
def add(self, path: str, line: int, text: str) -> None:
|
|
67
|
+
if not any(p == path and n == line for p, n, _ in self.places):
|
|
68
|
+
self.places.append((path, line, text))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _Merge:
|
|
72
|
+
def __init__(self, texts: dict[str, list[str]], constants: Constants):
|
|
73
|
+
self.texts = texts
|
|
74
|
+
self.constants = constants
|
|
75
|
+
self.deps: dict[str, _Dep] = {}
|
|
76
|
+
|
|
77
|
+
def resolve(self, ref: Ref) -> tuple[str | None, tuple[str, int] | None]:
|
|
78
|
+
"""A literal's value, or a constant's value and where it is defined."""
|
|
79
|
+
if ref.literal is not None:
|
|
80
|
+
return ref.literal, None
|
|
81
|
+
if ref.name in self.constants:
|
|
82
|
+
value, path, line = self.constants[ref.name]
|
|
83
|
+
return value, (path, line)
|
|
84
|
+
return None, None
|
|
85
|
+
|
|
86
|
+
def service_dep(self, service: dict) -> _Dep:
|
|
87
|
+
"""The one dependency of a known service (D22: all its hosts and SDKs merge into it)."""
|
|
88
|
+
return self.deps.setdefault(f"{service['kind']}-{_slug(service['id'])}",
|
|
89
|
+
_Dep(kind=service["kind"], name=service["name"], catalog=service["id"]))
|
|
90
|
+
|
|
91
|
+
def api(self, url: str, path: str, line: int, confirmed: bool, text: str, via: str | None = None) -> None:
|
|
92
|
+
host = host_of(url)
|
|
93
|
+
if is_local(host) or is_not_a_service(host):
|
|
94
|
+
return
|
|
95
|
+
service = services.by_host(host)
|
|
96
|
+
if service is not None and service["kind"] == "llm":
|
|
97
|
+
return # an LLM's address belongs to the LLM rule
|
|
98
|
+
if service is not None:
|
|
99
|
+
dep = self.service_dep(service)
|
|
100
|
+
else:
|
|
101
|
+
dep = self.deps.setdefault(f"api-{_slug(host)}", _Dep(kind="api", name=host))
|
|
102
|
+
if url not in dep.targets:
|
|
103
|
+
dep.targets.append(url)
|
|
104
|
+
dep.target = _shared_target(dep.targets)
|
|
105
|
+
# a www. page the app opens (a link, an attribution) is not a call, even through Uri.parse (D21)
|
|
106
|
+
page = host.startswith("www.") and service is None and not api_shaped(url)
|
|
107
|
+
dep.confirmed = dep.confirmed or (confirmed and not page) or service is not None or api_shaped(url)
|
|
108
|
+
if via:
|
|
109
|
+
dep.vias.add(via)
|
|
110
|
+
else:
|
|
111
|
+
dep.direct = True
|
|
112
|
+
dep.add(path, line, text)
|
|
113
|
+
|
|
114
|
+
def service_sdks(self, f: FileFacts) -> None:
|
|
115
|
+
"""An imported SDK of a hosted service (stripe, supabase…) is that service, URL or not (D21)."""
|
|
116
|
+
for pkg, line in f.imports:
|
|
117
|
+
service = services.by_sdk(pkg)
|
|
118
|
+
if service is None or service["kind"] != "api":
|
|
119
|
+
continue
|
|
120
|
+
dep = self.service_dep(service)
|
|
121
|
+
dep.confirmed = True
|
|
122
|
+
dep.add(f.path, line, f"imports {pkg.split('.')[0]}")
|
|
123
|
+
|
|
124
|
+
def installed(self, packages: list[tuple[str, str, int]], facts: list[FileFacts]) -> None:
|
|
125
|
+
"""An installed service SDK no code imports: an unconfirmed hint (D21). Imported ones are covered."""
|
|
126
|
+
used = {s["id"] for f in facts for pkg, _ in f.imports if (s := services.by_sdk(pkg))}
|
|
127
|
+
for pkg, path, line in packages:
|
|
128
|
+
service = services.by_sdk(pkg)
|
|
129
|
+
if service is None or service["id"] in used:
|
|
130
|
+
continue
|
|
131
|
+
self.service_dep(service).add(path, line, f"lists {pkg}")
|
|
132
|
+
|
|
133
|
+
def local(self, command: list[str], path: str, line: int) -> None:
|
|
134
|
+
program = command[-1].rsplit("/", 1)[-1]
|
|
135
|
+
stem = program.rsplit(".", 1)[0] if len(command) > 1 else program
|
|
136
|
+
# a script is named for what it runs, "packing list"; a program keeps its own name (D23)
|
|
137
|
+
name = re.sub(r"[_-]+", " ", stem) if len(command) > 1 else program
|
|
138
|
+
dep = self.deps.setdefault(f"local-{_slug(stem)}", _Dep(kind="local", name=name))
|
|
139
|
+
dep.command = dep.command or command
|
|
140
|
+
dep.target = dep.target or " ".join(command)
|
|
141
|
+
dep.confirmed = True
|
|
142
|
+
dep.add(path, line, "runs it")
|
|
143
|
+
|
|
144
|
+
def mcp(self, name: str, command: list[str], env_names: list[str], path: str, line: int, scope: str,
|
|
145
|
+
text: str) -> None:
|
|
146
|
+
dep = self.deps.get(f"mcp-{_slug(name)}")
|
|
147
|
+
if dep is None:
|
|
148
|
+
dep = self.deps[f"mcp-{_slug(name)}"] = _Dep(kind="mcp", name=name, scope=scope)
|
|
149
|
+
elif scope == "app":
|
|
150
|
+
dep.scope = "app" # one place shows the app uses it: it is the app's
|
|
151
|
+
dep.command = dep.command or command
|
|
152
|
+
dep.target = dep.target or " ".join(command) or None
|
|
153
|
+
dep.env_names = sorted({*dep.env_names, *env_names})
|
|
154
|
+
dep.confirmed = True
|
|
155
|
+
dep.add(path, line, text)
|
|
156
|
+
|
|
157
|
+
def stdio(self, f: FileFacts) -> None:
|
|
158
|
+
"""An MCP server the code starts itself, named by its package: StdioServerParameters, StdioClientTransport."""
|
|
159
|
+
for command, line in f.stdio:
|
|
160
|
+
name = next((a for a in command[1:] if not a.startswith("-") and a != "."), command[0])
|
|
161
|
+
self.mcp(name.split("@", 2)[1] if name.startswith("@") and name.count("@") > 1 else name, command, [],
|
|
162
|
+
f.path, line, "app", "starts it")
|
|
163
|
+
|
|
164
|
+
def llms(self, f: FileFacts) -> None:
|
|
165
|
+
"""One LLM per provider: the client's base URL decides it, else the SDK's own service."""
|
|
166
|
+
sdks = [(pkg, line, s) for pkg, line in f.imports if (s := services.by_sdk(pkg)) and s["kind"] == "llm"]
|
|
167
|
+
if not sdks:
|
|
168
|
+
self.routed_llms(f)
|
|
169
|
+
return
|
|
170
|
+
found: list[_Dep] = []
|
|
171
|
+
clients = [c for c in f.clients if (s := services.by_sdk(c.sdk)) and s["kind"] == "llm"]
|
|
172
|
+
for client in clients:
|
|
173
|
+
base, defined = self.resolve(client.base_url)
|
|
174
|
+
url = clean_url(base) if base else None
|
|
175
|
+
service = services.by_host(host_of(url)) if url else None
|
|
176
|
+
if url and service is None: # a provider we don't know: named by its address
|
|
177
|
+
dep = self.deps.setdefault(f"llm-{_slug(host_of(url))}", _Dep(kind="llm", name=host_of(url)))
|
|
178
|
+
else:
|
|
179
|
+
service = service or services.by_sdk(client.sdk)
|
|
180
|
+
dep = self.deps.setdefault(f"llm-{_slug(service['id'])}",
|
|
181
|
+
_Dep(kind="llm", name=service["name"], catalog=service["id"]))
|
|
182
|
+
dep.target = dep.target or (host_of(url) if url else None)
|
|
183
|
+
dep.add(f.path, client.line, "creates the client")
|
|
184
|
+
if defined:
|
|
185
|
+
dep.add(*defined, "its address")
|
|
186
|
+
found.append(dep)
|
|
187
|
+
if not found: # imported, no client seen: the SDK's own provider
|
|
188
|
+
found.append(self.service_dep(sdks[0][2]))
|
|
189
|
+
for dep in found:
|
|
190
|
+
dep.confirmed = True
|
|
191
|
+
for pkg, line, _ in sdks:
|
|
192
|
+
dep.add(f.path, line, f"imports {pkg.split('.')[0]}")
|
|
193
|
+
for ref, line in f.models:
|
|
194
|
+
value, defined = self.resolve(ref)
|
|
195
|
+
if value:
|
|
196
|
+
dep.model = dep.model or value
|
|
197
|
+
dep.add(*(defined or (f.path, line)), "the model")
|
|
198
|
+
|
|
199
|
+
def routed_llms(self, f: FileFacts) -> None:
|
|
200
|
+
"""An SDK that routes to many providers (litellm): each model name says which provider it is (D22)."""
|
|
201
|
+
routers = [(pkg, line) for pkg, line in f.imports if services.is_router(pkg)]
|
|
202
|
+
if not routers:
|
|
203
|
+
return
|
|
204
|
+
for ref, line in f.models:
|
|
205
|
+
value, defined = self.resolve(ref)
|
|
206
|
+
service = services.by_model(value) if value else None
|
|
207
|
+
dep = self.service_dep(service) if service else self.deps.setdefault(
|
|
208
|
+
"llm-litellm", _Dep(kind="llm", name="LiteLLM"))
|
|
209
|
+
dep.model = dep.model or value
|
|
210
|
+
dep.confirmed = True
|
|
211
|
+
dep.add(*(defined or (f.path, line)), "the model")
|
|
212
|
+
for pkg, at in routers:
|
|
213
|
+
dep.add(f.path, at, f"imports {pkg.split('.')[0]}")
|
|
214
|
+
|
|
215
|
+
def evidence(self, path: str, line: int, text: str) -> ScanEvidence:
|
|
216
|
+
lines = self.texts.get(path, [])
|
|
217
|
+
first = max(1, line - EXCERPT_RADIUS)
|
|
218
|
+
last = min(len(lines), line + EXCERPT_RADIUS)
|
|
219
|
+
excerpt = [redact_code(raw, _redactor)[:MAX_LINE] for raw in lines[first - 1:last]]
|
|
220
|
+
return ScanEvidence(ref=f"{path}:{line}", text=text, first_line=first, lines=excerpt, highlight=line)
|
|
221
|
+
|
|
222
|
+
def result(self, env: dict[tuple[str, int], list[str]]) -> list[Dependency]:
|
|
223
|
+
out = []
|
|
224
|
+
for dep_id, d in self.deps.items():
|
|
225
|
+
places = sorted(d.places, key=lambda p: (p[0], p[1]))
|
|
226
|
+
names = list(dict.fromkeys([*d.env_names, *(n for p, line, _ in places for n in env.get((p, line), []))]))
|
|
227
|
+
out.append(Dependency(
|
|
228
|
+
id=dep_id, kind=d.kind, name=d.name, catalog=d.catalog, target=d.target, targets=d.targets,
|
|
229
|
+
model=d.model, command=d.command, scope=d.scope,
|
|
230
|
+
via=next(iter(d.vias)) if len(d.vias) == 1 and not d.direct else None, # only ever through it
|
|
231
|
+
status="confirmed" if d.confirmed else "unconfirmed",
|
|
232
|
+
evidence=[self.evidence(p, n, t) for p, n, t in places[:MAX_EVIDENCE]], places=len(places),
|
|
233
|
+
env_names=names))
|
|
234
|
+
return sorted(out, key=lambda d: (d.kind, d.name.lower()))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _config_scope(rel: str, servers: list[mcp_config.Server], facts: list[FileFacts]) -> str:
|
|
238
|
+
"""D21: a config's servers are the app's when its code names the config file or a server in it; else they
|
|
239
|
+
serve your coding tools. A server name only counts when code that imports `mcp` passes it to a call, so a
|
|
240
|
+
common word such as "time" elsewhere doesn't make a config the app's (D22)."""
|
|
241
|
+
config_name = rel.rsplit("/", 1)[-1]
|
|
242
|
+
if any(config_name in f.strings or rel in f.strings for f in facts):
|
|
243
|
+
return "app"
|
|
244
|
+
mcp_code = [f for f in facts if any(pkg == "mcp" or pkg.startswith("mcp.") for pkg, _ in f.imports)]
|
|
245
|
+
named = any(s.name in f.call_strings for s in servers for f in mcp_code)
|
|
246
|
+
return "app" if named else "dev_tool"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class Places:
|
|
251
|
+
"""What the scan saw, for readers that follow calls (D25): every place behind each dependency, uncapped, as
|
|
252
|
+
(path, line, what it shows); the MCP servers the app itself uses; and the facts of each Python file."""
|
|
253
|
+
|
|
254
|
+
by_dep: dict[str, list[tuple[str, int, str]]]
|
|
255
|
+
app_servers: set[str]
|
|
256
|
+
facts: dict[str, FileFacts]
|
|
257
|
+
code: dict[str, str] = field(default_factory=dict) # every code file read -> its language (D32)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def scan(root: str | Path) -> ScanResult:
|
|
261
|
+
"""A repo's dependencies with evidence (D21): plain code, read-only, nothing sent (D20)."""
|
|
262
|
+
return scan_with_places(root)[0]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def scan_with_places(root: str | Path) -> tuple[ScanResult, Places]:
|
|
266
|
+
root = Path(root).resolve()
|
|
267
|
+
listing = list_files(root)
|
|
268
|
+
texts: dict[str, list[str]] = {}
|
|
269
|
+
facts: list[FileFacts] = []
|
|
270
|
+
configs: list[tuple[str, list[mcp_config.Server]]] = []
|
|
271
|
+
installed: list[tuple[str, str, int]] = [] # (package, file, line) from dependency files
|
|
272
|
+
languages: dict[str, int] = {}
|
|
273
|
+
for rel in listing.files:
|
|
274
|
+
text = (root / rel).read_text(encoding="utf-8", errors="replace")
|
|
275
|
+
lang = language(rel)
|
|
276
|
+
if mcp_config.is_config(rel):
|
|
277
|
+
configs.append((rel, mcp_config.parse(text)))
|
|
278
|
+
languages["json"] = languages.get("json", 0) + 1
|
|
279
|
+
elif manifests.is_manifest(rel):
|
|
280
|
+
installed += [(pkg, rel, line) for pkg, line in manifests.parse(rel, text)]
|
|
281
|
+
elif lang == "python":
|
|
282
|
+
try:
|
|
283
|
+
facts.append(python_ast.extract(rel, text))
|
|
284
|
+
except SyntaxError:
|
|
285
|
+
listing.skip("unparsable")
|
|
286
|
+
continue
|
|
287
|
+
elif lang is not None:
|
|
288
|
+
facts.append(patterns.extract(rel, text, lang))
|
|
289
|
+
texts[rel] = text.splitlines()
|
|
290
|
+
if lang:
|
|
291
|
+
languages[lang] = languages.get(lang, 0) + 1
|
|
292
|
+
|
|
293
|
+
constants: Constants = {name: (value, f.path, line) for f in facts for name, (value, line) in f.constants.items()}
|
|
294
|
+
merge = _Merge(texts, constants)
|
|
295
|
+
app_servers: set[str] = set()
|
|
296
|
+
for rel, servers in configs:
|
|
297
|
+
scope = _config_scope(rel, servers, facts)
|
|
298
|
+
for server in servers:
|
|
299
|
+
merge.mcp(server.name, server.command, server.env_names, rel, server.line, scope, "configured here")
|
|
300
|
+
if scope == "app":
|
|
301
|
+
app_servers |= {server.name for server in servers}
|
|
302
|
+
for f in facts:
|
|
303
|
+
merge.llms(f)
|
|
304
|
+
merge.service_sdks(f)
|
|
305
|
+
merge.stdio(f)
|
|
306
|
+
for command, line in f.commands:
|
|
307
|
+
merge.local(command, f.path, line)
|
|
308
|
+
for hit in f.urls:
|
|
309
|
+
via = next((f"mcp-{_slug(s)}" for s in hit.call_strings if s in app_servers), None)
|
|
310
|
+
if hit.url:
|
|
311
|
+
merge.api(hit.url, f.path, hit.line, hit.confirmed, "calls it" if hit.confirmed else "the address", via)
|
|
312
|
+
elif hit.name in constants:
|
|
313
|
+
value, where, at = constants[hit.name]
|
|
314
|
+
url = clean_url(value)
|
|
315
|
+
if url:
|
|
316
|
+
merge.api(url, where, at, hit.confirmed, "the address", via)
|
|
317
|
+
merge.api(url, f.path, hit.line, hit.confirmed, "calls it" if hit.confirmed else "uses the address",
|
|
318
|
+
via)
|
|
319
|
+
|
|
320
|
+
merge.installed(installed, facts)
|
|
321
|
+
|
|
322
|
+
env: dict[tuple[str, int], list[str]] = {}
|
|
323
|
+
seen: dict[str, str] = {}
|
|
324
|
+
for f in facts:
|
|
325
|
+
for name, line in f.env:
|
|
326
|
+
env.setdefault((f.path, line), []).append(name)
|
|
327
|
+
seen.setdefault(name, f"{f.path}:{line}")
|
|
328
|
+
dependencies = merge.result(env)
|
|
329
|
+
by_dep = {dep_id: sorted(d.places) for dep_id, d in merge.deps.items()}
|
|
330
|
+
config_keys = [ConfigKey(f"mcp-{_slug(server.name)}", server.name, name, rel, line)
|
|
331
|
+
for rel, servers in configs for server in servers for name, line in server.env_literals]
|
|
332
|
+
|
|
333
|
+
def excerpt(path: str, line: int) -> tuple[int, list[str]]:
|
|
334
|
+
ev = merge.evidence(path, line, "")
|
|
335
|
+
return ev.first_line or line, ev.lines
|
|
336
|
+
|
|
337
|
+
findings = findings_for(texts, {rel: language(rel) for rel in texts}, dependencies, by_dep, config_keys, excerpt)
|
|
338
|
+
result = ScanResult(root=root.name, files_scanned=len(listing.files), skipped=listing.skipped,
|
|
339
|
+
languages=languages, dependencies=dependencies,
|
|
340
|
+
env_names=[EnvName(name=n, ref=r) for n, r in sorted(seen.items())], findings=findings,
|
|
341
|
+
findings_checked=True)
|
|
342
|
+
places = Places(by_dep=by_dep, app_servers=app_servers, facts={f.path: f for f in facts if f.language == "python"},
|
|
343
|
+
code={f.path: f.language for f in facts})
|
|
344
|
+
return result, places
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Known services for the scan, from `connectors.presets.SERVICES` (brands live only there, D10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from flowforge.connectors.presets import MODEL_PREFIXES, ROUTER_SDKS, SERVICES
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_package(package: str) -> str:
|
|
11
|
+
"""Package names compare case-insensitively, with - _ . alike (PyPI vs import names)."""
|
|
12
|
+
return re.sub(r"[-_.]+", "_", package.lower())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_BY_SDK = {normalize_package(sdk): s for s in SERVICES for sdk in s["sdks"]}
|
|
16
|
+
_BY_ID = {s["id"]: s for s in SERVICES}
|
|
17
|
+
_ROUTERS = {normalize_package(p) for p in ROUTER_SDKS}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def by_id(service_id: str) -> dict | None:
|
|
21
|
+
return _BY_ID.get(service_id)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def by_host(host: str) -> dict | None:
|
|
25
|
+
"""The service a host (or host:port) belongs to. A leading "." in the table matches any subdomain."""
|
|
26
|
+
name = host.split(":")[0]
|
|
27
|
+
for s in SERVICES:
|
|
28
|
+
for h in s["hosts"]:
|
|
29
|
+
if host == h or name == h or (h.startswith(".") and name.endswith(h)):
|
|
30
|
+
return s
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def by_sdk(package: str) -> dict | None:
|
|
35
|
+
"""The service an imported package stands for: exact, or a sub-module of it (google.genai.types).
|
|
36
|
+
|
|
37
|
+
Only dotted sub-modules are trimmed, so a package of your own called `groq_utils` never matches Groq.
|
|
38
|
+
"""
|
|
39
|
+
parts = package.split(".")
|
|
40
|
+
for end in range(len(parts), 0, -1):
|
|
41
|
+
service = _BY_SDK.get(normalize_package(".".join(parts[:end])))
|
|
42
|
+
if service is not None:
|
|
43
|
+
return service
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_router(package: str) -> bool:
|
|
48
|
+
"""An SDK that routes to many LLM providers (litellm): the model name says which."""
|
|
49
|
+
return normalize_package(package.split(".")[0]) in _ROUTERS
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def by_model(model: str) -> dict | None:
|
|
53
|
+
"""The provider a model name points to: "anthropic/claude-…" or "claude-…" → Claude."""
|
|
54
|
+
name = model.lower()
|
|
55
|
+
for service_id, prefixes in MODEL_PREFIXES.items():
|
|
56
|
+
if name.startswith(prefixes):
|
|
57
|
+
return _BY_ID.get(service_id)
|
|
58
|
+
return None
|
flowforge/scan/urls.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""URLs as the scan keeps them (D21): scheme, host and path only. Credentials, query and fragment are dropped."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
_TEMPLATE = re.compile(r"[{}$<>]")
|
|
10
|
+
_API_PATH = re.compile(r"/api(/|$)|/v\d+(\.\d+)?(/|$)")
|
|
11
|
+
# never a dependency: documentation and namespace hosts (D21)
|
|
12
|
+
_NOT_SERVICES = ("example.com", "example.org", "example.net", "w3.org", "json-schema.org", "schemas.xmlsoap.org")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def clean_url(text: str) -> str | None:
|
|
16
|
+
"""`text` reduced to scheme://host[:port]/path when the whole string starts with an http(s) URL, else None."""
|
|
17
|
+
if not text.startswith(("http://", "https://")) or len(text) > 2000:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
parts = urlsplit(text)
|
|
21
|
+
host, port = parts.hostname, parts.port
|
|
22
|
+
except ValueError:
|
|
23
|
+
return None
|
|
24
|
+
if not host:
|
|
25
|
+
return None
|
|
26
|
+
labels = [label for label in host.split(".") if label and not _TEMPLATE.search(label)] # {s}.tile.x.org
|
|
27
|
+
if len(labels) < 1:
|
|
28
|
+
return None
|
|
29
|
+
netloc = ".".join(labels) + (f":{port}" if port else "")
|
|
30
|
+
path = re.split(r"[{$<\s]", parts.path)[0][:120] # stop before template parts: /driving/$path, /{z}/
|
|
31
|
+
return f"{parts.scheme}://{netloc}{path}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def host_of(url: str) -> str:
|
|
35
|
+
"""host[:port] of a cleaned URL."""
|
|
36
|
+
return urlsplit(url).netloc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_local(host: str) -> bool:
|
|
40
|
+
"""localhost, private and link-local addresses, `.local` names: on this machine or its network."""
|
|
41
|
+
name = host.rsplit(":", 1)[0] if host.count(":") == 1 else host
|
|
42
|
+
if name in ("localhost", "0.0.0.0") or name.endswith((".local", ".localhost", ".internal")):
|
|
43
|
+
return True
|
|
44
|
+
try:
|
|
45
|
+
ip = ipaddress.ip_address(name.strip("[]"))
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
return ip.is_private or ip.is_loopback or ip.is_link_local
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_not_a_service(host: str) -> bool:
|
|
52
|
+
"""Documentation and namespace hosts: example.com, w3.org, json-schema.org."""
|
|
53
|
+
name = host.split(":")[0]
|
|
54
|
+
return any(name == h or name.endswith("." + h) for h in _NOT_SERVICES)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def api_shaped(url: str) -> bool:
|
|
58
|
+
"""A host that starts with `api.`, or a path with /api/ or a version segment like /v1/."""
|
|
59
|
+
parts = urlsplit(url)
|
|
60
|
+
return parts.netloc.startswith("api.") or bool(_API_PATH.search(parts.path))
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Call dedupe: single-flight in-run memo + safe persistent cache (D3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cache_key(step_type: str, resolved_params: dict[str, Any]) -> str:
|
|
13
|
+
canonical = json.dumps(resolved_params, sort_keys=True, separators=(",", ":"), default=str)
|
|
14
|
+
return hashlib.sha256(f"{step_type}\x00{canonical}".encode()).hexdigest()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CallCache:
|
|
18
|
+
"""One instance per run.
|
|
19
|
+
|
|
20
|
+
get_or_run(key, fn, persistent):
|
|
21
|
+
1. finished result in memo → return it (hit)
|
|
22
|
+
2. same key currently in flight → await the same asyncio.Future (hit, single-flight)
|
|
23
|
+
3. persistent and in storage → return stored (hit)
|
|
24
|
+
4. otherwise run fn(), store the result (memo, and storage if persistent)
|
|
25
|
+
Failures are NOT cached; waiters on a failed call see the same exception.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, storage: Any | None = None):
|
|
29
|
+
self.storage = storage
|
|
30
|
+
self.hits = 0
|
|
31
|
+
self.misses = 0
|
|
32
|
+
self._done: dict[str, Any] = {}
|
|
33
|
+
self._inflight: dict[str, asyncio.Future[Any]] = {}
|
|
34
|
+
|
|
35
|
+
def has(self, key: str) -> bool:
|
|
36
|
+
"""True if a call for `key` is finished or running — awaiting it needs no worker slot."""
|
|
37
|
+
return key in self._done or key in self._inflight
|
|
38
|
+
|
|
39
|
+
async def get_or_run(
|
|
40
|
+
self, key: str, fn: Callable[[], Awaitable[Any]], persistent: bool
|
|
41
|
+
) -> tuple[Any, bool]:
|
|
42
|
+
"""Returns (value, cache_hit)."""
|
|
43
|
+
if key in self._done:
|
|
44
|
+
self.hits += 1
|
|
45
|
+
return self._done[key], True
|
|
46
|
+
if key in self._inflight:
|
|
47
|
+
self.hits += 1
|
|
48
|
+
# shield: one waiter being cancelled must not cancel the shared call
|
|
49
|
+
return await asyncio.shield(self._inflight[key]), True
|
|
50
|
+
if persistent and self.storage is not None:
|
|
51
|
+
found, value = self.storage.get_cached(key)
|
|
52
|
+
if found:
|
|
53
|
+
self.hits += 1
|
|
54
|
+
self._done[key] = value
|
|
55
|
+
return value, True
|
|
56
|
+
|
|
57
|
+
self.misses += 1
|
|
58
|
+
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
59
|
+
self._inflight[key] = future
|
|
60
|
+
try:
|
|
61
|
+
value = await fn()
|
|
62
|
+
except BaseException as exc:
|
|
63
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
64
|
+
future.cancel()
|
|
65
|
+
else:
|
|
66
|
+
future.set_exception(exc)
|
|
67
|
+
future.exception() # mark retrieved: no "never retrieved" warning if nobody waits
|
|
68
|
+
raise
|
|
69
|
+
finally:
|
|
70
|
+
del self._inflight[key]
|
|
71
|
+
|
|
72
|
+
self._done[key] = value
|
|
73
|
+
future.set_result(value)
|
|
74
|
+
if persistent and self.storage is not None:
|
|
75
|
+
self.storage.put_cached(key, value)
|
|
76
|
+
return value, False
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Critical path + bottom levels via DP over the DAG (D1, D5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from flowforge.scheduler.graph import DAG, topological_order
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CriticalPath:
|
|
12
|
+
path: list[str] # bottleneck chain, source → sink
|
|
13
|
+
length_ms: float # sum of weights along it
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def critical_path(dag: DAG, weights: dict[str, float]) -> CriticalPath:
|
|
17
|
+
"""Longest weighted path. DP in topological order:
|
|
18
|
+
finish[v] = weights[v] + max(finish[p] for p in parents[v], default 0);
|
|
19
|
+
keep the argmax parent to reconstruct the path. O(V + E).
|
|
20
|
+
|
|
21
|
+
Ties go to the earlier-declared parent / earlier sink, so the answer is deterministic.
|
|
22
|
+
"""
|
|
23
|
+
order = topological_order(dag)
|
|
24
|
+
finish: dict[str, float] = {}
|
|
25
|
+
via: dict[str, str | None] = {}
|
|
26
|
+
for v in order:
|
|
27
|
+
best = None
|
|
28
|
+
for p in dag.parents[v]:
|
|
29
|
+
if best is None or finish[p] > finish[best]:
|
|
30
|
+
best = p
|
|
31
|
+
via[v] = best
|
|
32
|
+
finish[v] = weights[v] + (finish[best] if best is not None else 0)
|
|
33
|
+
|
|
34
|
+
end = order[0]
|
|
35
|
+
for v in order:
|
|
36
|
+
if finish[v] > finish[end]:
|
|
37
|
+
end = v
|
|
38
|
+
|
|
39
|
+
path: list[str] = []
|
|
40
|
+
node: str | None = end
|
|
41
|
+
while node is not None:
|
|
42
|
+
path.append(node)
|
|
43
|
+
node = via[node]
|
|
44
|
+
return CriticalPath(path=path[::-1], length_ms=finish[end])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def bottom_levels(dag: DAG, weights: dict[str, float]) -> dict[str, float]:
|
|
48
|
+
"""Scheduling priority (HLFET). DP in *reverse* topological order:
|
|
49
|
+
bl[v] = weights[v] + max(bl[c] for c in children[v], default 0).
|
|
50
|
+
|
|
51
|
+
max(bl) equals the critical path length — the step with the highest
|
|
52
|
+
bottom level heads the longest remaining chain, so it should start first.
|
|
53
|
+
"""
|
|
54
|
+
bl: dict[str, float] = {}
|
|
55
|
+
for v in reversed(topological_order(dag)):
|
|
56
|
+
bl[v] = weights[v] + max((bl[c] for c in dag.children[v]), default=0)
|
|
57
|
+
return bl
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def lower_bound_ms(dag: DAG, weights: dict[str, float], k: int) -> float:
|
|
61
|
+
"""max(critical path length, total work / k) — no schedule on k slots can beat this:
|
|
62
|
+
the critical chain must run one step after another, and k slots can do at most
|
|
63
|
+
k units of work per unit of time.
|
|
64
|
+
"""
|
|
65
|
+
return max(critical_path(dag, weights).length_ms, sum(weights[v] for v in dag.nodes) / k)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Step-duration estimates: history (EWMA) → estimated_ms → per-type default (D1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flowforge.schema import Step, Workflow
|
|
6
|
+
|
|
7
|
+
DEFAULT_ESTIMATE_MS: dict[str, float] = {"llm": 3000, "mcp": 1000, "http": 300, "local": 1000, "check": 1000}
|
|
8
|
+
EWMA_ALPHA = 0.3
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_estimate(step: Step) -> float:
|
|
12
|
+
if step.type == "mock":
|
|
13
|
+
return float(step.params.get("duration_ms", 100))
|
|
14
|
+
return DEFAULT_ESTIMATE_MS[step.type]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ewma(previous: float | None, observed: float, alpha: float = EWMA_ALPHA) -> float:
|
|
18
|
+
return observed if previous is None else alpha * observed + (1 - alpha) * previous
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def estimate_weights(workflow: Workflow, history: dict[str, float] | None = None) -> dict[str, float]:
|
|
22
|
+
"""Weight per step id. `history` maps step id → stored EWMA for this workflow (from storage.py).
|
|
23
|
+
|
|
24
|
+
Fallback chain: measured history → the step's estimated_ms → per-type default.
|
|
25
|
+
"""
|
|
26
|
+
history = history or {}
|
|
27
|
+
weights: dict[str, float] = {}
|
|
28
|
+
for step in workflow.steps:
|
|
29
|
+
if step.id in history:
|
|
30
|
+
weights[step.id] = history[step.id]
|
|
31
|
+
elif step.estimated_ms is not None:
|
|
32
|
+
weights[step.id] = step.estimated_ms
|
|
33
|
+
else:
|
|
34
|
+
weights[step.id] = default_estimate(step)
|
|
35
|
+
return weights
|