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
flowforge/flow/index.py
ADDED
|
@@ -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
|
flowforge/flow/order.py
ADDED
|
@@ -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)}
|
flowforge/flow/reach.py
ADDED
|
@@ -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."""
|