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,258 @@
|
|
|
1
|
+
"""Flow JSON (D17). What the Planner returns and what the map draws.
|
|
2
|
+
|
|
3
|
+
Blocks (`FlowNode.kind`):
|
|
4
|
+
start, end where a flow begins (route, webhook, schedule) and stops
|
|
5
|
+
llm, api, mcp, local one call to a connected app (`connector` names it)
|
|
6
|
+
code the app's own code, no outside call
|
|
7
|
+
decision one branch is taken; its lines carry `when`
|
|
8
|
+
fork, join run together / wait for all
|
|
9
|
+
gate a person approves; added by the Planner or by code (`added_by="policy"`)
|
|
10
|
+
|
|
11
|
+
Lines (`FlowEdge.kind`): flow, branch (with `when`), fallback (taken when the source fails),
|
|
12
|
+
retry (a self-loop: the call is tried again), unconfirmed (leads to a block with no evidence).
|
|
13
|
+
|
|
14
|
+
A map is a `flow` (the order the app runs in, D17) or `dependencies` (what the app uses, from a scan, D21).
|
|
15
|
+
A flow read from code (`source="code"`, D25) carries no gates and no connected apps; its `advice` says which calls
|
|
16
|
+
could run together.
|
|
17
|
+
On a dependency map an app-call block is a dependency: it needs no connected app, gets no gate and no
|
|
18
|
+
critical path, and `catalog` names a known service for its tile.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
26
|
+
|
|
27
|
+
NodeKind = Literal["start", "end", "llm", "api", "mcp", "local", "code", "decision", "fork", "join", "gate"]
|
|
28
|
+
EdgeKind = Literal["flow", "branch", "fallback", "retry", "unconfirmed"]
|
|
29
|
+
|
|
30
|
+
CALL_KINDS = {"llm", "api", "mcp", "local"}
|
|
31
|
+
# block kind -> the connector type it must use (D10)
|
|
32
|
+
CONNECTOR_TYPE = {"llm": "llm", "api": "http", "mcp": "mcp", "local": "local"}
|
|
33
|
+
|
|
34
|
+
_ID = r"^[A-Za-z0-9_\-]{1,64}$"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Model(BaseModel):
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Evidence(_Model):
|
|
42
|
+
"""Why a block is on the map. `code` and `trace` confirm a block; `env_name` is only a hint."""
|
|
43
|
+
|
|
44
|
+
type: Literal["code", "trace", "policy", "env_name", "user", "missing"]
|
|
45
|
+
ref: str | None = Field(default=None, max_length=200) # "services/risk.py:44"
|
|
46
|
+
text: str | None = Field(default=None, max_length=300) # one short sentence
|
|
47
|
+
first_line: int | None = None # code excerpt: number of its first line
|
|
48
|
+
lines: list[str] = Field(default_factory=list, max_length=12)
|
|
49
|
+
highlight: int | None = None # the line the ref points at
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def confirms(self) -> bool:
|
|
53
|
+
return self.type in ("code", "trace", "user", "policy")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Op(_Model):
|
|
57
|
+
"""What a call does. Gating reads `method` (HTTP) and `read_only` (MCP), never the Planner's opinion."""
|
|
58
|
+
|
|
59
|
+
model: str | None = None
|
|
60
|
+
method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"] | None = None
|
|
61
|
+
path: str | None = None
|
|
62
|
+
tool: str | None = None
|
|
63
|
+
read_only: bool | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FlowNode(_Model):
|
|
67
|
+
id: str = Field(pattern=_ID)
|
|
68
|
+
kind: NodeKind
|
|
69
|
+
title: str = Field(min_length=1, max_length=80)
|
|
70
|
+
what: str = Field(default="", max_length=300)
|
|
71
|
+
connector: str | None = None
|
|
72
|
+
op: Op | None = None
|
|
73
|
+
condition: str | None = Field(default=None, max_length=200)
|
|
74
|
+
estimate_ms: float | None = Field(default=None, ge=0)
|
|
75
|
+
status: Literal["confirmed", "unconfirmed"] = "confirmed"
|
|
76
|
+
added_by: Literal["planner", "policy", "user"] = "planner"
|
|
77
|
+
side_effect: bool = False # set by validation, never trusted from input
|
|
78
|
+
catalog: str | None = None # dependency maps: a known service's id, for its tile (D21)
|
|
79
|
+
repeats: bool = False # flows read from code: the call sits in a loop (D25)
|
|
80
|
+
estimate_source: Literal["measured", "estimate"] | None = None # where `estimate_ms` came from (D25)
|
|
81
|
+
evidence: list[Evidence] = Field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FlowEdge(_Model):
|
|
85
|
+
id: str = Field(pattern=_ID)
|
|
86
|
+
source: str
|
|
87
|
+
target: str
|
|
88
|
+
kind: EdgeKind = "flow"
|
|
89
|
+
when: str | None = Field(default=None, max_length=24) # branch label: "yes", "no"
|
|
90
|
+
max: int | None = Field(default=None, ge=1, le=10) # retry: attempts
|
|
91
|
+
on: str | None = Field(default=None, max_length=60) # retry/fallback: when it happens
|
|
92
|
+
label: str | None = Field(default=None, max_length=40)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Suggestion(_Model):
|
|
96
|
+
"""A better fit the Planner found while running the app (shown as a tip on the block)."""
|
|
97
|
+
|
|
98
|
+
node: str
|
|
99
|
+
connector: str
|
|
100
|
+
text: str = Field(max_length=300)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Change(_Model):
|
|
104
|
+
"""The advice written out (D27): lines as they are and as they would be, masked like evidence. Text to copy,
|
|
105
|
+
never applied. `by_hand`: calls of the same advice in another block, each with where it is and why."""
|
|
106
|
+
|
|
107
|
+
path: str
|
|
108
|
+
first_line: int = Field(ge=1)
|
|
109
|
+
before: list[str]
|
|
110
|
+
after: list[str]
|
|
111
|
+
saved_ms: float = Field(ge=0)
|
|
112
|
+
by_hand: list[str] = Field(default_factory=list)
|
|
113
|
+
needs_import: bool = False
|
|
114
|
+
import_text: str | None = None # the import to add for the change, when the file lacks it (D32)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Action(_Model):
|
|
118
|
+
"""One entry point of a flow read from code, as the action picker shows it (D33): its calls one after another
|
|
119
|
+
as written, as early as inputs allow, and the chain that sets that time."""
|
|
120
|
+
|
|
121
|
+
id: str # its start block
|
|
122
|
+
title: str
|
|
123
|
+
written_ms: float = Field(ge=0)
|
|
124
|
+
could_ms: float = Field(ge=0)
|
|
125
|
+
path: list[str] = Field(default_factory=list)
|
|
126
|
+
calls: int = 0
|
|
127
|
+
# its calls as written, in groups: one call, or calls the code already runs together (count once, at the
|
|
128
|
+
# slowest). Kept so "as written" can be recomputed from what is still on the map after edits.
|
|
129
|
+
parts: list[list[str]] = Field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Advice(_Model):
|
|
133
|
+
"""Calls that could start together (D25): awaited one after another, and none uses another's result.
|
|
134
|
+
`saved_ms` is what running them together would save, from the blocks' weights. `change` writes it out when plain
|
|
135
|
+
code can do so safely; `prompt` hands it to a coding agent (D27)."""
|
|
136
|
+
|
|
137
|
+
nodes: list[str] = Field(min_length=2)
|
|
138
|
+
refs: list[str]
|
|
139
|
+
text: str = Field(max_length=300)
|
|
140
|
+
saved_ms: float = Field(ge=0)
|
|
141
|
+
caveat: str | None = Field(default=None, max_length=300)
|
|
142
|
+
change: Change | None = None
|
|
143
|
+
prompt: str = Field(default="", max_length=3000)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Flow(_Model):
|
|
147
|
+
kind: Literal["flow", "dependencies"] = "flow"
|
|
148
|
+
source: Literal["planner", "code"] = "planner" # code: read from the repo's own code (D25)
|
|
149
|
+
nodes: list[FlowNode]
|
|
150
|
+
edges: list[FlowEdge] = Field(default_factory=list)
|
|
151
|
+
suggestions: list[Suggestion] = Field(default_factory=list)
|
|
152
|
+
advice: list[Advice] = Field(default_factory=list)
|
|
153
|
+
written_ms: float | None = None # code flows: every call one after another, as the code is written (D25)
|
|
154
|
+
patterns: list[str] = Field(default_factory=list) # code flows: languages read by patterns, less exact (D32)
|
|
155
|
+
actions: list[Action] = Field(default_factory=list) # code flows: one per entry point, slowest first (D33)
|
|
156
|
+
|
|
157
|
+
@model_validator(mode="after")
|
|
158
|
+
def _structure(self) -> Flow:
|
|
159
|
+
ids = [n.id for n in self.nodes]
|
|
160
|
+
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
161
|
+
if dupes:
|
|
162
|
+
raise ValueError(f"duplicate block id: {', '.join(dupes)}")
|
|
163
|
+
edge_ids = [e.id for e in self.edges]
|
|
164
|
+
dupes = sorted({i for i in edge_ids if edge_ids.count(i) > 1})
|
|
165
|
+
if dupes:
|
|
166
|
+
raise ValueError(f"duplicate line id: {', '.join(dupes)}")
|
|
167
|
+
known = set(ids)
|
|
168
|
+
for e in self.edges:
|
|
169
|
+
for end in (e.source, e.target):
|
|
170
|
+
if end not in known:
|
|
171
|
+
raise ValueError(f"line '{e.id}' points at unknown block '{end}'")
|
|
172
|
+
if e.kind == "retry" and e.source != e.target:
|
|
173
|
+
raise ValueError(f"retry line '{e.id}' must loop back to the same block")
|
|
174
|
+
if e.kind != "retry" and e.source == e.target:
|
|
175
|
+
raise ValueError(f"line '{e.id}' loops back to '{e.source}'; only retry lines may")
|
|
176
|
+
if e.kind == "branch" and not e.when:
|
|
177
|
+
raise ValueError(f"branch line '{e.id}' needs `when` (for example yes or no)")
|
|
178
|
+
for s in self.suggestions:
|
|
179
|
+
if s.node not in known:
|
|
180
|
+
raise ValueError(f"suggestion for unknown block '{s.node}'")
|
|
181
|
+
for a in self.advice:
|
|
182
|
+
missing = [n for n in a.nodes if n not in known]
|
|
183
|
+
if missing:
|
|
184
|
+
raise ValueError(f"advice for unknown block '{missing[0]}'")
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
def node(self, node_id: str) -> FlowNode | None:
|
|
188
|
+
return next((n for n in self.nodes if n.id == node_id), None)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TraceStep(_Model):
|
|
192
|
+
"""One block in a recorded test run. Times are offsets from the start of the run."""
|
|
193
|
+
|
|
194
|
+
node: str
|
|
195
|
+
start_ms: float = Field(ge=0)
|
|
196
|
+
duration_ms: float = Field(ge=0)
|
|
197
|
+
outcome: Literal["ok", "failed", "cached", "waiting"]
|
|
198
|
+
answered_by: str | None = None
|
|
199
|
+
note: str | None = Field(default=None, max_length=120)
|
|
200
|
+
attempts: int = Field(default=1, ge=1)
|
|
201
|
+
tokens: int = Field(default=0, ge=0)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Trace(_Model):
|
|
205
|
+
"""A traced test order, replayed on the map (D17). `branches` says which way each decision went."""
|
|
206
|
+
|
|
207
|
+
label: str = Field(max_length=80)
|
|
208
|
+
steps: list[TraceStep]
|
|
209
|
+
branches: dict[str, str] = Field(default_factory=dict)
|
|
210
|
+
calls: int = 0
|
|
211
|
+
cached: int = 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class NodeStats(_Model):
|
|
215
|
+
p50_ms: float | None = None
|
|
216
|
+
summary: str = ""
|
|
217
|
+
history: list[dict[str, float | bool]] = Field(default_factory=list) # [{"ms": 2100, "ok": true}]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class AnalysisStage(_Model):
|
|
221
|
+
title: str
|
|
222
|
+
done: str
|
|
223
|
+
doing: str = ""
|
|
224
|
+
duration_ms: float = 2000
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Analysis(_Model):
|
|
228
|
+
"""How the Planner drew the map: its stages, a short log and the counts. Names only, never values."""
|
|
229
|
+
|
|
230
|
+
stages: list[AnalysisStage]
|
|
231
|
+
log: list[tuple[float, str, str]] = Field(default_factory=list) # (at_ms, kind, text)
|
|
232
|
+
files: list[str] = Field(default_factory=list)
|
|
233
|
+
calls: list[dict[str, str | int | bool]] = Field(default_factory=list)
|
|
234
|
+
dropped: list[dict[str, str]] = Field(default_factory=list) # [{"title", "reason"}]
|
|
235
|
+
stats: dict[str, NodeStats] = Field(default_factory=dict)
|
|
236
|
+
notes: dict[str, str] = Field(default_factory=dict) # block id -> why it changed (re-check)
|
|
237
|
+
positions: dict[str, tuple[float, float]] = Field(default_factory=dict) # optional starting layout
|
|
238
|
+
scan: dict[str, Any] | None = None # dependency maps: the full ScanResult, for checks later (D21)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Override(_Model):
|
|
242
|
+
"""One edit on top of the Planner's version (D17). It never touches the user's code."""
|
|
243
|
+
|
|
244
|
+
op: Literal["set_connector", "rename", "hide", "confirm", "add_node", "add_edge"]
|
|
245
|
+
node: str | None = Field(default=None, pattern=_ID)
|
|
246
|
+
connector: str | None = None
|
|
247
|
+
title: str | None = Field(default=None, min_length=1, max_length=80)
|
|
248
|
+
new_node: FlowNode | None = None
|
|
249
|
+
source: str | None = None
|
|
250
|
+
|
|
251
|
+
@model_validator(mode="after")
|
|
252
|
+
def _fields(self) -> Override:
|
|
253
|
+
need = {"set_connector": ("node", "connector"), "rename": ("node", "title"), "hide": ("node",),
|
|
254
|
+
"confirm": ("node",), "add_node": ("new_node",), "add_edge": ("node", "source")}[self.op]
|
|
255
|
+
missing = [f for f in need if getattr(self, f) in (None, "")]
|
|
256
|
+
if missing:
|
|
257
|
+
raise ValueError(f"{self.op} needs {', '.join(missing)}")
|
|
258
|
+
return self
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The Planner interface (D17) and its two implementations.
|
|
2
|
+
|
|
3
|
+
`FixturePlanner` draws the hand-made Baby-care flow map, test data for the flow-map code: nothing hand-drawn
|
|
4
|
+
reaches the dashboard (D23). `ScanPlanner` scans a folder and draws its dependency map (D21). `CodeFlowPlanner` reads
|
|
5
|
+
the folder's Python and draws the order it calls those dependencies (D25). The last two are plain code, no LLM (D20).
|
|
6
|
+
All return the same `Draft`, so versions, re-checks and edits work alike.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
from flowforge.flowmap import fixture
|
|
19
|
+
from flowforge.flowmap.models import CALL_KINDS, Analysis, AnalysisStage, Evidence, Flow, FlowEdge, FlowNode, Op, Trace
|
|
20
|
+
from flowforge.scan import ScanResult, scan
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Draft:
|
|
25
|
+
flow: Flow
|
|
26
|
+
trace: Trace | None
|
|
27
|
+
analysis: Analysis
|
|
28
|
+
fact_sheet_hash: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NoFlow(Exception):
|
|
32
|
+
"""There is nothing to draw, and the message says why (D25: "Flow order needs Python for now")."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Planner(Protocol):
|
|
36
|
+
def analyze(self, round: int) -> Draft:
|
|
37
|
+
"""Draw the map. `round` is 0 for the first map and counts up with every re-check."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fact_hash(value: object) -> str:
|
|
42
|
+
return "sha256:" + hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()[:16]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FixturePlanner:
|
|
46
|
+
"""The Baby-care shop, for tests (D23). Round 0 is the first map; any later round is the re-check."""
|
|
47
|
+
|
|
48
|
+
def analyze(self, round: int) -> Draft:
|
|
49
|
+
recheck = round > 0
|
|
50
|
+
flow = fixture.flow_v2() if recheck else fixture.flow_v1()
|
|
51
|
+
facts = {"repo": "you/baby-care-shop", "commit": "b7e9d21" if recheck else "a1b2c3f"}
|
|
52
|
+
return Draft(flow=flow, trace=fixture.trace(), analysis=fixture.analysis(recheck=recheck),
|
|
53
|
+
fact_sheet_hash=fact_hash(facts))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
APP_ID, TOOLS_ID = "app", "tools"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ScanPlanner:
|
|
60
|
+
"""A real repo (D21): scan the folder, draw one block per dependency off the app (or your coding tools)."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, root: str | Path):
|
|
63
|
+
self.root = Path(root)
|
|
64
|
+
|
|
65
|
+
def analyze(self, round: int) -> Draft: # every round scans the folder as it is now
|
|
66
|
+
return draft_from_scan(scan(self.root))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def draft_from_scan(result: ScanResult) -> Draft:
|
|
70
|
+
deps = result.dependencies
|
|
71
|
+
nodes = [FlowNode(id=APP_ID, kind="start", title=result.root[:80], what="Your app: the folder that was scanned.",
|
|
72
|
+
evidence=[Evidence(type="code", text=f"{result.files_scanned} files read.")])]
|
|
73
|
+
if any(d.scope == "dev_tool" for d in deps):
|
|
74
|
+
nodes.append(FlowNode(id=TOOLS_ID, kind="start", title="Your coding tools",
|
|
75
|
+
what="MCP servers your coding tools use, not your app.",
|
|
76
|
+
evidence=[Evidence(type="code", text="In an MCP config file the app never reads.")]))
|
|
77
|
+
ids = {d.id for d in deps}
|
|
78
|
+
edges = []
|
|
79
|
+
for d in deps:
|
|
80
|
+
nodes.append(FlowNode(
|
|
81
|
+
id=d.id, kind=d.kind, title=d.name[:80], what=(d.target or "")[:300], catalog=d.catalog,
|
|
82
|
+
op=Op(model=d.model) if d.model else None, status=d.status,
|
|
83
|
+
evidence=[Evidence(type="code", ref=e.ref, text=e.text, first_line=e.first_line, lines=e.lines,
|
|
84
|
+
highlight=e.highlight) for e in d.evidence]))
|
|
85
|
+
source = d.via if d.via in ids else TOOLS_ID if d.scope == "dev_tool" else APP_ID
|
|
86
|
+
edges.append(FlowEdge(id=f"{source}__{d.id}"[:64], source=source, target=d.id))
|
|
87
|
+
confirmed = sum(d.status == "confirmed" for d in deps)
|
|
88
|
+
unconfirmed = len(deps) - confirmed
|
|
89
|
+
analysis = Analysis(
|
|
90
|
+
stages=[
|
|
91
|
+
AnalysisStage(title="Read your code", done=f"{result.files_scanned} files read, nothing run or sent"),
|
|
92
|
+
AnalysisStage(title="Found dependencies", done=f"{len(deps)} found"),
|
|
93
|
+
AnalysisStage(title="Checked the evidence", done=f"{confirmed} confirmed, {unconfirmed} unconfirmed"),
|
|
94
|
+
],
|
|
95
|
+
files=sorted({e.ref.rsplit(":", 1)[0] for d in deps for e in d.evidence})[:200],
|
|
96
|
+
scan=result.model_dump(mode="json"))
|
|
97
|
+
facts = [(d.id, d.kind, d.target, d.status, [e.ref for e in d.evidence]) for d in deps]
|
|
98
|
+
return Draft(flow=Flow(kind="dependencies", nodes=nodes, edges=edges), trace=None, analysis=analysis,
|
|
99
|
+
fact_sheet_hash=fact_hash(facts))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CodeFlowPlanner:
|
|
103
|
+
"""A real repo's flow (D25): read its Python, draw the order its entry points call their dependencies, weighted
|
|
104
|
+
by the latest checks (D24). `latencies` is asked each time, so a re-scan picks up newer checks."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, root: str | Path, latencies: Callable[[], dict[str, float]] = dict):
|
|
107
|
+
self.root = Path(root)
|
|
108
|
+
self.latencies = latencies
|
|
109
|
+
|
|
110
|
+
def analyze(self, round: int) -> Draft:
|
|
111
|
+
from flowforge.flow import flow_of # flow/ builds on scan/ and flowmap/; imported here to keep them apart
|
|
112
|
+
|
|
113
|
+
result = flow_of(self.root, self.latencies())
|
|
114
|
+
if result.flow is None:
|
|
115
|
+
raise NoFlow(result.message or "No flow to draw.")
|
|
116
|
+
flow = result.flow
|
|
117
|
+
steps = [n for n in flow.nodes if n.kind in CALL_KINDS]
|
|
118
|
+
measured = sum(n.estimate_source == "measured" for n in steps)
|
|
119
|
+
analysis = Analysis(
|
|
120
|
+
stages=[
|
|
121
|
+
AnalysisStage(title="Read your Python", done=f"{len(result.entries)} entry point"
|
|
122
|
+
f"{'s' if len(result.entries) != 1 else ''}, nothing run or sent"),
|
|
123
|
+
AnalysisStage(title="Followed the calls", done=f"{len(steps)} calls, "
|
|
124
|
+
f"{sum(n.kind == 'decision' for n in flow.nodes)} decisions"),
|
|
125
|
+
AnalysisStage(title="Weighed them",
|
|
126
|
+
done=f"{measured} measured by checks, {len(steps) - measured} estimated"),
|
|
127
|
+
],
|
|
128
|
+
files=sorted({n.evidence[0].ref.rsplit(":", 1)[0] for n in flow.nodes if n.evidence and n.evidence[0].ref}))
|
|
129
|
+
facts = [(n.id, n.kind, n.condition, [e.ref for e in n.evidence]) for n in flow.nodes] + \
|
|
130
|
+
[(e.source, e.target, e.when) for e in flow.edges]
|
|
131
|
+
return Draft(flow=flow, trace=None, analysis=analysis, fact_sheet_hash=fact_hash(facts))
|