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,116 @@
1
+ """Every Python function in the repo, and which in-repo function each call reaches (D25). Read by `ast`, never run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from dataclasses import dataclass, field
7
+ from pathlib import PurePosixPath
8
+
9
+
10
+ @dataclass
11
+ class Func:
12
+ module: str # "services.geo"
13
+ path: str # "services/geo.py"
14
+ name: str # "find_place", or "Class.method"
15
+ node: ast.FunctionDef | ast.AsyncFunctionDef
16
+
17
+ @property
18
+ def key(self) -> str:
19
+ return f"{self.module}.{self.name}"
20
+
21
+
22
+ @dataclass
23
+ class Module:
24
+ name: str
25
+ path: str
26
+ tree: ast.Module
27
+ aliases: dict[str, str] = field(default_factory=dict) # local name -> dotted name it was imported as
28
+ uses_mcp: bool = False # imports the MCP SDK
29
+
30
+
31
+ def module_name(path: str) -> str:
32
+ parts = list(PurePosixPath(path).with_suffix("").parts)
33
+ if parts and parts[-1] == "__init__":
34
+ parts.pop()
35
+ return ".".join(parts)
36
+
37
+
38
+ def _imports(module: Module) -> None:
39
+ package = module.name.rsplit(".", 1)[0] if "." in module.name else ""
40
+ for node in ast.walk(module.tree):
41
+ if isinstance(node, ast.Import):
42
+ for a in node.names:
43
+ module.aliases[(a.asname or a.name).split(".")[0]] = a.name if a.asname else a.name.split(".")[0]
44
+ module.uses_mcp |= a.name == "mcp" or a.name.startswith("mcp.")
45
+ elif isinstance(node, ast.ImportFrom):
46
+ base = node.module or ""
47
+ if node.level: # relative: from .geo import x
48
+ up = package.split(".")[: max(0, len(package.split(".")) - node.level + 1)] if package else []
49
+ base = ".".join([*up, base] if base else up)
50
+ module.uses_mcp |= base == "mcp" or base.startswith("mcp.")
51
+ for a in node.names:
52
+ module.aliases[a.asname or a.name] = f"{base}.{a.name}" if base else a.name
53
+
54
+
55
+ class Index:
56
+ """The repo's functions. `resolve` turns a call into the in-repo function it runs, or None."""
57
+
58
+ def __init__(self, sources: dict[str, str]):
59
+ self.modules: dict[str, Module] = {}
60
+ self.funcs: dict[str, Func] = {}
61
+ for path, text in sources.items():
62
+ try:
63
+ tree = ast.parse(text)
64
+ except SyntaxError:
65
+ continue
66
+ module = Module(module_name(path), path, tree)
67
+ _imports(module)
68
+ self.modules[module.name] = module
69
+ for node in tree.body:
70
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
71
+ self._add(Func(module.name, path, node.name, node))
72
+ elif isinstance(node, ast.ClassDef):
73
+ for item in node.body:
74
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
75
+ self._add(Func(module.name, path, f"{node.name}.{item.name}", item))
76
+
77
+ def _add(self, func: Func) -> None:
78
+ self.funcs[func.key] = func
79
+
80
+ def _known(self, dotted: str) -> str | None:
81
+ if dotted in self.funcs:
82
+ return dotted
83
+ # a src/ layout or a run from a subfolder: match on the trailing parts
84
+ ends = [k for k in self.funcs if k.endswith("." + dotted)]
85
+ return ends[0] if len(ends) == 1 else None
86
+
87
+ def resolve(self, call: ast.Call, module: Module, cls: str | None = None) -> str | None:
88
+ f = call.func
89
+ if isinstance(f, ast.Name):
90
+ local = f"{module.name}.{f.id}"
91
+ if local in self.funcs:
92
+ return local
93
+ if f.id in module.aliases:
94
+ return self._known(module.aliases[f.id])
95
+ return None
96
+ if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name):
97
+ owner = f.value.id
98
+ if owner == "self" and cls:
99
+ return self._known(f"{module.name}.{cls}.{f.attr}")
100
+ if owner in module.aliases:
101
+ return self._known(f"{module.aliases[owner]}.{f.attr}")
102
+ local_class = f"{module.name}.{owner}.{f.attr}"
103
+ if local_class in self.funcs:
104
+ return local_class
105
+ return None
106
+
107
+ def callers(self) -> dict[str, set[str]]:
108
+ """func key -> keys of the in-repo functions that call it (module-level code is not a caller)."""
109
+ out: dict[str, set[str]] = {k: set() for k in self.funcs}
110
+ for func in self.funcs.values():
111
+ module = self.modules[func.module]
112
+ cls = func.name.split(".")[0] if "." in func.name else None
113
+ for node in ast.walk(func.node):
114
+ if isinstance(node, ast.Call) and (target := self.resolve(node, module, cls)) and target != func.key:
115
+ out[target].add(func.key)
116
+ return out
@@ -0,0 +1,181 @@
1
+ """The order one entry function calls its dependencies (D25): statements, what each reads and writes, the `if`s
2
+ around them and the early returns before them, projected onto the blocks the map draws.
3
+
4
+ A line A → B means B can't start before A is done: it reads something A wrote (directly or through plain code in
5
+ between), it sits in an `if` A decides, or A returns early and B comes after. Lines are transitively reduced.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ from flowforge.flow.index import Func, Index
15
+ from flowforge.flow.reach import Hit, Reach, gathers
16
+
17
+ MUTATORS = frozenset({"append", "extend", "update", "add", "insert", "pop", "remove", "clear", "setdefault", "write",
18
+ "sort", "discard", "appendleft", "put"})
19
+
20
+
21
+ @dataclass
22
+ class Stmt:
23
+ idx: int
24
+ node: ast.AST # a simple statement, or an `if`'s test, or a loop / with header
25
+ line: int
26
+ reads: set[str]
27
+ writes: set[str]
28
+ hits: list[Hit]
29
+ ifs: list[tuple[int, str]] # enclosing (if statement, "yes" | "no"), outermost first
30
+ loop: bool = False
31
+ returns: bool = False
32
+ gathered: bool = False
33
+ is_if: bool = False
34
+ block_end: int = -1 # an `if`: index of the last statement inside it
35
+ stops: str | None = None # an `if` that returns early: the branch that returns ("yes" | "no")
36
+ stop_line: int = 0
37
+
38
+
39
+ def _reads(node: ast.AST) -> set[str]:
40
+ return {n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)}
41
+
42
+
43
+ def _writes(node: ast.AST) -> set[str]:
44
+ out = {n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, (ast.Store, ast.Del))}
45
+ for n in ast.walk(node):
46
+ if isinstance(n, (ast.Subscript, ast.Attribute)) and isinstance(n.ctx, ast.Store):
47
+ base = n.value
48
+ while isinstance(base, (ast.Subscript, ast.Attribute)):
49
+ base = base.value
50
+ if isinstance(base, ast.Name):
51
+ out.add(base.id)
52
+ if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr in MUTATORS \
53
+ and isinstance(n.func.value, ast.Name):
54
+ out.add(n.func.value.id) # facts.append(x) writes facts
55
+ return out
56
+
57
+
58
+ @dataclass
59
+ class Body:
60
+ """One function's statements, flattened in source order."""
61
+
62
+ func: Func
63
+ stmts: list[Stmt] = field(default_factory=list)
64
+
65
+
66
+ def flatten(func: Func, index: Index, reach: Reach) -> Body:
67
+ module = index.modules[func.module]
68
+ cls = func.name.split(".")[0] if "." in func.name else None
69
+ body = Body(func)
70
+
71
+ def add(node: ast.AST, line: int, ifs: list[tuple[int, str]], loop: bool, *, reads: set[str] | None = None,
72
+ writes: set[str] | None = None, hit_node: ast.AST | None = None, is_if: bool = False) -> Stmt:
73
+ s = Stmt(idx=len(body.stmts), node=node, line=line, reads=_reads(node) if reads is None else reads,
74
+ writes=_writes(node) if writes is None else writes,
75
+ hits=reach.statement(hit_node if hit_node is not None else node, module, cls) if hit_node is not False
76
+ else [], ifs=list(ifs), loop=loop, returns=isinstance(node, (ast.Return, ast.Raise)),
77
+ gathered=gathers(node), is_if=is_if)
78
+ body.stmts.append(s)
79
+ return s
80
+
81
+ def walk(stmts: list[ast.stmt], ifs: list[tuple[int, str]], loop: bool) -> None:
82
+ for st in stmts:
83
+ if isinstance(st, ast.If):
84
+ test = add(st.test, st.lineno, ifs, loop, is_if=True)
85
+ first = len(body.stmts)
86
+ walk(st.body, [*ifs, (test.idx, "yes")], loop)
87
+ after_body = len(body.stmts)
88
+ walk(st.orelse, [*ifs, (test.idx, "no")], loop)
89
+ test.block_end = len(body.stmts) - 1
90
+ returns_yes = next((s for s in body.stmts[first:after_body] if s.returns and not s.loop), None)
91
+ returns_no = next((s for s in body.stmts[after_body:] if s.returns and not s.loop), None)
92
+ if returns_yes and not returns_no:
93
+ test.stops, test.stop_line = "yes", returns_yes.line
94
+ elif returns_no and not returns_yes:
95
+ test.stops, test.stop_line = "no", returns_no.line
96
+ elif isinstance(st, (ast.For, ast.AsyncFor)):
97
+ add(st.iter, st.lineno, ifs, loop, writes=_writes(st.target) | {n.id for n in ast.walk(st.target)
98
+ if isinstance(n, ast.Name)})
99
+ walk(st.body, ifs, True)
100
+ walk(st.orelse, ifs, loop)
101
+ elif isinstance(st, ast.While):
102
+ add(st.test, st.lineno, ifs, loop, writes=set())
103
+ walk(st.body, ifs, True)
104
+ walk(st.orelse, ifs, loop)
105
+ elif isinstance(st, (ast.With, ast.AsyncWith)):
106
+ header = ast.Tuple(elts=[i.context_expr for i in st.items], ctx=ast.Load())
107
+ ast.copy_location(header, st)
108
+ names = {n.id for i in st.items if i.optional_vars is not None for n in ast.walk(i.optional_vars)
109
+ if isinstance(n, ast.Name)}
110
+ add(header, st.lineno, ifs, loop, writes=names)
111
+ walk(st.body, ifs, loop)
112
+ elif isinstance(st, ast.Try) or type(st).__name__ == "TryStar":
113
+ walk(st.body, ifs, loop)
114
+ for handler in st.handlers:
115
+ walk(handler.body, ifs, loop)
116
+ walk(st.orelse, ifs, loop)
117
+ walk(st.finalbody, ifs, loop)
118
+ elif isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
119
+ add(st, st.lineno, ifs, loop, reads=set(), writes={st.name}, hit_node=False)
120
+ else:
121
+ add(st, st.lineno, ifs, loop)
122
+
123
+ walk(func.node.body, [], False)
124
+ return body
125
+
126
+
127
+ def label(stmt: Stmt, index: Index, module_name: str) -> str:
128
+ """The code's own words for a step: a string it passes (`attempt("find the city (Nominatim)", …)`), without the
129
+ trailing note in brackets; else the function it calls, as words (`packing_list` → "packing list")."""
130
+ for sub in ast.walk(stmt.node):
131
+ if isinstance(sub, ast.Call):
132
+ for arg in sub.args:
133
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and " " in arg.value.strip():
134
+ return re.sub(r"\s*\([^)]*\)\s*$", "", arg.value.strip())[:120]
135
+ for sub in ast.walk(stmt.node):
136
+ if isinstance(sub, ast.Call):
137
+ target = index.resolve(sub, index.modules[module_name])
138
+ if target:
139
+ return target.rsplit(".", 1)[-1].replace("_", " ").strip()
140
+ return ""
141
+
142
+
143
+ def preds(body: Body) -> dict[int, list[tuple[int, str | None]]]:
144
+ """Statement-level: what each statement waits for, and the branch label when it is an `if`'s own control."""
145
+ out: dict[int, list[tuple[int, str | None]]] = {}
146
+ for s in body.stmts:
147
+ found: dict[int, str | None] = {}
148
+ for name in s.reads:
149
+ for t in body.stmts[: s.idx]:
150
+ if name in t.writes:
151
+ found.setdefault(t.idx, None)
152
+ if s.ifs:
153
+ d, lab = s.ifs[-1]
154
+ found[d] = lab
155
+ for g in body.stmts[: s.idx]:
156
+ if g.stops and g.block_end < s.idx and all(i != g.idx for i, _ in s.ifs):
157
+ found[g.idx] = "no" if g.stops == "yes" else "yes" # the branch that carries on
158
+ out[s.idx] = sorted(found.items())
159
+ return out
160
+
161
+
162
+ def reduce(edges: set[tuple[str, str]]) -> set[tuple[str, str]]:
163
+ """Transitive reduction of a DAG: drop A → C when A reaches C another way."""
164
+ children: dict[str, set[str]] = {}
165
+ for a, b in edges:
166
+ children.setdefault(a, set()).add(b)
167
+
168
+ def reaches(a: str, target: str, skip: tuple[str, str]) -> bool:
169
+ stack, seen = [a], set()
170
+ while stack:
171
+ here = stack.pop()
172
+ for nxt in children.get(here, ()):
173
+ if (here, nxt) == skip or nxt in seen:
174
+ continue
175
+ if nxt == target:
176
+ return True
177
+ seen.add(nxt)
178
+ stack.append(nxt)
179
+ return False
180
+
181
+ return {e for e in edges if not reaches(e[0], e[1], e)}
@@ -0,0 +1,145 @@
1
+ """Which dependencies a statement or a function reaches (D25), from the scan's own places (D21).
2
+
3
+ A place counts only when it shows a call: an HTTP call, a program run, a server started, an LLM client created or
4
+ asked for a model. A place that only mentions an address (a link, a constant) is not a call. MCP servers are reached
5
+ when their configured name is passed to code that uses the MCP SDK (D21's `via` rule).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from dataclasses import dataclass
13
+
14
+ from flowforge.flow.index import Func, Index, Module
15
+ from flowforge.scan.scanner import Places
16
+
17
+ CALL_TEXTS = frozenset({"calls it", "runs it", "starts it", "creates the client", "the model"})
18
+ HTTP_METHODS = {"get": "GET", "head": "HEAD", "post": "POST", "put": "PUT", "patch": "PATCH", "delete": "DELETE",
19
+ "request": None, "stream": None}
20
+ # an MCP tool whose name starts with one of these changes something; any other is read as read-only (D25)
21
+ WRITE_VERBS = ("write", "create", "delete", "remove", "update", "set", "send", "post", "put", "insert", "upload",
22
+ "move", "add", "edit", "publish", "pay", "charge", "refund", "cancel", "push", "commit", "merge")
23
+ GATHER = ("gather", "TaskGroup", "create_task", "wait", "as_completed")
24
+
25
+
26
+ def _slug(text: str) -> str:
27
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:56]
28
+
29
+
30
+ @dataclass
31
+ class Hit:
32
+ """One dependency a statement reaches: how (an HTTP method, an MCP tool) and from where."""
33
+
34
+ dep: str
35
+ method: str | None = None
36
+ tool: str | None = None
37
+
38
+
39
+ def writes(tool: str | None) -> bool:
40
+ return bool(tool) and tool.lower().startswith(WRITE_VERBS)
41
+
42
+
43
+ class Reach:
44
+ def __init__(self, index: Index, places: Places):
45
+ self.index = index
46
+ self.servers = {s: f"mcp-{_slug(s)}" for s in places.app_servers}
47
+ # (path, line) -> dependencies a call there reaches
48
+ self.at: dict[tuple[str, int], list[str]] = {}
49
+ for dep_id, items in places.by_dep.items():
50
+ for path, line, text in items:
51
+ if text in CALL_TEXTS:
52
+ self.at.setdefault((path, line), []).append(dep_id)
53
+ # module-level names whose definition is a call place: `client = OpenAI(...)` -> its dependency
54
+ self.names: dict[tuple[str, str], list[str]] = {}
55
+ for module in index.modules.values():
56
+ for node in module.tree.body:
57
+ if isinstance(node, (ast.Assign, ast.AnnAssign)) and (deps := self._lines(module.path, node)):
58
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
59
+ for t in targets:
60
+ if isinstance(t, ast.Name):
61
+ self.names[(module.name, t.id)] = deps
62
+ self._memo: dict[str, list[Hit]] = {}
63
+
64
+ def _lines(self, path: str, node: ast.AST) -> list[str]:
65
+ out: list[str] = []
66
+ for line in range(node.lineno, (node.end_lineno or node.lineno) + 1):
67
+ out += [d for d in self.at.get((path, line), []) if d not in out]
68
+ return out
69
+
70
+ def function(self, func: Func) -> list[Hit]:
71
+ """Everything a function reaches, in the order its code reaches it (memoised; recursion ends)."""
72
+ if func.key in self._memo:
73
+ return self._memo[func.key]
74
+ self._memo[func.key] = []
75
+ module = self.index.modules[func.module]
76
+ cls = func.name.split(".")[0] if "." in func.name else None
77
+ hits: list[Hit] = []
78
+ for stmt in func.node.body:
79
+ for hit in self.statement(stmt, module, cls):
80
+ if not any(h.dep == hit.dep for h in hits):
81
+ hits.append(hit)
82
+ self._memo[func.key] = hits
83
+ return hits
84
+
85
+ def statement(self, node: ast.AST, module: Module, cls: str | None = None) -> list[Hit]:
86
+ """What one statement (or expression) reaches: places inside it, module-level clients it uses, MCP servers
87
+ it names, and the in-repo functions it calls, followed through."""
88
+ found: list[tuple[int, int, Hit]] = [] # (line, col) keeps the code's own order
89
+ for sub in ast.walk(node):
90
+ if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)) and sub is not node:
91
+ continue
92
+ at = (getattr(sub, "lineno", 0), getattr(sub, "col_offset", 0))
93
+ if isinstance(sub, ast.Call):
94
+ for dep in self.at.get((module.path, sub.lineno), []):
95
+ found.append((*at, Hit(dep, method=_method(sub))))
96
+ target = self.index.resolve(sub, module, cls)
97
+ if target is not None:
98
+ callee = self.index.funcs[target]
99
+ if self.index.modules[callee.module].uses_mcp:
100
+ strings = [a.value for a in sub.args
101
+ if isinstance(a, ast.Constant) and isinstance(a.value, str)]
102
+ server = next((self.servers[s] for s in strings if s in self.servers), None)
103
+ if server:
104
+ tool = next((s for s in strings if s not in self.servers), None)
105
+ found.append((*at, Hit(server, tool=tool)))
106
+ continue
107
+ found += [(*at, h) for h in self.function(callee)]
108
+ elif isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load):
109
+ key = (module.name, sub.id)
110
+ if key not in self.names and sub.id in module.aliases and "." in module.aliases[sub.id]:
111
+ owner, _, name = module.aliases[sub.id].rpartition(".") # from config import CLIENT
112
+ key = (owner, name)
113
+ found += [(*at, Hit(d)) for d in self.names.get(key, [])]
114
+ elif isinstance(sub, ast.Attribute) and isinstance(sub.value, ast.Name) and sub.value.id in module.aliases:
115
+ dotted = module.aliases[sub.value.id]
116
+ owner = self.index.modules.get(dotted)
117
+ if owner is not None:
118
+ found += [(*at, Hit(d)) for d in self.names.get((owner.name, sub.attr), [])]
119
+ found.sort(key=lambda f: (f[0], f[1]))
120
+ out: list[Hit] = []
121
+ for _, _, hit in found:
122
+ same = next((h for h in out if h.dep == hit.dep), None)
123
+ if same is None:
124
+ out.append(hit)
125
+ else:
126
+ same.method = same.method or hit.method
127
+ same.tool = same.tool or hit.tool
128
+ return out
129
+
130
+
131
+ def _method(call: ast.Call) -> str | None:
132
+ if isinstance(call.func, ast.Attribute) and call.func.attr in HTTP_METHODS:
133
+ return HTTP_METHODS[call.func.attr]
134
+ return None
135
+
136
+
137
+ def gathers(node: ast.AST) -> bool:
138
+ """The statement runs its calls together: asyncio.gather, a TaskGroup, create_task (D25)."""
139
+ for sub in ast.walk(node):
140
+ if isinstance(sub, ast.Call):
141
+ f = sub.func
142
+ name = f.attr if isinstance(f, ast.Attribute) else f.id if isinstance(f, ast.Name) else ""
143
+ if name in GATHER:
144
+ return True
145
+ return False
@@ -0,0 +1,146 @@
1
+ """Advice written out (D27): one `asyncio.gather` in place of awaits that don't need each other, when plain code can
2
+ write it safely, and a prompt that hands the whole advice to a coding agent. Nothing here edits a file.
3
+
4
+ Safe means: the calls are in one block of one async function, each a plain `x = await …` or `await …`, and nothing
5
+ between them reads their results or changes what they read. Calls of the same advice elsewhere are listed to move
6
+ by hand.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from flowforge.flowmap.models import Change
16
+ from flowforge.security.logfilter import redact_code
17
+
18
+
19
+ @dataclass
20
+ class Call:
21
+ """One awaited call of a piece of advice. `stmt` and `func` are the reader's own: an `ast` statement and function
22
+ for Python, a `Stmt` and `curly.Func` for Dart/JS/TS (D32)."""
23
+
24
+ stmt: Any
25
+ func: Any
26
+ path: str
27
+ words: str # what it does, in the code's words ("find the sight")
28
+ ref: str # "planner.py:58"
29
+ weight: float
30
+ line: int = 0
31
+
32
+
33
+ def _blocks(func: ast.AST) -> dict[int, tuple[ast.AST, list[ast.stmt]]]:
34
+ """Each statement's block: the node that owns it and the list it sits in."""
35
+ out: dict[int, tuple[ast.AST, list[ast.stmt]]] = {}
36
+ for node in ast.walk(func):
37
+ for attr in ("body", "orelse", "finalbody"):
38
+ stmts = getattr(node, attr, None)
39
+ if isinstance(stmts, list):
40
+ for st in stmts:
41
+ if isinstance(st, ast.stmt):
42
+ out[id(st)] = (node, stmts)
43
+ for handler in getattr(node, "handlers", []) or []:
44
+ for st in handler.body:
45
+ out[id(st)] = (handler, handler.body)
46
+ return out
47
+
48
+
49
+ def _where(owner: ast.AST) -> str:
50
+ if isinstance(owner, ast.If):
51
+ return f"inside `if {redact_code(ast.unparse(owner.test))[:60]}:`"
52
+ if isinstance(owner, ast.AsyncWith):
53
+ return "inside the `async with` block"
54
+ if isinstance(owner, ast.With):
55
+ return "inside the `with` block"
56
+ if isinstance(owner, (ast.Try, ast.ExceptHandler)) or type(owner).__name__ == "TryStar":
57
+ return "inside a `try`"
58
+ if isinstance(owner, (ast.For, ast.AsyncFor, ast.While)):
59
+ return "inside a loop"
60
+ return "in another block"
61
+
62
+
63
+ def _awaited(st: ast.stmt) -> tuple[str, ast.expr] | None:
64
+ """`x = await e` → ("x", e); `await e` → ("_", e); anything else → None."""
65
+ if isinstance(st, ast.Assign) and len(st.targets) == 1 and isinstance(st.value, ast.Await):
66
+ return ast.unparse(st.targets[0]), st.value.value
67
+ if isinstance(st, ast.AnnAssign) and isinstance(st.value, ast.Await) and st.simple:
68
+ return ast.unparse(st.target), st.value.value
69
+ if isinstance(st, ast.Expr) and isinstance(st.value, ast.Await):
70
+ return "_", st.value.value
71
+ return None
72
+
73
+
74
+ def _names(node: ast.AST, ctx: type) -> set[str]:
75
+ return {n.id for n in ast.walk(node) if isinstance(n, ast.Name) and isinstance(n.ctx, ctx)}
76
+
77
+
78
+ def _quoted(c: Call) -> str:
79
+ return f'"{c.words}" ({c.ref})'
80
+
81
+
82
+ def change(calls: list[Call], source: str, module: ast.Module) -> Change | None:
83
+ """The gather for the largest group of calls that share a block, or None when it can't be written safely."""
84
+ func = calls[0].func
85
+ if not isinstance(func, ast.AsyncFunctionDef) or any(c.func is not func for c in calls):
86
+ return None
87
+ blocks = _blocks(func)
88
+ groups: dict[int, list[Call]] = {}
89
+ for c in calls:
90
+ if id(c.stmt) in blocks:
91
+ groups.setdefault(id(blocks[id(c.stmt)][1]), []).append(c)
92
+ if not groups:
93
+ return None
94
+ chosen = max(groups.values(), key=lambda g: (len(g), -g[0].stmt.lineno))
95
+ plain = [c for c in chosen if _awaited(c.stmt)]
96
+ if len(plain) < 2:
97
+ return None
98
+ plain.sort(key=lambda c: c.stmt.lineno)
99
+ body = blocks[id(plain[0].stmt)][1]
100
+ first, last = body.index(plain[0].stmt), body.index(plain[-1].stmt)
101
+ members = {id(c.stmt) for c in plain}
102
+ targets = set().union(*(_names(c.stmt, ast.Store) for c in plain))
103
+ reads = set().union(*(_names(_awaited(c.stmt)[1], ast.Load) for c in plain))
104
+ for st in body[first: last + 1]:
105
+ if id(st) not in members and (_names(st, ast.Load) & targets or _names(st, ast.Store) & reads):
106
+ return None # code between them uses a result, or changes what a call reads
107
+
108
+ lines = source.splitlines()
109
+ indent = lines[plain[0].stmt.lineno - 1][: plain[0].stmt.col_offset]
110
+ names = [_awaited(c.stmt)[0] for c in plain]
111
+ lhs = "" if all(n == "_" for n in names) else f"{', '.join(names)} = "
112
+ gather = [f"{indent}{lhs}await asyncio.gather(",
113
+ *(f"{indent} {ast.unparse(_awaited(c.stmt)[1])}," for c in plain), f"{indent})"]
114
+ end = plain[-1].stmt.end_lineno or plain[-1].stmt.lineno
115
+ spans = [(c.stmt.lineno, c.stmt.end_lineno or c.stmt.lineno) for c in plain]
116
+ after: list[str] = []
117
+ for n in range(plain[0].stmt.lineno, end + 1):
118
+ if n == spans[0][0]:
119
+ after += gather
120
+ elif not any(a <= n <= b for a, b in spans):
121
+ after.append(lines[n - 1])
122
+ weights = [c.weight for c in plain]
123
+ imported = any(isinstance(n, ast.Import) and any(a.name == "asyncio" for a in n.names) for n in ast.walk(module))
124
+ left = [c for c in calls if c not in plain]
125
+ return Change(
126
+ path=plain[0].path, first_line=plain[0].stmt.lineno,
127
+ before=[redact_code(x) for x in lines[plain[0].stmt.lineno - 1: end]],
128
+ after=[redact_code(x) for x in after], saved_ms=round(sum(weights) - max(weights), 1),
129
+ by_hand=[f"{_quoted(c)}: {_where(blocks[id(c.stmt)][0]) if id(c.stmt) in blocks else 'in another block'}"
130
+ for c in sorted(left, key=lambda c: c.stmt.lineno)],
131
+ needs_import=not imported, import_text=None if imported else "import asyncio")
132
+
133
+
134
+ def prompt(calls: list[Call], entry: str, saved_ms: float, caveat: str | None, how: str) -> str:
135
+ """The advice for a coding agent: where, what, why, and what not to touch. Masked; no code beyond the words."""
136
+ paths = sorted({c.path for c in calls})
137
+ lines = [f"In {', '.join(paths)}, {entry} waits for each of these calls before starting the next, "
138
+ "but none of them uses another's result:",
139
+ *(f"- line {c.ref.rsplit(':', 1)[1]}: \"{c.words}\"" for c in sorted(calls, key=lambda c: c.line)),
140
+ f"Make them run at the same time, {how}, so the slowest one sets the time. "
141
+ f"FlowForge estimates about {saved_ms / 1000:.1f} s saved."]
142
+ if caveat:
143
+ lines.append(f"Note: {caveat}")
144
+ lines.append("Keep the behaviour the same: the same results, the same error handling, and the same checks before "
145
+ "each call. Change nothing else, and run the tests.")
146
+ return redact_code("\n".join(lines))[:3000]
@@ -0,0 +1 @@
1
+ """The flow map (D17): the Planner's picture of how an app's LLM, MCP and API calls fit together."""