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,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))