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/curly.py
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
"""Flow order for Dart, JavaScript and TypeScript (D32): the same statements, reads, writes and calls as Python's
|
|
2
|
+
reader (D25), found by patterns in code whose comments and string contents are blanked out first. Nothing is run.
|
|
3
|
+
|
|
4
|
+
Less exact than Python's AST, and the map says so (`Flow.patterns`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from flowforge.connectors.presets import CLIENTS
|
|
13
|
+
from flowforge.flow.order import Body, Stmt
|
|
14
|
+
from flowforge.flow.reach import CALL_TEXTS, Hit
|
|
15
|
+
from flowforge.flow.rewrite import Call
|
|
16
|
+
from flowforge.flowmap.models import Change
|
|
17
|
+
from flowforge.scan import services
|
|
18
|
+
from flowforge.scan.scanner import Places
|
|
19
|
+
from flowforge.security.logfilter import redact_code
|
|
20
|
+
|
|
21
|
+
LANGUAGES = frozenset({"dart", "javascript", "typescript"})
|
|
22
|
+
KEYWORDS = frozenset({
|
|
23
|
+
"if", "else", "for", "while", "do", "switch", "case", "default", "break", "continue", "return", "throw", "try",
|
|
24
|
+
"catch", "finally", "on", "new", "await", "async", "function", "var", "let", "const", "final", "late", "typeof",
|
|
25
|
+
"instanceof", "in", "of", "is", "as", "this", "super", "null", "true", "false", "undefined", "void", "yield",
|
|
26
|
+
"assert", "class", "extends", "implements", "with", "import", "export", "from", "required", "static", "get", "set",
|
|
27
|
+
"rethrow", "delete", "dynamic", "external", "factory", "operator", "part", "show", "hide", "sync", "abstract",
|
|
28
|
+
"interface", "type", "enum", "public", "private", "protected", "readonly", "declare", "namespace", "mixin",
|
|
29
|
+
})
|
|
30
|
+
_IDENT = r"[A-Za-z_$][\w$]*"
|
|
31
|
+
_CALL = re.compile(rf"(?<![\w$.])({_IDENT})\s*(?:<[^<>();{{}}]*>)?\s*\(")
|
|
32
|
+
_STATIC_CALL = re.compile(rf"(?<![\w$.])([A-Z][\w$]*)\.({_IDENT})\s*\(")
|
|
33
|
+
_NAME = re.compile(rf"(?<![\w$.])({_IDENT})")
|
|
34
|
+
_CLASS = re.compile(rf"\b(?:class|mixin|extension)\s+({_IDENT})[^{{;]*\{{")
|
|
35
|
+
_ARROW = re.compile(rf"(?:\b(?:const|let|var|final)\s+|^\s*(?:(?:public|private|protected|static|readonly)\s+)*)"
|
|
36
|
+
rf"({_IDENT})\s*(?::\s*[^=;{{]+)?=\s*(async\s*)?(?:function\b\s*{_IDENT}?\s*)?"
|
|
37
|
+
rf"(?:\((?:[^()]|\([^()]*\))*\)|{_IDENT})\s*(?::\s*[^=;{{]+?)?\s*(=>|\{{)", re.MULTILINE)
|
|
38
|
+
_GETTER = re.compile(rf"\bget\s+({_IDENT})\s*(=>|\{{)")
|
|
39
|
+
_DECL = re.compile(rf"^\s*(?:@{_IDENT}\s+)*(?:late\s+)?(final|var|const|let)\s+(?:[\w$<>?,\[\]. ]+?\s+)?({_IDENT})\s*"
|
|
40
|
+
rf"(?::\s*([^=;]+?))?\s*(?:=|;|$)")
|
|
41
|
+
_DESTRUCT = re.compile(r"^\s*(?:final|var|const|let)\s*[\[({]([^=]*?)[\])}]\s*(?::[^=]+)?=")
|
|
42
|
+
_TYPED = re.compile(rf"^\s*([A-Z][\w$]*(?:<[^=;]*>)?\??)\s+({_IDENT})\s*=(?!=)")
|
|
43
|
+
_ASSIGN = re.compile(rf"^\s*({_IDENT})(?:\s*\.\s*{_IDENT}|\[[^\]]*\])*\s*(?:[-+*/%&|^]|\?\?)?=(?![=>])")
|
|
44
|
+
_MUTATE = re.compile(rf"(?<![\w$.])({_IDENT})\.(?:add|addAll|insert|insertAll|remove|removeAt|removeWhere|removeLast|"
|
|
45
|
+
r"clear|sort|push|pop|shift|unshift|splice|set|delete|putIfAbsent|update|addEntries)\s*\(")
|
|
46
|
+
_INCREMENT = re.compile(rf"(?<![\w$.])({_IDENT})\s*(?:\+\+|--)|(?:\+\+|--)\s*({_IDENT})")
|
|
47
|
+
_FOR_IN = re.compile(rf"\(\s*(?:final|var|const|let)?\s*(?:[\w<>?]+\s+)?({_IDENT})\s+(?:in|of)\b")
|
|
48
|
+
_GATHER = re.compile(r"\bFuture\.wait\s*\(|\)\s*\.wait\b|\bPromise\.(?:all|allSettled|race|any)\s*\(")
|
|
49
|
+
_AWAIT = re.compile(r"\bawait\b")
|
|
50
|
+
_HTTP_METHOD = re.compile(r"\.(get|post|put|patch|delete|head)\s*\(", re.IGNORECASE)
|
|
51
|
+
_FETCH_METHOD = re.compile(r"""method\s*:\s*['"](\w+)['"]""")
|
|
52
|
+
# an SDK call that changes something: data written, or the session (sign in, sign out) changed
|
|
53
|
+
_SDK_WRITE = re.compile(r"\.(?:insert|update|upsert|delete|remove|set|add|rpc|invoke|upload)\s*\(|\.auth\.\w+\s*\(")
|
|
54
|
+
# anonymous functions: a callback runs later, so its calls aren't the statement's (D25 skips lambdas the same way)
|
|
55
|
+
_CLOSURE = re.compile(rf"(?:(?<=[(,=:?\[])|(?<==>)|(?<=\breturn)|(?<=\bawait))\s*(?:async\s+)?"
|
|
56
|
+
rf"(?:function\s*{_IDENT}?\s*)?\((?:[^()]|\([^()]*\))*\)\s*(?:async\*?\s*)?(\{{|=>)")
|
|
57
|
+
_CLOSURE_ONE = re.compile(rf"(?<=[(,=:])\s*(?:async\s+)?{_IDENT}\s*=>")
|
|
58
|
+
_LOCAL = re.compile(rf"(?:\b(?:final|var|const|let|late)\s+(?:[\w$<>?,\[\]. ]+?\s+)?"
|
|
59
|
+
rf"|\b[A-Z][\w$]*(?:<[^=;()]*>)?\??\s+)({_IDENT})\s*(?=[=;:,)]|\s+(?:in|of)\b)")
|
|
60
|
+
_FIELD_WRITE = re.compile(rf"(?<![\w$.])({_IDENT})(?:\s*\[[^\]]*\])?\s*(?:[-+*/%&|^]|\?\?)?=(?![=>])")
|
|
61
|
+
_CONTINUES = re.compile(r"(?:else|catch|finally|on|while)\b")
|
|
62
|
+
_STRING = re.compile(r"""(['"`])((?:(?!\1).)*)\1""")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def mask(text: str, language: str) -> str:
|
|
66
|
+
"""The same text with comments and string contents blanked (newlines and quotes kept). `${…}` interpolations,
|
|
67
|
+
and Dart's `$name`, stay code, so what a string reads still counts."""
|
|
68
|
+
dart = language == "dart"
|
|
69
|
+
out = list(text)
|
|
70
|
+
n = len(text)
|
|
71
|
+
|
|
72
|
+
def blank(a: int, b: int) -> None:
|
|
73
|
+
for k in range(a, min(b, n)):
|
|
74
|
+
if out[k] != "\n":
|
|
75
|
+
out[k] = " "
|
|
76
|
+
|
|
77
|
+
stack: list[list] = [["code", 0]]
|
|
78
|
+
i = 0
|
|
79
|
+
while i < n:
|
|
80
|
+
frame, c = stack[-1], text[i]
|
|
81
|
+
if frame[0] == "code":
|
|
82
|
+
if text.startswith("//", i):
|
|
83
|
+
j = text.find("\n", i)
|
|
84
|
+
j = n if j < 0 else j
|
|
85
|
+
blank(i, j)
|
|
86
|
+
i = j
|
|
87
|
+
elif text.startswith("/*", i):
|
|
88
|
+
j = text.find("*/", i + 2)
|
|
89
|
+
j = n if j < 0 else j + 2
|
|
90
|
+
blank(i, j)
|
|
91
|
+
i = j
|
|
92
|
+
elif c == "{":
|
|
93
|
+
frame[1] += 1
|
|
94
|
+
i += 1
|
|
95
|
+
elif c == "}":
|
|
96
|
+
if frame[1] == 0 and len(stack) > 1: # the end of an interpolation: back into the string
|
|
97
|
+
blank(i, i + 1)
|
|
98
|
+
stack.pop()
|
|
99
|
+
else:
|
|
100
|
+
frame[1] -= 1
|
|
101
|
+
i += 1
|
|
102
|
+
elif c in "'\"" or (c == "`" and not dart):
|
|
103
|
+
raw = dart and i > 0 and text[i - 1] == "r" and (i < 2 or not _word_char(text[i - 2]))
|
|
104
|
+
quote = c * 3 if dart and text.startswith(c * 3, i) else c
|
|
105
|
+
stack.append(["str", quote, raw, (dart and not raw) or c == "`"])
|
|
106
|
+
i += len(quote)
|
|
107
|
+
else:
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
_, quote, raw, interp = frame
|
|
111
|
+
if text.startswith(quote, i):
|
|
112
|
+
stack.pop()
|
|
113
|
+
i += len(quote)
|
|
114
|
+
elif c == "\n" and len(quote) == 1 and quote != "`":
|
|
115
|
+
stack.pop() # an unclosed string ends at the line
|
|
116
|
+
i += 1
|
|
117
|
+
elif c == "\\" and not raw:
|
|
118
|
+
blank(i, i + 2)
|
|
119
|
+
i += 2
|
|
120
|
+
elif interp and text.startswith("${", i):
|
|
121
|
+
blank(i, i + 2)
|
|
122
|
+
stack.append(["code", 0])
|
|
123
|
+
i += 2
|
|
124
|
+
elif interp and dart and c == "$" and i + 1 < n and (text[i + 1].isalpha() or text[i + 1] == "_"):
|
|
125
|
+
blank(i, i + 1)
|
|
126
|
+
i += 1
|
|
127
|
+
while i < n and (text[i].isalnum() or text[i] == "_"):
|
|
128
|
+
i += 1
|
|
129
|
+
else:
|
|
130
|
+
blank(i, i + 1)
|
|
131
|
+
i += 1
|
|
132
|
+
return "".join(out)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _word_char(c: str) -> bool:
|
|
136
|
+
return c.isalnum() or c in "_$"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _match(code: str, i: int) -> int:
|
|
140
|
+
"""The index of the bracket that closes the one at `i` (or the end)."""
|
|
141
|
+
pairs = {"(": ")", "[": "]", "{": "}"}
|
|
142
|
+
stack = [pairs[code[i]]]
|
|
143
|
+
for j in range(i + 1, len(code)):
|
|
144
|
+
c = code[j]
|
|
145
|
+
if c in pairs:
|
|
146
|
+
stack.append(pairs[c])
|
|
147
|
+
elif c in ")]}":
|
|
148
|
+
if stack and c == stack[-1]:
|
|
149
|
+
stack.pop()
|
|
150
|
+
if not stack:
|
|
151
|
+
return j
|
|
152
|
+
return len(code) - 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _skip(code: str, i: int, end: int | None = None) -> int:
|
|
156
|
+
end = len(code) if end is None else end
|
|
157
|
+
while i < end and code[i].isspace():
|
|
158
|
+
i += 1
|
|
159
|
+
return i
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _word(code: str, i: int) -> str:
|
|
163
|
+
m = re.match(_IDENT, code[i:i + 64])
|
|
164
|
+
return m.group(0) if m else ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def words(name: str) -> str:
|
|
168
|
+
"""`getItineraryItems` → "get itinerary items", `_dayRoutes` → "day routes"."""
|
|
169
|
+
parts = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+", name.lstrip("_$"))
|
|
170
|
+
return " ".join(p.lower() for p in parts)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class Func:
|
|
175
|
+
"""One function of a Dart/JS/TS file. `body` is the span inside its braces, or its arrow expression."""
|
|
176
|
+
|
|
177
|
+
path: str
|
|
178
|
+
name: str # "downloadTrip", or "Class.method"
|
|
179
|
+
language: str
|
|
180
|
+
line: int
|
|
181
|
+
is_async: bool
|
|
182
|
+
body: tuple[int, int]
|
|
183
|
+
params: set[str] = field(default_factory=set)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def key(self) -> str:
|
|
187
|
+
return f"{self.path}:{self.name}"
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def short(self) -> str:
|
|
191
|
+
return self.name.rsplit(".", 1)[-1]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class Source:
|
|
196
|
+
path: str
|
|
197
|
+
language: str
|
|
198
|
+
text: str
|
|
199
|
+
code: str # masked
|
|
200
|
+
starts: list[int] = field(default_factory=list) # offset of each line's start
|
|
201
|
+
|
|
202
|
+
def line(self, offset: int) -> int:
|
|
203
|
+
lo, hi = 0, len(self.starts) - 1
|
|
204
|
+
while lo < hi:
|
|
205
|
+
mid = (lo + hi + 1) // 2
|
|
206
|
+
if self.starts[mid] <= offset:
|
|
207
|
+
lo = mid
|
|
208
|
+
else:
|
|
209
|
+
hi = mid - 1
|
|
210
|
+
return lo + 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _params(code: str, open_paren: int) -> set[str]:
|
|
214
|
+
"""Parameter names: TS `city: string` → city, Dart `String tripId` / `{required int n = 1}` → tripId, n."""
|
|
215
|
+
names = set()
|
|
216
|
+
for part in re.split(r"[,{}\[\]]", code[open_paren + 1:_match(code, open_paren)]):
|
|
217
|
+
part = part.split("=")[0]
|
|
218
|
+
tokens = [t for t in re.findall(_IDENT, part.split(":")[0] if ":" in part else part) if t not in KEYWORDS]
|
|
219
|
+
if tokens:
|
|
220
|
+
names.add(tokens[-1] if ":" not in part else tokens[0])
|
|
221
|
+
return names
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Index:
|
|
225
|
+
"""Every function in the Dart/JS/TS files, and the names bound to a known service's client."""
|
|
226
|
+
|
|
227
|
+
def __init__(self, sources: dict[str, tuple[str, str]]):
|
|
228
|
+
self.files: dict[str, Source] = {}
|
|
229
|
+
self.funcs: dict[str, Func] = {}
|
|
230
|
+
self.getters: dict[tuple[str, str], tuple[int, int]] = {} # (path, name) -> its expression span
|
|
231
|
+
self.closures: dict[str, list[tuple[int, int]]] = {} # path -> anonymous function bodies
|
|
232
|
+
self._effects: dict[str, tuple[set[str], set[str]]] = {}
|
|
233
|
+
for path, (language, text) in sources.items():
|
|
234
|
+
code = mask(text, language)
|
|
235
|
+
src = Source(path, language, text, code, [0] + [m.end() for m in re.finditer("\n", text)])
|
|
236
|
+
self.files[path] = src
|
|
237
|
+
self._functions(src)
|
|
238
|
+
self.closures[path] = self._closures(code)
|
|
239
|
+
|
|
240
|
+
def _class_at(self, src: str, classes: list[tuple[int, int, str]], at: int) -> str | None:
|
|
241
|
+
inside = [c for c in classes if c[0] < at < c[1]]
|
|
242
|
+
return max(inside, key=lambda c: c[0])[2] if inside else None
|
|
243
|
+
|
|
244
|
+
def _add(self, src: Source, name: str, cls: str | None, at: int, is_async: bool, body: tuple[int, int],
|
|
245
|
+
params: set[str]) -> None:
|
|
246
|
+
full = cls if name == cls else f"{cls}.{name}" if cls else name # a constructor is named for its class
|
|
247
|
+
key = f"{src.path}:{full}"
|
|
248
|
+
if key not in self.funcs:
|
|
249
|
+
self.funcs[key] = Func(src.path, full, src.language, src.line(at), is_async, body, params)
|
|
250
|
+
|
|
251
|
+
def _functions(self, src: Source) -> None:
|
|
252
|
+
code = src.code
|
|
253
|
+
classes = [(m.end() - 1, _match(code, m.end() - 1), m.group(1)) for m in _CLASS.finditer(code)]
|
|
254
|
+
for m in _GETTER.finditer(code):
|
|
255
|
+
start = m.end()
|
|
256
|
+
end = _match(code, m.end() - 1) if m.group(2) == "{" else self._expr_end(code, start)
|
|
257
|
+
self.getters[(src.path, m.group(1))] = (start, end)
|
|
258
|
+
for m in _CALL.finditer(code):
|
|
259
|
+
name = m.group(1)
|
|
260
|
+
if name in KEYWORDS:
|
|
261
|
+
continue
|
|
262
|
+
before = code[:m.start()].rstrip()
|
|
263
|
+
prev = re.search(rf"({_IDENT})\s*$", before)
|
|
264
|
+
# a declaration follows a type (`Future<Trip?>`, `void`), `function`, or the end of something else
|
|
265
|
+
if before.endswith((".", "=", ",", "(", "!", ":", "&", "|", "+", "-", "*", "/", "[")) \
|
|
266
|
+
or (prev and prev.group(1) in {"new", "await", "return", "throw", "yield", "case", "else", "get"}):
|
|
267
|
+
continue
|
|
268
|
+
paren = m.end() - 1
|
|
269
|
+
close = _match(code, paren)
|
|
270
|
+
j = _skip(code, close + 1)
|
|
271
|
+
if code.startswith(":", j): # a TS return type, or a Dart constructor's initialisers
|
|
272
|
+
brace = code.find("{", j)
|
|
273
|
+
arrow = code.find("=>", j)
|
|
274
|
+
semi = code.find(";", j)
|
|
275
|
+
stops = [x for x in (brace, arrow, semi) if x >= 0]
|
|
276
|
+
if not stops or min(stops) == semi:
|
|
277
|
+
continue
|
|
278
|
+
j = min(stops)
|
|
279
|
+
is_async = False
|
|
280
|
+
tail = re.match(r"(async\*?|sync\*)\s*", code[j:j + 12])
|
|
281
|
+
if tail:
|
|
282
|
+
is_async = tail.group(1).startswith("async")
|
|
283
|
+
j += tail.end()
|
|
284
|
+
head = code[max(0, before.rfind("\n", 0, len(before)) + 1):m.start()]
|
|
285
|
+
is_async = is_async or bool(re.search(r"\basync\b", head))
|
|
286
|
+
if code.startswith("{", j):
|
|
287
|
+
body = (j + 1, _match(code, j))
|
|
288
|
+
elif code.startswith("=>", j):
|
|
289
|
+
body = (j + 2, self._expr_end(code, j + 2))
|
|
290
|
+
else:
|
|
291
|
+
continue
|
|
292
|
+
self._add(src, name, self._class_at(src.path, classes, m.start()), m.start(), is_async, body,
|
|
293
|
+
_params(code, paren))
|
|
294
|
+
for m in _ARROW.finditer(code):
|
|
295
|
+
name = m.group(1)
|
|
296
|
+
if name in KEYWORDS:
|
|
297
|
+
continue
|
|
298
|
+
if m.group(3) == "{":
|
|
299
|
+
body = (m.end(), _match(code, m.end() - 1))
|
|
300
|
+
else:
|
|
301
|
+
j = _skip(code, m.end())
|
|
302
|
+
body = (j + 1, _match(code, j)) if code.startswith("{", j) else (j, self._expr_end(code, j))
|
|
303
|
+
paren = code.find("(", m.start(), m.end())
|
|
304
|
+
self._add(src, name, self._class_at(src.path, classes, m.start()), m.start(), bool(m.group(2)), body,
|
|
305
|
+
_params(code, paren) if paren >= 0 else set())
|
|
306
|
+
|
|
307
|
+
def _closures(self, code: str) -> list[tuple[int, int]]:
|
|
308
|
+
out = []
|
|
309
|
+
for m in _CLOSURE.finditer(code):
|
|
310
|
+
out.append((m.end(), _match(code, m.end() - 1)) if m.group(1) == "{" else
|
|
311
|
+
(m.end(), self._expr_end(code, m.end())))
|
|
312
|
+
for m in _CLOSURE_ONE.finditer(code):
|
|
313
|
+
j = _skip(code, m.end())
|
|
314
|
+
out.append((j + 1, _match(code, j)) if code.startswith("{", j) else (j, self._expr_end(code, j)))
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
def skips(self, func: Func, span: tuple[int, int]) -> list[tuple[int, int]]:
|
|
318
|
+
"""What inside `span` doesn't run as part of it: other functions declared there, and callbacks. A statement
|
|
319
|
+
that gathers (`Future.wait`, `Promise.all`) runs its callbacks now, so they count."""
|
|
320
|
+
inner = [b for b in self.nested(func, span) if b != func.body]
|
|
321
|
+
if _GATHER.search(self.files[func.path].code[span[0]:span[1]]):
|
|
322
|
+
return inner
|
|
323
|
+
return inner + [c for c in self.closures.get(func.path, [])
|
|
324
|
+
if span[0] <= c[0] and c[1] <= span[1] and c != func.body]
|
|
325
|
+
|
|
326
|
+
def effects(self, func: Func) -> tuple[set[str], set[str]]:
|
|
327
|
+
"""The names outside a function (fields, globals) that it reads and writes, through the repo functions it
|
|
328
|
+
calls too: `loadTrips()` filling `_trips` orders it before a line that reads `_trips` (D32)."""
|
|
329
|
+
if func.key in self._effects:
|
|
330
|
+
return self._effects[func.key]
|
|
331
|
+
self._effects[func.key] = (set(), set())
|
|
332
|
+
a, b = func.body
|
|
333
|
+
text = self.files[func.path].code[a:b].replace("this.", "")
|
|
334
|
+
local = set(_LOCAL.findall(text)) | func.params | set(_FOR_IN.findall(text))
|
|
335
|
+
writes = {n for n in _FIELD_WRITE.findall(text)} | set(_MUTATE.findall(text)) \
|
|
336
|
+
| {x or y for x, y in _INCREMENT.findall(text)}
|
|
337
|
+
reads = {n for n in _NAME.findall(text) if n not in KEYWORDS}
|
|
338
|
+
reads, writes = reads - local, (writes - local) - KEYWORDS
|
|
339
|
+
for _, target in self.calls(func):
|
|
340
|
+
r, w = self.effects(target)
|
|
341
|
+
reads |= r
|
|
342
|
+
writes |= w
|
|
343
|
+
self._effects[func.key] = (reads, writes)
|
|
344
|
+
return reads, writes
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _expr_end(code: str, i: int) -> int:
|
|
348
|
+
"""Where an arrow's expression ends: the `;`, or the `,` / closing bracket of what holds it, at depth 0."""
|
|
349
|
+
depth = 0
|
|
350
|
+
for j in range(i, len(code)):
|
|
351
|
+
c = code[j]
|
|
352
|
+
if c in "([{":
|
|
353
|
+
depth += 1
|
|
354
|
+
elif c in ")]}":
|
|
355
|
+
if depth == 0:
|
|
356
|
+
return j
|
|
357
|
+
depth -= 1
|
|
358
|
+
elif c in ";," and depth == 0:
|
|
359
|
+
return j
|
|
360
|
+
return len(code)
|
|
361
|
+
|
|
362
|
+
def resolve(self, name: str, func: Func) -> Func | None:
|
|
363
|
+
"""A call by name: a method of the same class, then the same file, then a name unique in the repo."""
|
|
364
|
+
if "." in name:
|
|
365
|
+
found = [f for f in self.funcs.values() if f.name == name]
|
|
366
|
+
return found[0] if len(found) == 1 else None
|
|
367
|
+
cls = func.name.rsplit(".", 1)[0] if "." in func.name else None
|
|
368
|
+
for key in ([f"{func.path}:{cls}.{name}"] if cls else []) + [f"{func.path}:{name}"]:
|
|
369
|
+
if key in self.funcs:
|
|
370
|
+
return self.funcs[key]
|
|
371
|
+
found = [f for f in self.funcs.values() if f.short == name and "." not in f.name]
|
|
372
|
+
return found[0] if len(found) == 1 else None
|
|
373
|
+
|
|
374
|
+
def calls(self, func: Func, span: tuple[int, int] | None = None) -> list[tuple[int, Func]]:
|
|
375
|
+
"""The repo functions called in `span` (default: the whole body), with where."""
|
|
376
|
+
src = self.files[func.path]
|
|
377
|
+
a, b = span or func.body
|
|
378
|
+
skip = self.skips(func, (a, b))
|
|
379
|
+
out: list[tuple[int, Func]] = []
|
|
380
|
+
for m in _CALL.finditer(src.code, a, b):
|
|
381
|
+
target = self.resolve(m.group(1), func)
|
|
382
|
+
if target is not None and target is not func and not any(x <= m.start() < y for x, y in skip):
|
|
383
|
+
out.append((m.start(), target))
|
|
384
|
+
for m in _STATIC_CALL.finditer(src.code, a, b):
|
|
385
|
+
target = self.resolve(f"{m.group(1)}.{m.group(2)}", func)
|
|
386
|
+
if target is not None and target is not func and not any(x <= m.start() < y for x, y in skip):
|
|
387
|
+
out.append((m.start(), target))
|
|
388
|
+
return sorted(out, key=lambda c: c[0])
|
|
389
|
+
|
|
390
|
+
def nested(self, func: Func, span: tuple[int, int]) -> list[tuple[int, int]]:
|
|
391
|
+
"""Bodies of other functions declared inside `span`: their calls belong to them, not to this statement."""
|
|
392
|
+
return [f.body for f in self.funcs.values()
|
|
393
|
+
if f.path == func.path and f is not func and span[0] <= f.body[0] and f.body[1] <= span[1]]
|
|
394
|
+
|
|
395
|
+
def callers(self) -> dict[str, set[str]]:
|
|
396
|
+
out: dict[str, set[str]] = {k: set() for k in self.funcs}
|
|
397
|
+
for func in self.funcs.values():
|
|
398
|
+
for _, target in self.calls(func):
|
|
399
|
+
out[target.key].add(func.key)
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class Reach:
|
|
404
|
+
"""Which dependencies a Dart/JS/TS statement or function reaches: the scan's call places inside it, a known
|
|
405
|
+
service's client it uses, and the repo functions it calls, followed through (D32)."""
|
|
406
|
+
|
|
407
|
+
def __init__(self, index: Index, places: Places, dep_ids: set[str]):
|
|
408
|
+
self.index = index
|
|
409
|
+
self.at: dict[tuple[str, int], list[str]] = {}
|
|
410
|
+
for dep_id, items in places.by_dep.items():
|
|
411
|
+
for path, line, text in items:
|
|
412
|
+
if text in CALL_TEXTS and path in index.files:
|
|
413
|
+
self.at.setdefault((path, line), []).append(dep_id)
|
|
414
|
+
# a service's client: its own expression, and every name the repo binds to it
|
|
415
|
+
self.clients: list[tuple[re.Pattern, str]] = []
|
|
416
|
+
for service_id, patterns in CLIENTS.items():
|
|
417
|
+
service = services.by_id(service_id)
|
|
418
|
+
slug = re.sub(r"[^a-z0-9]+", "-", service_id.lower()).strip("-")
|
|
419
|
+
dep_id = f"{service['kind']}-{slug}" if service else None
|
|
420
|
+
if dep_id not in dep_ids:
|
|
421
|
+
continue
|
|
422
|
+
expr = re.compile("|".join(patterns))
|
|
423
|
+
self.clients.append((expr, dep_id))
|
|
424
|
+
for src in index.files.values():
|
|
425
|
+
for (path, name), (a, b) in index.getters.items():
|
|
426
|
+
if path == src.path and expr.search(src.code[a:b]):
|
|
427
|
+
self.clients.append((re.compile(rf"(?<![\w$.]){re.escape(name)}\b"), dep_id))
|
|
428
|
+
for m in re.finditer(rf"({_IDENT})\s*(?::[^=;]+)?=\s*(?:await\s+)?([^;\n]+)", src.code):
|
|
429
|
+
if expr.search(m.group(2)) and m.group(1) not in KEYWORDS:
|
|
430
|
+
self.clients.append((re.compile(rf"(?<![\w$.]){re.escape(m.group(1))}\b"), dep_id))
|
|
431
|
+
self._memo: dict[str, list[Hit]] = {}
|
|
432
|
+
|
|
433
|
+
def statement(self, func: Func, span: tuple[int, int]) -> list[Hit]:
|
|
434
|
+
src = self.index.files[func.path]
|
|
435
|
+
skip = self.index.skips(func, span)
|
|
436
|
+
inside = lambda at: span[0] <= at < span[1] and not any(a <= at < b for a, b in skip) # noqa: E731
|
|
437
|
+
text = src.code[span[0]:span[1]]
|
|
438
|
+
method = _method(text)
|
|
439
|
+
found: list[tuple[int, Hit]] = []
|
|
440
|
+
for line in range(src.line(span[0]), src.line(max(span[0], span[1] - 1)) + 1):
|
|
441
|
+
at = src.starts[line - 1]
|
|
442
|
+
if any(a <= at and src.starts[min(line, len(src.starts) - 1)] <= b for a, b in skip):
|
|
443
|
+
continue
|
|
444
|
+
for dep in self.at.get((func.path, line), []):
|
|
445
|
+
found.append((max(at, span[0]), Hit(dep, method=method)))
|
|
446
|
+
for pattern, dep in self.clients:
|
|
447
|
+
for m in pattern.finditer(src.code, span[0], span[1]):
|
|
448
|
+
if inside(m.start()):
|
|
449
|
+
tail = src.code[m.start():_simple_end(src.code, m.start(), span[1], src.language)]
|
|
450
|
+
found.append((m.start(), Hit(dep, method="POST" if _SDK_WRITE.search(tail) else "GET")))
|
|
451
|
+
for at, target in self.index.calls(func, span):
|
|
452
|
+
if inside(at):
|
|
453
|
+
found += [(at, h) for h in self.function(target)]
|
|
454
|
+
found.sort(key=lambda f: f[0])
|
|
455
|
+
out: list[Hit] = []
|
|
456
|
+
for _, hit in found:
|
|
457
|
+
same = next((h for h in out if h.dep == hit.dep), None)
|
|
458
|
+
if same is None:
|
|
459
|
+
out.append(Hit(hit.dep, hit.method, hit.tool))
|
|
460
|
+
elif hit.method not in (None, "GET", "HEAD") or same.method is None:
|
|
461
|
+
same.method = hit.method # reads and writes the same service: it writes (D25's rule)
|
|
462
|
+
return out
|
|
463
|
+
|
|
464
|
+
def function(self, func: Func) -> list[Hit]:
|
|
465
|
+
if func.key in self._memo:
|
|
466
|
+
return self._memo[func.key]
|
|
467
|
+
self._memo[func.key] = []
|
|
468
|
+
hits = self.statement(func, func.body)
|
|
469
|
+
self._memo[func.key] = hits
|
|
470
|
+
return hits
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _method(text: str) -> str | None:
|
|
474
|
+
if m := _FETCH_METHOD.search(text):
|
|
475
|
+
return m.group(1).upper()
|
|
476
|
+
if m := _HTTP_METHOD.search(text):
|
|
477
|
+
return m.group(1).upper()
|
|
478
|
+
return "GET" if "fetch(" in text else None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@dataclass
|
|
482
|
+
class Part:
|
|
483
|
+
"""What one statement is, beyond `Stmt`: its spans, its block and the declaration it makes."""
|
|
484
|
+
|
|
485
|
+
span: tuple[int, int]
|
|
486
|
+
block: int # the start of the block it sits in
|
|
487
|
+
keyword: str | None = None # final, var, const, let
|
|
488
|
+
typed: bool = False # a declared type (Dart `final Trip? trip`, TS `const a: T`)
|
|
489
|
+
cond: tuple[int, int] | None = None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _ends_line(code: str, i: int, language: str) -> bool:
|
|
493
|
+
"""JS without `;`: does the line break at `i` end the statement?"""
|
|
494
|
+
if language == "dart":
|
|
495
|
+
return False
|
|
496
|
+
before = code[:i].rstrip()
|
|
497
|
+
after = code[i:].lstrip()
|
|
498
|
+
if not before or before[-1] in "=+-*/%&|^,(.[{?:<>!":
|
|
499
|
+
return False
|
|
500
|
+
return not (after[:1] and after[0] in ".?:+-*/%&|^,)]}=" or re.match(r"(else|catch|finally)\b", after))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _simple_end(code: str, i: int, end: int, language: str) -> int:
|
|
504
|
+
"""The end of a simple statement starting at `i`: after its `;`, at a line break that ends it (JS), or before the
|
|
505
|
+
`}` that closes the block."""
|
|
506
|
+
depth = 0
|
|
507
|
+
j = i
|
|
508
|
+
while j < end:
|
|
509
|
+
c = code[j]
|
|
510
|
+
if c in "([{":
|
|
511
|
+
depth += 1
|
|
512
|
+
elif c in ")]}":
|
|
513
|
+
if depth == 0:
|
|
514
|
+
return j
|
|
515
|
+
depth -= 1
|
|
516
|
+
if depth == 0 and c == "}":
|
|
517
|
+
nxt = _skip(code, j + 1, end)
|
|
518
|
+
goes_on = code[nxt] in ".,;)]?:+-*/%&|^=" or _CONTINUES.match(code, nxt)
|
|
519
|
+
if nxt >= end or (not goes_on and "\n" in code[j:nxt]):
|
|
520
|
+
return j + 1
|
|
521
|
+
elif c == ";" and depth == 0:
|
|
522
|
+
return j + 1
|
|
523
|
+
elif c == "\n" and depth == 0 and j > i and _ends_line(code, j, language):
|
|
524
|
+
return j
|
|
525
|
+
j += 1
|
|
526
|
+
return end
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class _Reader:
|
|
530
|
+
"""One function's statements in order (D25's `Body`), from its masked code."""
|
|
531
|
+
|
|
532
|
+
def __init__(self, func: Func, index: Index, reach: Reach):
|
|
533
|
+
self.func, self.index, self.reach = func, index, reach
|
|
534
|
+
self.src = index.files[func.path]
|
|
535
|
+
self.code = self.src.code
|
|
536
|
+
self.body = Body(func) # type: ignore[arg-type]
|
|
537
|
+
self.parts: dict[int, Part] = {}
|
|
538
|
+
|
|
539
|
+
def add(self, span: tuple[int, int], ifs: list[tuple[int, str]], loop: bool, block: int, *, is_if: bool = False,
|
|
540
|
+
hit_span: tuple[int, int] | None = None, header: str | None = None) -> Stmt:
|
|
541
|
+
a, b = span
|
|
542
|
+
text = self.code[a:b]
|
|
543
|
+
reads = {n for n in _NAME.findall(text) if n not in KEYWORDS}
|
|
544
|
+
writes: set[str] = set()
|
|
545
|
+
keyword, typed = None, False
|
|
546
|
+
if not is_if:
|
|
547
|
+
if m := _DECL.match(text):
|
|
548
|
+
keyword, typed = m.group(1), bool(m.group(3)) or self.src.language == "dart" and \
|
|
549
|
+
bool(re.match(rf"\s*(?:late\s+)?(?:final|var|const)\s+[\w$<>?,\[\]. ]+?\s+{_IDENT}\s*=", text))
|
|
550
|
+
writes.add(m.group(2))
|
|
551
|
+
if m := _DESTRUCT.match(text):
|
|
552
|
+
writes |= {n for n in re.findall(_IDENT, m.group(1)) if n not in KEYWORDS}
|
|
553
|
+
if m := _TYPED.match(text):
|
|
554
|
+
writes.add(m.group(2))
|
|
555
|
+
if m := _ASSIGN.match(text):
|
|
556
|
+
writes.add(m.group(1))
|
|
557
|
+
writes |= {n for n in _MUTATE.findall(text)}
|
|
558
|
+
writes |= {x or y for x, y in _INCREMENT.findall(text)}
|
|
559
|
+
if header == "for":
|
|
560
|
+
writes |= set(_FOR_IN.findall(text))
|
|
561
|
+
reads -= writes if keyword else set()
|
|
562
|
+
for _, target in self.index.calls(self.func, span): # what the repo functions it calls read and write
|
|
563
|
+
r, w = self.index.effects(target)
|
|
564
|
+
reads |= r
|
|
565
|
+
writes |= w
|
|
566
|
+
stripped = text.lstrip()
|
|
567
|
+
s = Stmt(idx=len(self.body.stmts), node=None, line=self.src.line(a + len(text) - len(stripped)), reads=reads,
|
|
568
|
+
writes=writes, hits=self.reach.statement(self.func, hit_span or span) if hit_span != (0, 0) else [],
|
|
569
|
+
ifs=list(ifs), loop=loop, returns=bool(re.match(r"(return|throw|rethrow)\b", stripped)),
|
|
570
|
+
gathered=bool(_GATHER.search(text)), is_if=is_if)
|
|
571
|
+
self.body.stmts.append(s)
|
|
572
|
+
self.parts[s.idx] = Part(span=span, block=block, keyword=keyword, typed=typed)
|
|
573
|
+
return s
|
|
574
|
+
|
|
575
|
+
def extent(self, i: int, end: int) -> int:
|
|
576
|
+
"""Where the statement starting at `i` ends, with its blocks, `else` and `catch` included."""
|
|
577
|
+
code = self.code
|
|
578
|
+
if code.startswith("{", i):
|
|
579
|
+
return _match(code, i) + 1
|
|
580
|
+
word = _word(code, i)
|
|
581
|
+
if word in ("if", "for", "while", "switch") and code.startswith("(", _skip(code, i + len(word), end)):
|
|
582
|
+
p = _skip(code, i + len(word), end)
|
|
583
|
+
j = self.extent(_skip(code, _match(code, p) + 1, end), end)
|
|
584
|
+
if word == "if":
|
|
585
|
+
k = _skip(code, j, end)
|
|
586
|
+
if _word(code, k) == "else":
|
|
587
|
+
j = self.extent(_skip(code, k + 4, end), end)
|
|
588
|
+
return j
|
|
589
|
+
if word == "do":
|
|
590
|
+
j = self.extent(_skip(code, i + 2, end), end)
|
|
591
|
+
return _simple_end(code, _skip(code, j, end), end, self.src.language)
|
|
592
|
+
if word == "try":
|
|
593
|
+
j = self.extent(_skip(code, i + 3, end), end)
|
|
594
|
+
while True:
|
|
595
|
+
k = _skip(code, j, end)
|
|
596
|
+
w = _word(code, k)
|
|
597
|
+
if w == "catch" or w == "finally" or w == "on":
|
|
598
|
+
brace = code.find("{", k, end)
|
|
599
|
+
if brace < 0:
|
|
600
|
+
return j
|
|
601
|
+
j = _match(code, brace) + 1
|
|
602
|
+
else:
|
|
603
|
+
return j
|
|
604
|
+
return _simple_end(code, i, end, self.src.language)
|
|
605
|
+
|
|
606
|
+
def walk(self, a: int, b: int, ifs: list[tuple[int, str]], loop: bool) -> None:
|
|
607
|
+
code = self.code
|
|
608
|
+
i = a
|
|
609
|
+
while True:
|
|
610
|
+
i = _skip(code, i, b)
|
|
611
|
+
while i < b and code[i] == ";":
|
|
612
|
+
i = _skip(code, i + 1, b)
|
|
613
|
+
if i >= b:
|
|
614
|
+
return
|
|
615
|
+
word = _word(code, i)
|
|
616
|
+
if code.startswith("{", i):
|
|
617
|
+
close = _match(code, i)
|
|
618
|
+
self.walk(i + 1, close, ifs, loop)
|
|
619
|
+
i = close + 1
|
|
620
|
+
elif word == "if" and code.startswith("(", _skip(code, i + 2, b)):
|
|
621
|
+
i = self._if(i, b, ifs, loop)
|
|
622
|
+
elif word in ("for", "while") and code.startswith("(", _skip(code, i + len(word), b)):
|
|
623
|
+
p = _skip(code, i + len(word), b)
|
|
624
|
+
q = _match(code, p)
|
|
625
|
+
self.add((p + 1, q), ifs, loop, a, header="for" if word == "for" else None)
|
|
626
|
+
body_start = _skip(code, q + 1, b)
|
|
627
|
+
end = self.extent(body_start, b)
|
|
628
|
+
self._branch(body_start, end, ifs, True)
|
|
629
|
+
i = end
|
|
630
|
+
elif word == "do":
|
|
631
|
+
body_start = _skip(code, i + 2, b)
|
|
632
|
+
end = self.extent(body_start, b)
|
|
633
|
+
self._branch(body_start, end, ifs, True)
|
|
634
|
+
tail = _skip(code, end, b)
|
|
635
|
+
close = _simple_end(code, tail, b, self.src.language)
|
|
636
|
+
self.add((tail, close), ifs, loop, a)
|
|
637
|
+
i = close
|
|
638
|
+
elif word == "try":
|
|
639
|
+
end = self.extent(i, b)
|
|
640
|
+
j = _skip(code, i + 3, b)
|
|
641
|
+
while j < end:
|
|
642
|
+
brace = code.find("{", j, end)
|
|
643
|
+
if brace < 0:
|
|
644
|
+
break
|
|
645
|
+
close = _match(code, brace)
|
|
646
|
+
self.walk(brace + 1, close, ifs, loop)
|
|
647
|
+
j = close + 1
|
|
648
|
+
i = end
|
|
649
|
+
else:
|
|
650
|
+
end = self.extent(i, b)
|
|
651
|
+
self.add((i, end), ifs, loop, a)
|
|
652
|
+
i = end
|
|
653
|
+
|
|
654
|
+
def _branch(self, start: int, end: int, ifs: list[tuple[int, str]], loop: bool) -> None:
|
|
655
|
+
if self.code.startswith("{", start):
|
|
656
|
+
self.walk(start + 1, end - 1, ifs, loop)
|
|
657
|
+
else:
|
|
658
|
+
self.walk(start, end, ifs, loop)
|
|
659
|
+
|
|
660
|
+
def _if(self, i: int, b: int, ifs: list[tuple[int, str]], loop: bool) -> int:
|
|
661
|
+
code = self.code
|
|
662
|
+
p = _skip(code, i + 2, b)
|
|
663
|
+
q = _match(code, p)
|
|
664
|
+
test = self.add((p + 1, q), ifs, loop, i, is_if=True)
|
|
665
|
+
self.parts[test.idx].cond = (p + 1, q)
|
|
666
|
+
first = len(self.body.stmts)
|
|
667
|
+
body_start = _skip(code, q + 1, b)
|
|
668
|
+
body_end = self.extent(body_start, b)
|
|
669
|
+
self._branch(body_start, body_end, [*ifs, (test.idx, "yes")], loop)
|
|
670
|
+
after_body = len(self.body.stmts)
|
|
671
|
+
end = body_end
|
|
672
|
+
k = _skip(code, body_end, b)
|
|
673
|
+
if _word(code, k) == "else":
|
|
674
|
+
else_start = _skip(code, k + 4, b)
|
|
675
|
+
end = self.extent(else_start, b)
|
|
676
|
+
self._branch(else_start, end, [*ifs, (test.idx, "no")], loop)
|
|
677
|
+
test.block_end = len(self.body.stmts) - 1
|
|
678
|
+
yes = next((s for s in self.body.stmts[first:after_body] if s.returns and not s.loop), None)
|
|
679
|
+
no = next((s for s in self.body.stmts[after_body:] if s.returns and not s.loop), None)
|
|
680
|
+
if yes and not no:
|
|
681
|
+
test.stops, test.stop_line = "yes", yes.line
|
|
682
|
+
elif no and not yes:
|
|
683
|
+
test.stops, test.stop_line = "no", no.line
|
|
684
|
+
return end
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def read(func: Func, index: Index, reach: Reach) -> tuple[Body, dict[int, Part]]:
|
|
688
|
+
reader = _Reader(func, index, reach)
|
|
689
|
+
a, b = func.body
|
|
690
|
+
if index.files[func.path].code[a - 2:a] == "=>" or not index.files[func.path].code[a - 1:a] == "{":
|
|
691
|
+
reader.add((a, b), [], False, a) # an arrow's expression is one statement
|
|
692
|
+
else:
|
|
693
|
+
reader.walk(a, b, [], False)
|
|
694
|
+
return reader.body, reader.parts
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def entries(index: Index, reach: Reach) -> list[Func]:
|
|
698
|
+
"""Functions that reach a dependency and that nothing in the repo calls, in file and line order."""
|
|
699
|
+
callers = index.callers()
|
|
700
|
+
found = [f for f in index.funcs.values() if reach.function(f) and not callers[f.key]]
|
|
701
|
+
return sorted(found, key=lambda f: (f.path, f.line))
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class Lang:
|
|
705
|
+
"""What the shared flow builder asks of a Dart/JS/TS function (D32)."""
|
|
706
|
+
|
|
707
|
+
name = "curly"
|
|
708
|
+
|
|
709
|
+
def __init__(self, index: Index):
|
|
710
|
+
self.index = index
|
|
711
|
+
self.parts: dict[str, dict[int, Part]] = {} # func key -> statement index -> Part
|
|
712
|
+
self.bodies: dict[str, Body] = {}
|
|
713
|
+
|
|
714
|
+
def read(self, func: Func, reach: Reach) -> Body:
|
|
715
|
+
if func.key not in self.bodies:
|
|
716
|
+
self.bodies[func.key], self.parts[func.key] = read(func, self.index, reach)
|
|
717
|
+
return self.bodies[func.key]
|
|
718
|
+
|
|
719
|
+
def start_line(self, func: Func) -> int:
|
|
720
|
+
return func.line
|
|
721
|
+
|
|
722
|
+
@staticmethod
|
|
723
|
+
def gather(func: Func) -> str:
|
|
724
|
+
return "a record's .wait" if func.language == "dart" else "Promise.all"
|
|
725
|
+
|
|
726
|
+
def change(self, calls: list[Call]) -> Change | None:
|
|
727
|
+
return change(calls, self)
|
|
728
|
+
|
|
729
|
+
def is_async(self, func: Func) -> bool:
|
|
730
|
+
return func.is_async
|
|
731
|
+
|
|
732
|
+
def _part(self, func: Func, s: Stmt) -> Part:
|
|
733
|
+
return self.parts[func.key][s.idx]
|
|
734
|
+
|
|
735
|
+
def text(self, func: Func, s: Stmt, *, raw: bool = False) -> str:
|
|
736
|
+
p = self._part(func, s)
|
|
737
|
+
src = self.index.files[func.path]
|
|
738
|
+
a, b = p.cond or p.span
|
|
739
|
+
return (src.text if raw else src.code)[a:b]
|
|
740
|
+
|
|
741
|
+
def end_line(self, func: Func, s: Stmt) -> int:
|
|
742
|
+
p = self._part(func, s)
|
|
743
|
+
return self.index.files[func.path].line(max(p.span[0], p.span[1] - 1))
|
|
744
|
+
|
|
745
|
+
def awaited(self, func: Func, s: Stmt) -> bool:
|
|
746
|
+
return bool(_AWAIT.search(self.text(func, s)))
|
|
747
|
+
|
|
748
|
+
def label(self, func: Func, s: Stmt) -> str:
|
|
749
|
+
"""A string with spaces it passes, without a trailing note in brackets; else the repo function it calls."""
|
|
750
|
+
raw = self.text(func, s, raw=True)
|
|
751
|
+
for m in _STRING.finditer(raw):
|
|
752
|
+
value = m.group(2).strip()
|
|
753
|
+
if " " in value and "$" not in value and "http" not in value:
|
|
754
|
+
return re.sub(r"\s*\([^)]*\)\s*$", "", value)[:120]
|
|
755
|
+
p = self._part(func, s)
|
|
756
|
+
calls = self.index.calls(func, p.span)
|
|
757
|
+
return words(calls[0][1].short) if calls else ""
|
|
758
|
+
|
|
759
|
+
def condition(self, func: Func, s: Stmt) -> str:
|
|
760
|
+
return re.sub(r"\s+", " ", self.text(func, s, raw=True)).strip()
|
|
761
|
+
|
|
762
|
+
def keep_decision(self, func: Func, body: Body, s: Stmt) -> bool:
|
|
763
|
+
"""A check that only stops on UI state (`!mounted || request != _request`) isn't the app's flow (D33): keep a
|
|
764
|
+
decision that guards a call, or whose condition reads a call's result or an input."""
|
|
765
|
+
if any(t.hits for t in body.stmts[s.idx + 1: s.block_end + 1]):
|
|
766
|
+
return True
|
|
767
|
+
# names that carry a call's result or an input, followed through plain code: `ok = res.ok` after `res = await …`
|
|
768
|
+
carried = set(func.params)
|
|
769
|
+
for t in body.stmts[: s.idx]:
|
|
770
|
+
if t.hits or t.reads & carried:
|
|
771
|
+
carried |= t.writes
|
|
772
|
+
return bool(s.reads & carried)
|
|
773
|
+
|
|
774
|
+
def question(self, func: Func, body: Body, s: Stmt) -> str:
|
|
775
|
+
cond = re.sub(r"\s+", " ", self.text(func, s)).strip()
|
|
776
|
+
name, failed = None, False
|
|
777
|
+
if m := re.fullmatch(rf"({_IDENT})\s*(==|===|!=|!==)\s*(null|undefined)", cond):
|
|
778
|
+
name, failed = m.group(1), m.group(2).startswith("=")
|
|
779
|
+
elif m := re.fullmatch(rf"!\s*({_IDENT})(?:\.ok)?", cond):
|
|
780
|
+
name, failed = m.group(1), True
|
|
781
|
+
elif m := re.fullmatch(rf"({_IDENT})\.(isEmpty|isNotEmpty|ok)", cond):
|
|
782
|
+
name, failed = m.group(1), m.group(2) == "isEmpty"
|
|
783
|
+
elif m := re.fullmatch(_IDENT, cond):
|
|
784
|
+
name = cond
|
|
785
|
+
if name is None:
|
|
786
|
+
return f"Is {self.condition(func, s)} true?"
|
|
787
|
+
writer = next((t for t in reversed(body.stmts[: s.idx]) if name in t.writes and t.hits), None)
|
|
788
|
+
if writer is not None and (said := self.label(func, writer)):
|
|
789
|
+
return f'Did "{said}" {"fail" if failed else "work"}?'
|
|
790
|
+
said = words(name)
|
|
791
|
+
if name in func.params:
|
|
792
|
+
return f"No {said} given?" if failed else f"Was a {said} given?"
|
|
793
|
+
return f"No {said}?" if failed else f"Has a {said}?"
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
_DECL_AWAIT = re.compile(rf"^(final|var|const|let)\s+(?:([\w$<>?,\[\]. ]+?)\s+)?({_IDENT})\s*(?::\s*([^=]+?))?\s*="
|
|
797
|
+
r"\s*await\s+(.+?)\s*(;?)\s*$", re.DOTALL)
|
|
798
|
+
_ASSIGN_AWAIT = re.compile(rf"^({_IDENT})\s*=\s*await\s+(.+?)\s*(;?)\s*$", re.DOTALL)
|
|
799
|
+
_BARE_AWAIT = re.compile(r"^await\s+(.+?)\s*(;?)\s*$", re.DOTALL)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _one_line(expr: str) -> str:
|
|
803
|
+
"""A call chain written over several lines, on one: `supabaseClient\n .from('x')` → `supabaseClient.from('x')`."""
|
|
804
|
+
return re.sub(r"\s+", " ", re.sub(r"\s*\n\s*(?=\.)", "", expr))
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _plain(raw: str) -> tuple[str | None, str | None, str | None, str, bool, str | None] | None:
|
|
808
|
+
"""`final x = await e;` → (keyword, dart type, name, e, semicolon, ts type); `x = await e;` → (None, None, x, …);
|
|
809
|
+
`await e;` → (None, None, None, e, …). Anything else → None."""
|
|
810
|
+
raw = raw.strip()
|
|
811
|
+
if m := _DECL_AWAIT.match(raw):
|
|
812
|
+
keyword, dart_type, name, ts_type, expr, semi = m.groups()
|
|
813
|
+
return keyword, dart_type, name, _one_line(expr), bool(semi), ts_type
|
|
814
|
+
if m := _ASSIGN_AWAIT.match(raw):
|
|
815
|
+
return None, None, m.group(1), _one_line(m.group(2)), bool(m.group(3)), None
|
|
816
|
+
if m := _BARE_AWAIT.match(raw):
|
|
817
|
+
return None, None, None, _one_line(m.group(1)), bool(m.group(2)), None
|
|
818
|
+
return None
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def change(calls: list[Call], lang: Lang) -> Change | None:
|
|
822
|
+
"""The calls written to run together (D27, D32): Dart's record `.wait`, JS/TS `Promise.all`, when one block of one
|
|
823
|
+
async function holds them as plain `x = await …` with nothing between using a result or changing an input."""
|
|
824
|
+
func: Func = calls[0].func
|
|
825
|
+
if not func.is_async or any(c.func is not func for c in calls):
|
|
826
|
+
return None
|
|
827
|
+
parts = lang.parts[func.key]
|
|
828
|
+
src = lang.index.files[func.path]
|
|
829
|
+
blocks: dict[int, list[Call]] = {}
|
|
830
|
+
for c in calls:
|
|
831
|
+
blocks.setdefault(parts[c.stmt.idx].block, []).append(c)
|
|
832
|
+
chosen = max(blocks.values(), key=lambda g: (len(g), -g[0].line))
|
|
833
|
+
plain = [(c, p) for c in chosen if (p := _plain(lang.text(func, c.stmt, raw=True)))
|
|
834
|
+
and not (func.language == "typescript" and p[5])]
|
|
835
|
+
if len(plain) < 2:
|
|
836
|
+
return None
|
|
837
|
+
plain.sort(key=lambda cp: cp[0].stmt.idx)
|
|
838
|
+
members = {c.stmt.idx for c, _ in plain}
|
|
839
|
+
first, last = plain[0][0].stmt.idx, plain[-1][0].stmt.idx
|
|
840
|
+
targets = set().union(*(c.stmt.writes for c, _ in plain))
|
|
841
|
+
reads = set().union(*(c.stmt.reads for c, _ in plain))
|
|
842
|
+
for s in lang.bodies[func.key].stmts[first + 1:last]:
|
|
843
|
+
if s.idx not in members and (s.reads & targets or s.writes & reads):
|
|
844
|
+
return None # code between them uses a result, or changes what a call reads
|
|
845
|
+
names = [p[2] or "_" for _, p in plain]
|
|
846
|
+
exprs = [p[3] for _, p in plain]
|
|
847
|
+
keywords = {p[0] for _, p in plain if p[0]}
|
|
848
|
+
if keywords and len(keywords) != 1 and not keywords <= {"final", "var", "const", "let"}:
|
|
849
|
+
return None
|
|
850
|
+
if any(p[0] is None and p[2] for _, p in plain) and keywords:
|
|
851
|
+
return None # a declaration beside a plain assignment: by hand
|
|
852
|
+
first_line = plain[0][0].line
|
|
853
|
+
last_line = lang.end_line(func, plain[-1][0].stmt)
|
|
854
|
+
lines = src.text.splitlines()
|
|
855
|
+
indent = re.match(r"\s*", lines[first_line - 1]).group(0)
|
|
856
|
+
semi = ";" if func.language == "dart" or all(p[4] for _, p in plain) else ""
|
|
857
|
+
if func.language == "dart":
|
|
858
|
+
kw = "var" if "var" in keywords else "final" if keywords else ""
|
|
859
|
+
typed = [f"{p[1]} {p[2]}" if p[1] else (p[2] or "_") for _, p in plain]
|
|
860
|
+
lhs = f"{kw + ' ' if kw else ''}({', '.join(typed)}) = " if any(n != "_" for n in names) else ""
|
|
861
|
+
line = f"{indent}{lhs}await ({', '.join(exprs)}).wait{semi}"
|
|
862
|
+
has_async = re.search(r"""^\s*import\s+['"]dart:async['"]""", src.text, re.MULTILINE)
|
|
863
|
+
import_text = None if has_async else "import 'dart:async';"
|
|
864
|
+
else:
|
|
865
|
+
kw = "let" if keywords & {"let", "var"} else "const" if keywords else ""
|
|
866
|
+
lhs = f"{kw + ' ' if kw else ''}[{', '.join('' if n == '_' else n for n in names)}] = " \
|
|
867
|
+
if any(n != "_" for n in names) else ""
|
|
868
|
+
line = f"{indent}{lhs}await Promise.all([{', '.join(exprs)}]){semi}"
|
|
869
|
+
import_text = None
|
|
870
|
+
spans = [(c.line, lang.end_line(func, c.stmt)) for c, _ in plain]
|
|
871
|
+
after: list[str] = []
|
|
872
|
+
for n in range(first_line, last_line + 1):
|
|
873
|
+
if n == spans[0][0]:
|
|
874
|
+
after.append(line)
|
|
875
|
+
elif not any(a <= n <= b for a, b in spans):
|
|
876
|
+
after.append(lines[n - 1])
|
|
877
|
+
weights = [c.weight for c, _ in plain]
|
|
878
|
+
left = [c for c in calls if c.stmt.idx not in members]
|
|
879
|
+
return Change(path=func.path, first_line=first_line,
|
|
880
|
+
before=[redact_code(x) for x in lines[first_line - 1:last_line]],
|
|
881
|
+
after=[redact_code(x) for x in after], saved_ms=round(sum(weights) - max(weights), 1),
|
|
882
|
+
by_hand=[f'"{c.words}" ({c.ref}): in another block' for c in sorted(left, key=lambda c: c.line)],
|
|
883
|
+
import_text=import_text)
|
|
884
|
+
|