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