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.
Files changed (113) hide show
  1. flowforge/__init__.py +0 -0
  2. flowforge/_classic.html +289 -0
  3. flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  4. flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  5. flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  6. flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  7. flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  8. flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  9. flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  10. flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  11. flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  12. flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  13. flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  14. flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
  15. flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
  16. flowforge/_dashboard/favicon.svg +1 -0
  17. flowforge/_dashboard/index.html +16 -0
  18. flowforge/_example/trip-planner/.env.example +3 -0
  19. flowforge/_example/trip-planner/.gitignore +2 -0
  20. flowforge/_example/trip-planner/.mcp.json +20 -0
  21. flowforge/_example/trip-planner/README.md +48 -0
  22. flowforge/_example/trip-planner/config.py +27 -0
  23. flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
  24. flowforge/_example/trip-planner/planner.py +113 -0
  25. flowforge/_example/trip-planner/requirements.txt +3 -0
  26. flowforge/_example/trip-planner/services/__init__.py +0 -0
  27. flowforge/_example/trip-planner/services/geo.py +49 -0
  28. flowforge/_example/trip-planner/services/llm.py +18 -0
  29. flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
  30. flowforge/_example/trip-planner/services/weather.py +25 -0
  31. flowforge/_example/trip-planner/tools/packing_list.py +24 -0
  32. flowforge/_example/trip-planner/trips/.gitkeep +0 -0
  33. flowforge/_workflows/diamond_mock.json +71 -0
  34. flowforge/_workflows/stripe_to_razorpay.json +38 -0
  35. flowforge/agent/__init__.py +5 -0
  36. flowforge/agent/server.py +333 -0
  37. flowforge/api/__init__.py +0 -0
  38. flowforge/api/agent.py +79 -0
  39. flowforge/api/checks.py +255 -0
  40. flowforge/api/connectors.py +289 -0
  41. flowforge/api/flowmap.py +174 -0
  42. flowforge/api/runs.py +284 -0
  43. flowforge/api/scan.py +117 -0
  44. flowforge/api/security.py +31 -0
  45. flowforge/api/stats.py +80 -0
  46. flowforge/checks/__init__.py +13 -0
  47. flowforge/checks/models.py +41 -0
  48. flowforge/checks/probes.py +359 -0
  49. flowforge/cli.py +108 -0
  50. flowforge/connectors/__init__.py +0 -0
  51. flowforge/connectors/catalog.py +84 -0
  52. flowforge/connectors/models.py +149 -0
  53. flowforge/connectors/presets.py +192 -0
  54. flowforge/connectors/registry.py +85 -0
  55. flowforge/connectors/runtime.py +47 -0
  56. flowforge/connectors/secrets.py +53 -0
  57. flowforge/example_data.py +247 -0
  58. flowforge/flow/__init__.py +328 -0
  59. flowforge/flow/advice.py +132 -0
  60. flowforge/flow/curly.py +884 -0
  61. flowforge/flow/index.py +116 -0
  62. flowforge/flow/order.py +181 -0
  63. flowforge/flow/reach.py +145 -0
  64. flowforge/flow/rewrite.py +146 -0
  65. flowforge/flowmap/__init__.py +1 -0
  66. flowforge/flowmap/diff.py +145 -0
  67. flowforge/flowmap/fixture.py +283 -0
  68. flowforge/flowmap/models.py +258 -0
  69. flowforge/flowmap/planner.py +131 -0
  70. flowforge/flowmap/service.py +379 -0
  71. flowforge/flowmap/validate.py +228 -0
  72. flowforge/home.py +58 -0
  73. flowforge/main.py +137 -0
  74. flowforge/nodes/__init__.py +3 -0
  75. flowforge/nodes/base.py +74 -0
  76. flowforge/nodes/check_node.py +50 -0
  77. flowforge/nodes/http_node.py +161 -0
  78. flowforge/nodes/llm_node.py +158 -0
  79. flowforge/nodes/local_node.py +164 -0
  80. flowforge/nodes/mcp_node.py +243 -0
  81. flowforge/nodes/mock_node.py +40 -0
  82. flowforge/scan/__init__.py +6 -0
  83. flowforge/scan/facts.py +47 -0
  84. flowforge/scan/files.py +112 -0
  85. flowforge/scan/manifests.py +65 -0
  86. flowforge/scan/mcp_config.py +75 -0
  87. flowforge/scan/models.py +73 -0
  88. flowforge/scan/patterns.py +86 -0
  89. flowforge/scan/python_ast.py +246 -0
  90. flowforge/scan/scanner.py +344 -0
  91. flowforge/scan/services.py +58 -0
  92. flowforge/scan/urls.py +60 -0
  93. flowforge/scheduler/__init__.py +0 -0
  94. flowforge/scheduler/cache.py +76 -0
  95. flowforge/scheduler/critical_path.py +65 -0
  96. flowforge/scheduler/durations.py +35 -0
  97. flowforge/scheduler/executor.py +390 -0
  98. flowforge/scheduler/graph.py +108 -0
  99. flowforge/scheduler/rate_limit.py +36 -0
  100. flowforge/schema.py +81 -0
  101. flowforge/security/__init__.py +0 -0
  102. flowforge/security/finders.py +127 -0
  103. flowforge/security/findings.py +169 -0
  104. flowforge/security/logfilter.py +129 -0
  105. flowforge/spa.py +52 -0
  106. flowforge/state.py +73 -0
  107. flowforge/storage.py +481 -0
  108. flowforge/templating.py +60 -0
  109. flowforge_map-0.1.0.dist-info/METADATA +335 -0
  110. flowforge_map-0.1.0.dist-info/RECORD +113 -0
  111. flowforge_map-0.1.0.dist-info/WHEEL +4 -0
  112. flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
  113. 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