codeterrain 0.3.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.
- codeterrain/__init__.py +3 -0
- codeterrain/_guard.py +243 -0
- codeterrain/_pytest_dump_plugin.py +253 -0
- codeterrain/_routes_runner.py +119 -0
- codeterrain/budget.py +128 -0
- codeterrain/cache.py +618 -0
- codeterrain/calls_join.py +416 -0
- codeterrain/calls_static.py +1099 -0
- codeterrain/cli.py +1447 -0
- codeterrain/code_static.py +2411 -0
- codeterrain/config.py +331 -0
- codeterrain/executables.py +130 -0
- codeterrain/fixtures_order.py +77 -0
- codeterrain/fixtures_static.py +501 -0
- codeterrain/hookspecs.py +25 -0
- codeterrain/impact.py +725 -0
- codeterrain/languages.py +293 -0
- codeterrain/plugins/__init__.py +0 -0
- codeterrain/plugins/fastapi_routes.py +193 -0
- codeterrain/plugins/manifest_join.py +107 -0
- codeterrain/protocol.py +138 -0
- codeterrain/routes_view.py +61 -0
- codeterrain/sandbox.py +565 -0
- codeterrain/views.py +198 -0
- codeterrain-0.3.0.dist-info/METADATA +295 -0
- codeterrain-0.3.0.dist-info/RECORD +30 -0
- codeterrain-0.3.0.dist-info/WHEEL +4 -0
- codeterrain-0.3.0.dist-info/entry_points.txt +2 -0
- codeterrain-0.3.0.dist-info/licenses/LICENSE +201 -0
- codeterrain-0.3.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
"""Static HTTP call sites in JavaScript and TypeScript: a tokenizer in Python, nothing is run.
|
|
2
|
+
|
|
3
|
+
Per file the pass records every call that reaches an HTTP path: ``<receiver>.<verb>(path, ...)``,
|
|
4
|
+
``fetch(path, {method})`` and object literals with a ``url:`` key (``useCustom({url, method})``,
|
|
5
|
+
axios configs). A path written as a string or template literal, a ``+`` concatenation of those, or a
|
|
6
|
+
same-file ``const`` of one is resolved; ``${expr}`` becomes ``{}``. A call whose path cannot be read
|
|
7
|
+
statically is recorded as unresolved, so coverage stays visible. Facts are keyed by content hash in
|
|
8
|
+
the cache, like the fixture index, so an edit re-reads one file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterator, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import PurePosixPath
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from codeterrain.cache import Cache, ViewRecord
|
|
21
|
+
from codeterrain.config import Config
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from codeterrain.languages import Language
|
|
25
|
+
|
|
26
|
+
VIEW = "calls"
|
|
27
|
+
#: Bump the number whenever ``parse_source`` would produce different facts for the same text: cached
|
|
28
|
+
#: facts are keyed by content hash only, so a parser change would otherwise keep serving old facts.
|
|
29
|
+
OBJECT_KIND = "calls-16"
|
|
30
|
+
SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts")
|
|
31
|
+
VERBS = frozenset({"get", "post", "put", "patch", "delete", "head", "options"})
|
|
32
|
+
#: Receivers whose ``.get(x)`` is an HTTP call even when ``x`` cannot be read statically. Any other
|
|
33
|
+
#: receiver counts only when its first argument reads as a path, which keeps ``map.get(key)`` out.
|
|
34
|
+
CLIENT_NAMES = frozenset(
|
|
35
|
+
{"api", "apiClient", "axios", "axiosInstance", "client", "http", "httpClient", "request"}
|
|
36
|
+
)
|
|
37
|
+
CALL_FACT_TYPES = {
|
|
38
|
+
"method": str,
|
|
39
|
+
"path": str,
|
|
40
|
+
"line": "int",
|
|
41
|
+
"style": str,
|
|
42
|
+
"resolved": bool,
|
|
43
|
+
"raw": str,
|
|
44
|
+
}
|
|
45
|
+
#: Tests, mocks (``__mocks__``, msw's ``mocks/``) and build output (a Next.js export in ``out/``,
|
|
46
|
+
#: coverage reports). The cache walk already skips ``build``, ``dist`` and ``node_modules``.
|
|
47
|
+
_SKIP_DIRS = frozenset(
|
|
48
|
+
{"__tests__", "__mocks__", "__fixtures__", "mocks", "test", "tests", "e2e", "out", "coverage",
|
|
49
|
+
"storybook-static"}
|
|
50
|
+
)
|
|
51
|
+
_SKIP_NAME = re.compile(r"\.(test|spec|stories|d|min)\.[cm]?[jt]sx?$")
|
|
52
|
+
# A `/` after one of these starts a regular expression literal, not a division.
|
|
53
|
+
_REGEX_AFTER_PUNCT = frozenset("(,=:[!&|?{};+-*%<>~^")
|
|
54
|
+
_REGEX_AFTER_WORD = frozenset(
|
|
55
|
+
{"return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw",
|
|
56
|
+
"yield", "await"}
|
|
57
|
+
)
|
|
58
|
+
_RAW_LIMIT = 120
|
|
59
|
+
GLOBALS = frozenset({"window", "globalThis", "self"})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Tok:
|
|
64
|
+
kind: str # "id", "punct", "str", "tpl", "num", "regex"
|
|
65
|
+
value: Any # str for most kinds; for "tpl" a list of ("text", s) / ("expr", source)
|
|
66
|
+
line: int
|
|
67
|
+
#: For "tpl": per part, the source lines between the template's start and where the part's
|
|
68
|
+
#: text begins (an expression's first character), counted in the source, not the value.
|
|
69
|
+
lines: tuple[int, ...] = ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_skipped_client_file(rel: str) -> bool:
|
|
73
|
+
path = PurePosixPath(rel)
|
|
74
|
+
return bool(_SKIP_NAME.search(path.name)) or any(p in _SKIP_DIRS for p in path.parts[:-1])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- tokenizer ---------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_SIMPLE_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}
|
|
81
|
+
_HEX = re.compile(r"x([0-9A-Fa-f]{2})|u([0-9A-Fa-f]{4})|u\{([0-9A-Fa-f]{1,6})\}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _escape(src: str, i: int) -> tuple[str, int]:
|
|
85
|
+
"""The text the escape at ``src[i] == '\\'`` stands for and the index after it: ``\\n`` and
|
|
86
|
+
the like, ``\\xHH``, ``\\uHHHH``, ``\\u{H...}``, a line continuation (nothing), any other
|
|
87
|
+
character as itself. A malformed hex escape keeps its letters, so the path it spells cannot
|
|
88
|
+
match a route by accident."""
|
|
89
|
+
c = src[i + 1]
|
|
90
|
+
if c == "\n":
|
|
91
|
+
return "", i + 2
|
|
92
|
+
if c in "xu":
|
|
93
|
+
hexed = _HEX.match(src, i + 1)
|
|
94
|
+
if hexed:
|
|
95
|
+
code = int(next(g for g in hexed.groups() if g is not None), 16)
|
|
96
|
+
if code <= 0x10FFFF:
|
|
97
|
+
return chr(code), hexed.end()
|
|
98
|
+
return _SIMPLE_ESCAPES.get(c, c), i + 2
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _skip_string(src: str, i: int, quote: str) -> tuple[str, int]:
|
|
102
|
+
"""The value of the string starting at ``src[i] == quote``, and the index after it. An
|
|
103
|
+
unterminated string ends at the newline (JSX text such as ``Don't`` must not eat the file)."""
|
|
104
|
+
out: list[str] = []
|
|
105
|
+
i += 1
|
|
106
|
+
n = len(src)
|
|
107
|
+
while i < n:
|
|
108
|
+
c = src[i]
|
|
109
|
+
if c == "\\" and i + 1 < n:
|
|
110
|
+
value, i = _escape(src, i)
|
|
111
|
+
out.append(value)
|
|
112
|
+
continue
|
|
113
|
+
if c == quote:
|
|
114
|
+
return "".join(out), i + 1
|
|
115
|
+
if c == "\n":
|
|
116
|
+
return "".join(out), i
|
|
117
|
+
out.append(c)
|
|
118
|
+
i += 1
|
|
119
|
+
return "".join(out), i
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _skip_template(src: str, i: int) -> tuple[list[tuple[str, str]], int, tuple[int, ...]]:
|
|
123
|
+
"""The parts of the template literal starting at ``src[i] == '`'``, the index after it, and
|
|
124
|
+
each part's line offset from the template's first line (``Tok.lines``)."""
|
|
125
|
+
parts: list[tuple[str, str]] = []
|
|
126
|
+
lines: list[int] = []
|
|
127
|
+
text: list[str] = []
|
|
128
|
+
seen, newlines = i, 0
|
|
129
|
+
text_start = i + 1
|
|
130
|
+
|
|
131
|
+
def offset(at: int) -> int:
|
|
132
|
+
nonlocal seen, newlines
|
|
133
|
+
newlines += src.count("\n", seen, at)
|
|
134
|
+
seen = at
|
|
135
|
+
return newlines
|
|
136
|
+
|
|
137
|
+
i += 1
|
|
138
|
+
n = len(src)
|
|
139
|
+
while i < n:
|
|
140
|
+
c = src[i]
|
|
141
|
+
if c == "\\" and i + 1 < n:
|
|
142
|
+
value, i = _escape(src, i)
|
|
143
|
+
text.append(value)
|
|
144
|
+
continue
|
|
145
|
+
if c == "`":
|
|
146
|
+
if text:
|
|
147
|
+
parts.append(("text", "".join(text)))
|
|
148
|
+
lines.append(offset(text_start))
|
|
149
|
+
return parts, i + 1, tuple(lines)
|
|
150
|
+
if c == "$" and i + 1 < n and src[i + 1] == "{":
|
|
151
|
+
if text:
|
|
152
|
+
parts.append(("text", "".join(text)))
|
|
153
|
+
lines.append(offset(text_start))
|
|
154
|
+
text = []
|
|
155
|
+
end = _skip_braces(src, i + 2)
|
|
156
|
+
raw = src[i + 2 : end - 1]
|
|
157
|
+
parts.append(("expr", raw.strip()))
|
|
158
|
+
lines.append(offset(i + 2 + len(raw) - len(raw.lstrip())))
|
|
159
|
+
i = text_start = end
|
|
160
|
+
continue
|
|
161
|
+
text.append(c)
|
|
162
|
+
i += 1
|
|
163
|
+
if text:
|
|
164
|
+
parts.append(("text", "".join(text)))
|
|
165
|
+
lines.append(offset(text_start))
|
|
166
|
+
return parts, i, tuple(lines)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
#: Characters after which a `/` inside ``${...}`` starts a regex literal, not a division.
|
|
170
|
+
_REGEX_BEFORE = "({[,;:=!&|?+-*%<>~^"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _skip_braces(src: str, i: int) -> int:
|
|
174
|
+
"""The index after the ``}`` closing an expression that starts at ``i`` (inside ``${``).
|
|
175
|
+
Strings, templates, comments and regex literals (``/}/``) are stepped over whole, so a brace
|
|
176
|
+
inside one does not count."""
|
|
177
|
+
depth = 1
|
|
178
|
+
n = len(src)
|
|
179
|
+
last = "{" # the last character that is not space: a `/` after an operator opens a regex
|
|
180
|
+
while i < n:
|
|
181
|
+
c = src[i]
|
|
182
|
+
if c in "'\"":
|
|
183
|
+
_, i = _skip_string(src, i, c)
|
|
184
|
+
last = c
|
|
185
|
+
continue
|
|
186
|
+
if c == "`":
|
|
187
|
+
_, i, _ = _skip_template(src, i)
|
|
188
|
+
last = c
|
|
189
|
+
continue
|
|
190
|
+
if c == "/" and i + 1 < n and src[i + 1] in "/*":
|
|
191
|
+
i = _skip_comment(src, i)
|
|
192
|
+
continue
|
|
193
|
+
if c == "/" and last in _REGEX_BEFORE:
|
|
194
|
+
end = _regex_end(src, i)
|
|
195
|
+
if end is not None:
|
|
196
|
+
i, last = end, "/"
|
|
197
|
+
continue
|
|
198
|
+
if not c.isspace():
|
|
199
|
+
last = c
|
|
200
|
+
if c == "{":
|
|
201
|
+
depth += 1
|
|
202
|
+
elif c == "}":
|
|
203
|
+
depth -= 1
|
|
204
|
+
if depth == 0:
|
|
205
|
+
return i + 1
|
|
206
|
+
i += 1
|
|
207
|
+
return i
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _skip_comment(src: str, i: int) -> int:
|
|
211
|
+
if src[i + 1] == "/":
|
|
212
|
+
end = src.find("\n", i)
|
|
213
|
+
return len(src) if end == -1 else end
|
|
214
|
+
end = src.find("*/", i + 2)
|
|
215
|
+
return len(src) if end == -1 else end + 2
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _regex_end(src: str, i: int) -> int | None:
|
|
219
|
+
"""The index after a regex literal starting at ``src[i] == '/'``, or None when there is no
|
|
220
|
+
closing ``/`` on the line (then it was a division or JSX after all)."""
|
|
221
|
+
n = len(src)
|
|
222
|
+
j = i + 1
|
|
223
|
+
in_class = False
|
|
224
|
+
while j < n:
|
|
225
|
+
c = src[j]
|
|
226
|
+
if c == "\n":
|
|
227
|
+
return None
|
|
228
|
+
if c == "\\":
|
|
229
|
+
j += 2
|
|
230
|
+
continue
|
|
231
|
+
if c == "[":
|
|
232
|
+
in_class = True
|
|
233
|
+
elif c == "]":
|
|
234
|
+
in_class = False
|
|
235
|
+
elif c == "/" and not in_class:
|
|
236
|
+
j += 1
|
|
237
|
+
while j < n and (src[j].isalnum() or src[j] in "_$"):
|
|
238
|
+
j += 1
|
|
239
|
+
return j
|
|
240
|
+
j += 1
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _regex_allowed(prev: Tok | None, src: str, i: int) -> bool:
|
|
245
|
+
if i + 1 < len(src) and src[i + 1] == ">":
|
|
246
|
+
return False # JSX self-closing `/>`
|
|
247
|
+
if prev is None:
|
|
248
|
+
return True
|
|
249
|
+
if prev.kind == "punct":
|
|
250
|
+
return prev.value in _REGEX_AFTER_PUNCT and prev.value != "<" # `</div>` is JSX
|
|
251
|
+
return prev.kind == "id" and prev.value in _REGEX_AFTER_WORD
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def tokenize(src: str) -> list[Tok]:
|
|
255
|
+
"""Tokens of a JavaScript or TypeScript source, comments dropped. Good enough to find calls:
|
|
256
|
+
strings, templates (with nested templates), regex literals and JSX text do not derail it past
|
|
257
|
+
the end of a line."""
|
|
258
|
+
toks: list[Tok] = []
|
|
259
|
+
i, n, line = 0, len(src), 1
|
|
260
|
+
while i < n:
|
|
261
|
+
c = src[i]
|
|
262
|
+
if c == "\n":
|
|
263
|
+
line += 1
|
|
264
|
+
i += 1
|
|
265
|
+
continue
|
|
266
|
+
if c.isspace():
|
|
267
|
+
i += 1
|
|
268
|
+
continue
|
|
269
|
+
start = i
|
|
270
|
+
if c == "/" and i + 1 < n and src[i + 1] in "/*":
|
|
271
|
+
i = _skip_comment(src, i)
|
|
272
|
+
elif c == "'" and 0 < i < n - 1 and src[i - 1].isalnum() and src[i + 1].isalpha():
|
|
273
|
+
# An apostrophe inside a word (JSX text: `Don't`) never opens a string in code.
|
|
274
|
+
toks.append(Tok("punct", c, line))
|
|
275
|
+
i += 1
|
|
276
|
+
elif c in "'\"":
|
|
277
|
+
value, i = _skip_string(src, i, c)
|
|
278
|
+
toks.append(Tok("str", value, line))
|
|
279
|
+
elif c == "`":
|
|
280
|
+
parts, i, offsets = _skip_template(src, i)
|
|
281
|
+
toks.append(Tok("tpl", parts, line, offsets))
|
|
282
|
+
elif c.isalpha() or c in "_$" or ord(c) > 127:
|
|
283
|
+
while i < n and (src[i].isalnum() or src[i] in "_$" or ord(src[i]) > 127):
|
|
284
|
+
i += 1
|
|
285
|
+
toks.append(Tok("id", src[start:i], line))
|
|
286
|
+
elif c.isdigit():
|
|
287
|
+
while i < n and (src[i].isalnum() or src[i] in "._"):
|
|
288
|
+
i += 1
|
|
289
|
+
toks.append(Tok("num", src[start:i], line))
|
|
290
|
+
elif c == "/" and _regex_allowed(toks[-1] if toks else None, src, i):
|
|
291
|
+
end = _regex_end(src, i)
|
|
292
|
+
if end is None:
|
|
293
|
+
toks.append(Tok("punct", "/", line))
|
|
294
|
+
i += 1
|
|
295
|
+
else:
|
|
296
|
+
toks.append(Tok("regex", src[i:end], line))
|
|
297
|
+
i = end
|
|
298
|
+
elif c == "?" and i + 1 < n and src[i + 1] == "." and not src[i + 2 : i + 3].isdigit():
|
|
299
|
+
toks.append(Tok("punct", ".", line)) # optional chaining reads like `.`
|
|
300
|
+
i += 2
|
|
301
|
+
else:
|
|
302
|
+
toks.append(Tok("punct", c, line))
|
|
303
|
+
i += 1
|
|
304
|
+
line += src.count("\n", start, i)
|
|
305
|
+
return toks
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --- paths -------------------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _is(tok: Tok | None, kind: str, value: str | None = None) -> bool:
|
|
312
|
+
return tok is not None and tok.kind == kind and (value is None or tok.value == value)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class ScanBudgetExceeded(Exception):
|
|
316
|
+
"""A file whose calls cost more than ``WORK_PER_TOKEN`` steps per token to read."""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
#: Argument tokens one file may make the scanner walk, per token of the file (plus a floor).
|
|
320
|
+
#: Nested calls re-read their enclosing arguments, so a file of thousands of calls nested in one
|
|
321
|
+
#: another costs the square of its size; ordinary code stays far below the bound.
|
|
322
|
+
WORK_PER_TOKEN = 64
|
|
323
|
+
WORK_FLOOR = 100_000
|
|
324
|
+
_work = [0]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _spend(steps: int) -> None:
|
|
328
|
+
_work[0] -= steps
|
|
329
|
+
if _work[0] < 0:
|
|
330
|
+
raise ScanBudgetExceeded(
|
|
331
|
+
"calls nested too deeply to read within the work bound; the file is skipped"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _split_args(toks: list[Tok], open_index: int) -> tuple[list[list[Tok]], int]:
|
|
336
|
+
"""Arguments of the call whose ``(`` is at ``open_index``, and the index after its ``)``."""
|
|
337
|
+
args: list[list[Tok]] = [[]]
|
|
338
|
+
depth = 0
|
|
339
|
+
i = open_index + 1
|
|
340
|
+
while i < len(toks):
|
|
341
|
+
tok = toks[i]
|
|
342
|
+
if tok.kind == "punct" and tok.value in "([{":
|
|
343
|
+
depth += 1
|
|
344
|
+
elif tok.kind == "punct" and tok.value in ")]}":
|
|
345
|
+
if depth == 0:
|
|
346
|
+
_spend(i - open_index)
|
|
347
|
+
return ([] if args == [[]] else args), i + 1
|
|
348
|
+
depth -= 1
|
|
349
|
+
elif tok.kind == "punct" and tok.value == "," and depth == 0:
|
|
350
|
+
args.append([])
|
|
351
|
+
i += 1
|
|
352
|
+
continue
|
|
353
|
+
args[-1].append(tok)
|
|
354
|
+
i += 1
|
|
355
|
+
_spend(i - open_index)
|
|
356
|
+
return ([] if args == [[]] else args), i
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _operands(expr: list[Tok]) -> list[list[Tok]] | None:
|
|
360
|
+
"""``a + b + c`` as its operands, or None when ``expr`` holds anything else at the top level."""
|
|
361
|
+
out: list[list[Tok]] = [[]]
|
|
362
|
+
for tok in expr:
|
|
363
|
+
if tok.kind == "punct" and tok.value == "+":
|
|
364
|
+
out.append([])
|
|
365
|
+
elif tok.kind in ("str", "tpl", "id", "num") or (tok.kind == "punct" and tok.value == "."):
|
|
366
|
+
out[-1].append(tok)
|
|
367
|
+
else:
|
|
368
|
+
return None
|
|
369
|
+
return out if all(out) else None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _text(expr: list[Tok]) -> str:
|
|
373
|
+
pieces: list[str] = []
|
|
374
|
+
size = 0
|
|
375
|
+
for tok in expr:
|
|
376
|
+
if size > _RAW_LIMIT:
|
|
377
|
+
break # the rest would be cut anyway
|
|
378
|
+
if tok.kind == "str":
|
|
379
|
+
pieces.append(repr(tok.value))
|
|
380
|
+
elif tok.kind == "tpl":
|
|
381
|
+
pieces.append("`" + "".join(v if k == "text" else "${" + v + "}" for k, v in tok.value)
|
|
382
|
+
+ "`")
|
|
383
|
+
else:
|
|
384
|
+
pieces.append(str(tok.value))
|
|
385
|
+
size += len(pieces[-1]) + 1
|
|
386
|
+
return " ".join(pieces)[:_RAW_LIMIT]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
Pieces = list[tuple[str, str]]
|
|
390
|
+
#: The file's constants: a plain mapping, or the ``_Resolver`` that works them out on demand.
|
|
391
|
+
Consts = "dict[str, Pieces] | _Resolver"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
#: A string expression with more pieces or text than this is not a path this scanner reads.
|
|
395
|
+
MAX_PIECES = 64
|
|
396
|
+
MAX_PATH_TEXT = 2048
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _piece_list(expr: list[Tok], consts: Consts) -> Pieces | None:
|
|
400
|
+
"""``expr`` as ("text", s) / ("expr", source) pieces, or None when it is not a string-ish
|
|
401
|
+
expression this scanner reads."""
|
|
402
|
+
operands = _operands(expr)
|
|
403
|
+
if operands is None:
|
|
404
|
+
return None
|
|
405
|
+
pieces: list[tuple[str, str]] = []
|
|
406
|
+
size = 0
|
|
407
|
+
for more in _operand_pieces(operands, consts):
|
|
408
|
+
# Every constant was bounded when it was read, so one step adds a bounded amount: a
|
|
409
|
+
# chain of `const b = a + a` stops here instead of doubling until memory runs out.
|
|
410
|
+
size += sum(len(value) for _, value in more)
|
|
411
|
+
if len(pieces) + len(more) > MAX_PIECES or size > MAX_PATH_TEXT:
|
|
412
|
+
return None
|
|
413
|
+
pieces.extend(more)
|
|
414
|
+
return pieces
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _operand_pieces(operands: list[list[Tok]], consts: Consts) -> Iterator[Pieces]:
|
|
418
|
+
for operand in operands:
|
|
419
|
+
if len(operand) == 1 and operand[0].kind == "str":
|
|
420
|
+
yield [("text", operand[0].value)]
|
|
421
|
+
elif len(operand) == 1 and operand[0].kind == "tpl":
|
|
422
|
+
for kind, value in operand[0].value:
|
|
423
|
+
# `${BASE}/users` with a same-file `const BASE = ...` reads through the constant.
|
|
424
|
+
yield consts[value] if kind == "expr" and value in consts else [(kind, value)]
|
|
425
|
+
elif len(operand) == 1 and operand[0].kind == "id" and operand[0].value in consts:
|
|
426
|
+
yield consts[operand[0].value]
|
|
427
|
+
else:
|
|
428
|
+
yield [("expr", _text(operand))]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _path_from_pieces(pieces: list[tuple[str, str]]) -> str | None:
|
|
432
|
+
"""The path the pieces spell, ``${expr}`` as ``{}``; a leading expression followed by ``/`` is
|
|
433
|
+
a base URL and dropped. None unless the result starts with ``/``, and None when a piece is a
|
|
434
|
+
name the file binds to a string it cannot read (``unknown``)."""
|
|
435
|
+
if any(kind == "unknown" for kind, _ in pieces):
|
|
436
|
+
return None
|
|
437
|
+
if len(pieces) > 1 and pieces[0][0] == "expr" and pieces[1][0] == "text" and \
|
|
438
|
+
pieces[1][1].startswith("/"):
|
|
439
|
+
pieces = pieces[1:]
|
|
440
|
+
path = "".join(v if k == "text" else "{}" for k, v in pieces)
|
|
441
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
442
|
+
return None
|
|
443
|
+
return path
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def resolve_path(expr: list[Tok], consts: dict[str, list[tuple[str, str]]]) -> str | None:
|
|
447
|
+
pieces = _piece_list(expr, consts)
|
|
448
|
+
return None if pieces is None else _path_from_pieces(pieces)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _initializer(toks: list[Tok], i: int) -> int | None:
|
|
452
|
+
"""The index of the ``=`` after the name at ``toks[i]``, past a type annotation
|
|
453
|
+
(``BASE: string``, ``R: Record<string, () => void>``); None when there is none."""
|
|
454
|
+
j = i + 1
|
|
455
|
+
if j < len(toks) and _is(toks[j], "punct", "="):
|
|
456
|
+
return j
|
|
457
|
+
if not (j < len(toks) and _is(toks[j], "punct", ":")):
|
|
458
|
+
return None
|
|
459
|
+
depth = 0
|
|
460
|
+
for k in range(j + 1, len(toks)):
|
|
461
|
+
tok = toks[k]
|
|
462
|
+
if tok.kind != "punct":
|
|
463
|
+
continue
|
|
464
|
+
if tok.value in "([{<":
|
|
465
|
+
depth += 1
|
|
466
|
+
elif tok.value in ")]}" or (tok.value == ">" and not _is(toks[k - 1], "punct", "=")):
|
|
467
|
+
depth -= 1
|
|
468
|
+
if depth < 0:
|
|
469
|
+
_spend(k - i)
|
|
470
|
+
return None
|
|
471
|
+
elif tok.value == "=" and depth == 0 and not _is(toks[k + 1] if k + 1 < len(toks)
|
|
472
|
+
else None, "punct", ">"):
|
|
473
|
+
_spend(k - i)
|
|
474
|
+
return k
|
|
475
|
+
elif tok.value in ";," and depth == 0:
|
|
476
|
+
_spend(k - i)
|
|
477
|
+
return None
|
|
478
|
+
_spend(len(toks) - i)
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
#: Operators that end a type and continue the value after a ``as T`` (``|`` and ``&`` are type
|
|
483
|
+
#: unions and intersections).
|
|
484
|
+
_VALUE_OPS = "+-*/%?:,!"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _without_assertion(expr: list[Tok]) -> list[Tok] | None:
|
|
488
|
+
"""``expr`` without a top-level TypeScript ``as T`` or ``satisfies T`` tail
|
|
489
|
+
(``'/v1' as const``), which says nothing about the value. None when more expression follows
|
|
490
|
+
the type (``'/v1' as string + '/items'``, ``as string ? '/a' : '/b'``): cutting there would
|
|
491
|
+
drop part of the value."""
|
|
492
|
+
depth = 0
|
|
493
|
+
cut: int | None = None
|
|
494
|
+
for k, tok in enumerate(expr):
|
|
495
|
+
# Angle brackets nest only inside the asserted type (`as Record<string, X>`).
|
|
496
|
+
opening, closing = ("([{<", ")]}>") if cut is not None else ("([{", ")]}")
|
|
497
|
+
if tok.kind == "punct" and tok.value in opening:
|
|
498
|
+
depth += 1
|
|
499
|
+
elif tok.kind == "punct" and tok.value in closing:
|
|
500
|
+
depth -= 1
|
|
501
|
+
elif depth == 0 and k and tok.kind == "id" and tok.value in ("as", "satisfies"):
|
|
502
|
+
cut = k if cut is None else cut
|
|
503
|
+
elif depth == 0 and cut is not None and tok.kind == "punct" and tok.value in _VALUE_OPS:
|
|
504
|
+
return None
|
|
505
|
+
return expr if cut is None else expr[:cut]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
#: A line ending in one of these, or starting with one, continues the expression before it, as
|
|
509
|
+
#: JavaScript reads it: `'/v1'` then `.concat('/items')` on the next line is one value.
|
|
510
|
+
_OPERATORS = ".([+-*/%?:|&=<>^"
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _continues(last: Tok, first: Tok) -> bool:
|
|
514
|
+
"""Whether the line starting with ``first`` continues the expression ending with ``last``."""
|
|
515
|
+
if last.kind == "punct" and last.value in _OPERATORS and last.value not in ")]":
|
|
516
|
+
return True
|
|
517
|
+
return first.kind == "tpl" or (first.kind == "punct" and first.value in _OPERATORS)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _declarator_value(toks: list[Tok], j: int) -> tuple[list[Tok], int]:
|
|
521
|
+
"""The initializer starting at ``toks[j]`` and the index of what ends it: a ``;``, a top-level
|
|
522
|
+
``,`` (the next declarator of ``const A = '/a', B = 5``) or a new line that does not continue
|
|
523
|
+
the expression (``_continues``)."""
|
|
524
|
+
expr: list[Tok] = []
|
|
525
|
+
depth = 0
|
|
526
|
+
while j < len(toks):
|
|
527
|
+
tok = toks[j]
|
|
528
|
+
if _is(tok, "punct", ";") or (depth == 0 and _is(tok, "punct", ",")):
|
|
529
|
+
break
|
|
530
|
+
if expr and tok.line > expr[-1].line and not _continues(expr[-1], tok):
|
|
531
|
+
break
|
|
532
|
+
if tok.kind == "punct" and tok.value in "([{":
|
|
533
|
+
depth += 1
|
|
534
|
+
elif tok.kind == "punct" and tok.value in ")]}":
|
|
535
|
+
depth -= 1
|
|
536
|
+
expr.append(tok)
|
|
537
|
+
j += 1
|
|
538
|
+
_spend(len(expr) + 1)
|
|
539
|
+
return expr, j
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _constants(toks: list[Tok]) -> dict[str, list[tuple[str, str]]]:
|
|
543
|
+
"""``const NAME = <string expression>`` anywhere in the file, as pieces; every declarator of
|
|
544
|
+
``const A = ..., B = ...`` counts. A name the file binds to a string this scanner cannot pin
|
|
545
|
+
down maps to an ``unknown`` piece, so a path built on it stays unresolved instead of being
|
|
546
|
+
shortened as if the name were a base URL: a ``let`` or ``var`` (it can be reassigned before
|
|
547
|
+
the request), a name declared more than once with different values, an initializer that holds
|
|
548
|
+
a string but is not a plain string expression (``cond ? '/a' : '/b'``). Identical
|
|
549
|
+
redeclarations are kept. Declarations are gathered first and resolved after, so a constant
|
|
550
|
+
can use one declared further down the file (a function body runs after the module's top
|
|
551
|
+
level); a cycle leaves its names unknown."""
|
|
552
|
+
decls: dict[str, list[list[Tok]]] = {}
|
|
553
|
+
unknown: set[str] = set()
|
|
554
|
+
mutable: set[str] = set()
|
|
555
|
+
for i, tok in enumerate(toks):
|
|
556
|
+
if tok.kind != "id" or tok.value not in ("const", "let", "var"):
|
|
557
|
+
continue
|
|
558
|
+
k = i + 1
|
|
559
|
+
while k < len(toks) and toks[k].kind == "id":
|
|
560
|
+
name = toks[k].value
|
|
561
|
+
eq = _initializer(toks, k)
|
|
562
|
+
if eq is None:
|
|
563
|
+
break
|
|
564
|
+
expr, j = _declarator_value(toks, eq + 1)
|
|
565
|
+
if tok.value == "const":
|
|
566
|
+
decls.setdefault(name, []).append(expr)
|
|
567
|
+
elif any(t.kind in ("str", "tpl") for t in expr):
|
|
568
|
+
unknown.add(name)
|
|
569
|
+
else:
|
|
570
|
+
mutable.add(name)
|
|
571
|
+
if not (j < len(toks) and _is(toks[j], "punct", ",")):
|
|
572
|
+
break
|
|
573
|
+
k = j + 1
|
|
574
|
+
# A `let url = getUrl()` may shadow a `const url` where a call reads it; a `let` that shadows
|
|
575
|
+
# nothing keeps its base-URL reading.
|
|
576
|
+
unknown |= (_shadowing_names(toks) | mutable) & decls.keys()
|
|
577
|
+
resolver = _Resolver(decls, unknown)
|
|
578
|
+
return {name: pieces for name in [*decls, *unknown] if (pieces := resolver.get(name))}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class _Resolver:
|
|
582
|
+
"""Each constant's pieces, worked out once on demand, so the order of declarations does not
|
|
583
|
+
matter. Behaves as the read-only mapping ``_piece_list`` looks names up in."""
|
|
584
|
+
|
|
585
|
+
def __init__(self, decls: dict[str, list[list[Tok]]], unknown: set[str]) -> None:
|
|
586
|
+
self.decls, self.unknown = decls, unknown
|
|
587
|
+
self.done: dict[str, Pieces | None] = {}
|
|
588
|
+
self.busy: set[str] = set()
|
|
589
|
+
|
|
590
|
+
def get(self, name: str) -> Pieces | None:
|
|
591
|
+
"""The pieces of ``name``; ``unknown`` for a name bound to a string this scanner cannot
|
|
592
|
+
read; None for a name that is not a string constant (a base URL from the environment)."""
|
|
593
|
+
if name in self.unknown or name in self.busy:
|
|
594
|
+
return [("unknown", name)]
|
|
595
|
+
if name in self.done:
|
|
596
|
+
return self.done[name]
|
|
597
|
+
if name not in self.decls:
|
|
598
|
+
return None
|
|
599
|
+
self.busy.add(name)
|
|
600
|
+
found: list[Pieces | None] = []
|
|
601
|
+
for expr in self.decls[name]:
|
|
602
|
+
value = _without_assertion(expr)
|
|
603
|
+
found.append(None if value is None else _piece_list(value, self))
|
|
604
|
+
self.busy.discard(name)
|
|
605
|
+
readable = [p for p in found if p is not None and any(k != "expr" for k, _ in p)]
|
|
606
|
+
stringish = any(t.kind in ("str", "tpl") for expr in self.decls[name] for t in expr)
|
|
607
|
+
if readable and len(readable) == len(found) and all(p == readable[0] for p in readable):
|
|
608
|
+
result: Pieces | None = readable[0]
|
|
609
|
+
elif readable or stringish:
|
|
610
|
+
result = [("unknown", name)]
|
|
611
|
+
else:
|
|
612
|
+
result = None
|
|
613
|
+
self.done[name] = result
|
|
614
|
+
return result
|
|
615
|
+
|
|
616
|
+
def __contains__(self, name: object) -> bool:
|
|
617
|
+
return isinstance(name, str) and self.get(name) is not None
|
|
618
|
+
|
|
619
|
+
def __getitem__(self, name: str) -> Pieces:
|
|
620
|
+
pieces = self.get(name)
|
|
621
|
+
if pieces is None:
|
|
622
|
+
raise KeyError(name)
|
|
623
|
+
return pieces
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _arrow_at(toks: list[Tok], i: int) -> bool:
|
|
627
|
+
"""``=>`` (two tokens) at ``toks[i]``."""
|
|
628
|
+
return i + 1 < len(toks) and _is(toks[i], "punct", "=") and _is(toks[i + 1], "punct", ">")
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
#: Keywords whose ``( ... )`` is a condition or a loop head, not a parameter list.
|
|
632
|
+
_NOT_PARAMS = ("if", "while", "for", "switch", "with")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _shadowing_names(toks: list[Tok]) -> set[str]:
|
|
636
|
+
"""Names the file binds other than by a readable ``const``: parameters (``function f(url)``,
|
|
637
|
+
``(url: string) => ...``, ``url => ...``, ``load(url) {``, ``catch (url)``, destructured or
|
|
638
|
+
typed), destructured declarations and declarations without a value (``for (const url of
|
|
639
|
+
urls)``). Scopes are not tracked: a constant of the same name could be shadowed where a call
|
|
640
|
+
reads it, so it is left unresolved everywhere, never read as the wrong path."""
|
|
641
|
+
names: set[str] = set()
|
|
642
|
+
opens: list[int] = []
|
|
643
|
+
for i, tok in enumerate(toks):
|
|
644
|
+
prev = toks[i - 1] if i else None
|
|
645
|
+
nxt = toks[i + 1] if i + 1 < len(toks) else None
|
|
646
|
+
if tok.kind == "id" and tok.value in ("const", "let", "var") and nxt is not None:
|
|
647
|
+
if nxt.kind == "id" and _initializer(toks, i + 1) is None:
|
|
648
|
+
names.add(nxt.value)
|
|
649
|
+
elif _is(nxt, "punct", "{") or _is(nxt, "punct", "["):
|
|
650
|
+
_, end = _split_args(toks, i + 1)
|
|
651
|
+
names.update(t.value for t in toks[i + 2:end] if t.kind == "id")
|
|
652
|
+
elif tok.kind == "id" and _arrow_at(toks, i + 1) and not _is(prev, "punct", "."):
|
|
653
|
+
names.add(tok.value) # url => ...
|
|
654
|
+
elif _is(tok, "punct", "("):
|
|
655
|
+
opens.append(i)
|
|
656
|
+
elif _is(tok, "punct", ")") and opens:
|
|
657
|
+
start = opens.pop()
|
|
658
|
+
before = toks[start - 1] if start else None
|
|
659
|
+
if before is not None and before.kind == "id" and before.value in _NOT_PARAMS:
|
|
660
|
+
continue
|
|
661
|
+
if _is(nxt, "punct", "{") or _is(nxt, "punct", ":") or _arrow_at(toks, i + 1):
|
|
662
|
+
_spend(i - start)
|
|
663
|
+
names.update(t.value for t in toks[start + 1:i] if t.kind == "id")
|
|
664
|
+
return names
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# --- call sites --------------------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _skip_type_args(toks: list[Tok], i: int) -> int:
|
|
671
|
+
"""The index after ``<...>`` type arguments starting at ``toks[i] == '<'`` (``>>`` is two
|
|
672
|
+
tokens), or ``i`` when they do not close: an unmatched ``)``, ``}`` or ``]``, or a ``;``
|
|
673
|
+
outside braces, ends the scan. Parentheses inside (``(User | null)[]``, ``() => void``) are
|
|
674
|
+
balanced, and the ``>`` of ``=>`` closes nothing."""
|
|
675
|
+
depth = 0
|
|
676
|
+
brace_depth = 0
|
|
677
|
+
bracket_depth = 0
|
|
678
|
+
paren_depth = 0
|
|
679
|
+
j = i
|
|
680
|
+
while j < len(toks):
|
|
681
|
+
tok = toks[j]
|
|
682
|
+
if _is(tok, "punct", "<"):
|
|
683
|
+
depth += 1
|
|
684
|
+
elif _is(tok, "punct", ">") and not _is(toks[j - 1], "punct", "="):
|
|
685
|
+
depth -= 1
|
|
686
|
+
if depth == 0:
|
|
687
|
+
_spend(j - i)
|
|
688
|
+
return j + 1
|
|
689
|
+
elif _is(tok, "punct", "{"):
|
|
690
|
+
brace_depth += 1
|
|
691
|
+
elif _is(tok, "punct", "}"):
|
|
692
|
+
brace_depth -= 1
|
|
693
|
+
if brace_depth < 0:
|
|
694
|
+
_spend(j - i)
|
|
695
|
+
return i
|
|
696
|
+
elif _is(tok, "punct", "["):
|
|
697
|
+
bracket_depth += 1
|
|
698
|
+
elif _is(tok, "punct", "]"):
|
|
699
|
+
bracket_depth -= 1
|
|
700
|
+
if bracket_depth < 0:
|
|
701
|
+
_spend(j - i)
|
|
702
|
+
return i
|
|
703
|
+
elif _is(tok, "punct", "("):
|
|
704
|
+
paren_depth += 1
|
|
705
|
+
elif _is(tok, "punct", ")"):
|
|
706
|
+
paren_depth -= 1
|
|
707
|
+
if paren_depth < 0:
|
|
708
|
+
_spend(j - i)
|
|
709
|
+
return i
|
|
710
|
+
elif tok.kind == "punct" and tok.value == ";" and brace_depth == 0:
|
|
711
|
+
_spend(j - i)
|
|
712
|
+
return i
|
|
713
|
+
j += 1
|
|
714
|
+
_spend(j - i)
|
|
715
|
+
return i
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _receiver(toks: list[Tok], dot: int) -> tuple[str, int] | None:
|
|
719
|
+
"""The receiver chain ending before ``toks[dot] == '.'`` (``this.api``, ``client``) and the
|
|
720
|
+
index of its first token; None when it is not a plain name chain."""
|
|
721
|
+
j = dot - 1
|
|
722
|
+
if j < 0 or toks[j].kind != "id":
|
|
723
|
+
return None
|
|
724
|
+
while j - 2 >= 0 and _is(toks[j - 1], "punct", ".") and toks[j - 2].kind == "id":
|
|
725
|
+
j -= 2
|
|
726
|
+
_spend(dot - j)
|
|
727
|
+
return toks[dot - 1].value, j
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _object_fields(arg: list[Tok]) -> dict[str, list[Tok]]:
|
|
731
|
+
"""Top-level ``key: value`` pairs of an object literal argument (``{`` ... ``}``); a computed
|
|
732
|
+
key that is a string (``['method']: 'POST'``) counts as that key. A ``...spread`` entry or a
|
|
733
|
+
computed key the scanner cannot read (``[key]: value``) can overwrite any field before it, so
|
|
734
|
+
those are dropped; fields after it are kept."""
|
|
735
|
+
if not arg or not _is(arg[0], "punct", "{"):
|
|
736
|
+
return {}
|
|
737
|
+
fields: dict[str, list[Tok]] = {}
|
|
738
|
+
inner, _ = _split_args([Tok("punct", "(", 0), *arg[1:]], 0)
|
|
739
|
+
for entry in inner:
|
|
740
|
+
if _overwrites_any(entry):
|
|
741
|
+
fields.clear()
|
|
742
|
+
elif len(entry) >= 5 and _is(entry[0], "punct", "[") and entry[1].kind == "str" and \
|
|
743
|
+
_is(entry[2], "punct", "]") and _is(entry[3], "punct", ":"):
|
|
744
|
+
fields[entry[1].value] = entry[4:]
|
|
745
|
+
elif len(entry) >= 3 and entry[0].kind in ("id", "str") and _is(entry[1], "punct", ":"):
|
|
746
|
+
fields[entry[0].value] = entry[2:]
|
|
747
|
+
elif len(entry) == 1 and entry[0].kind == "id":
|
|
748
|
+
fields[entry[0].value] = entry # shorthand `{ url }`
|
|
749
|
+
return fields
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _overwrites_any(entry: list[Tok]) -> bool:
|
|
753
|
+
"""A ``...spread`` entry, or a computed key other than one string (``[key]: value``)."""
|
|
754
|
+
if len(entry) >= 3 and all(_is(t, "punct", ".") for t in entry[:3]):
|
|
755
|
+
return True
|
|
756
|
+
if entry and _is(entry[0], "punct", "["):
|
|
757
|
+
return not (len(entry) >= 3 and entry[1].kind == "str" and _is(entry[2], "punct", "]"))
|
|
758
|
+
return False
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _has_spread(arg: list[Tok]) -> bool:
|
|
762
|
+
"""True when the object literal ``arg`` has a top-level entry that could set any field: a
|
|
763
|
+
``...spread`` or a computed key the scanner cannot read."""
|
|
764
|
+
if not arg or not _is(arg[0], "punct", "{"):
|
|
765
|
+
return False
|
|
766
|
+
inner, _ = _split_args([Tok("punct", "(", 0), *arg[1:]], 0)
|
|
767
|
+
return any(_overwrites_any(entry) for entry in inner)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _method_of(value: list[Tok] | None, default: str | None) -> str:
|
|
771
|
+
if value is None:
|
|
772
|
+
return default or "?"
|
|
773
|
+
if len(value) == 1 and value[0].kind == "str":
|
|
774
|
+
return value[0].value.upper()
|
|
775
|
+
return "?"
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _fact(method: str, path: str | None, line: int, style: str, raw: list[Tok]) -> dict[str, Any]:
|
|
779
|
+
return {
|
|
780
|
+
"method": method,
|
|
781
|
+
"path": path or "",
|
|
782
|
+
"line": line,
|
|
783
|
+
"style": style,
|
|
784
|
+
"resolved": path is not None,
|
|
785
|
+
"raw": "" if path is not None else _text(raw),
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
#: How deep ``${...}`` inside templates inside ``${...}`` is scanned for calls.
|
|
790
|
+
TEMPLATE_DEPTH = 8
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def extract_calls(
|
|
794
|
+
toks: list[Tok], consts: dict[str, list[tuple[str, str]]] | None = None, depth: int = 0
|
|
795
|
+
) -> list[dict[str, Any]]:
|
|
796
|
+
"""The call sites in ``toks``, in line order, those inside a template's ``${...}`` included
|
|
797
|
+
(``consts``: the file's constants, for an expression scanned on its own). Raises
|
|
798
|
+
``ScanBudgetExceeded`` past ``WORK_PER_TOKEN`` steps per token."""
|
|
799
|
+
if depth == 0:
|
|
800
|
+
_work[0] = WORK_FLOOR + WORK_PER_TOKEN * len(toks)
|
|
801
|
+
if consts is None:
|
|
802
|
+
consts = _constants(toks)
|
|
803
|
+
else:
|
|
804
|
+
# `${items.map(url => fetch(url))}`: a parameter inside the expression shadows too.
|
|
805
|
+
shadowed = _shadowing_names(toks) & consts.keys()
|
|
806
|
+
if shadowed:
|
|
807
|
+
consts = {**consts, **{name: [("unknown", name)] for name in shadowed}}
|
|
808
|
+
calls: list[dict[str, Any]] = []
|
|
809
|
+
for i, tok in enumerate(toks):
|
|
810
|
+
prev = toks[i - 1] if i else None
|
|
811
|
+
# <receiver>.<verb>[<T>](path, ...)
|
|
812
|
+
if tok.kind == "id" and tok.value in VERBS and _is(prev, "punct", "."):
|
|
813
|
+
found = _receiver(toks, i - 1)
|
|
814
|
+
if found is None:
|
|
815
|
+
continue
|
|
816
|
+
name, first = found
|
|
817
|
+
if first and _is(toks[first - 1], "id", "typeof"):
|
|
818
|
+
continue
|
|
819
|
+
j = i + 1
|
|
820
|
+
if _is(toks[j] if j < len(toks) else None, "punct", "<"):
|
|
821
|
+
j = _skip_type_args(toks, j)
|
|
822
|
+
if not _is(toks[j] if j < len(toks) else None, "punct", "("):
|
|
823
|
+
continue
|
|
824
|
+
args, _ = _split_args(toks, j)
|
|
825
|
+
if not args:
|
|
826
|
+
continue
|
|
827
|
+
path = resolve_path(args[0], consts)
|
|
828
|
+
if path is None and resolve_path_is_external(args[0]):
|
|
829
|
+
continue
|
|
830
|
+
if path is not None or name in CLIENT_NAMES:
|
|
831
|
+
calls.append(_fact(tok.value.upper(), path, tok.line, "client", args[0]))
|
|
832
|
+
# fetch(path, { method })
|
|
833
|
+
elif _is(tok, "id", "fetch") and not _is(prev, "id", "function"):
|
|
834
|
+
j = i + 1
|
|
835
|
+
if not _is(toks[j] if j < len(toks) else None, "punct", "("):
|
|
836
|
+
continue
|
|
837
|
+
if _is(prev, "punct", ".") and not (i >= 2 and toks[i - 2].value in GLOBALS):
|
|
838
|
+
continue # `client.fetch(...)` is someone else's method
|
|
839
|
+
args, _ = _split_args(toks, j)
|
|
840
|
+
if not args:
|
|
841
|
+
continue
|
|
842
|
+
path = resolve_path(args[0], consts)
|
|
843
|
+
if path is None and resolve_path_is_external(args[0]):
|
|
844
|
+
continue
|
|
845
|
+
options = _object_fields(args[1]) if len(args) > 1 else {}
|
|
846
|
+
second = args[1] if len(args) > 1 else []
|
|
847
|
+
if not second:
|
|
848
|
+
default: str | None = "GET"
|
|
849
|
+
elif _is(second[0], "punct", "{"):
|
|
850
|
+
default = None if _has_spread(second) else "GET"
|
|
851
|
+
else:
|
|
852
|
+
default = None
|
|
853
|
+
calls.append(_fact(_method_of(options.get("method"), default), path, tok.line,
|
|
854
|
+
"fetch", args[0]))
|
|
855
|
+
# { url: path, method: 'post' }, or the shorthand { url, method: 'post' }
|
|
856
|
+
elif (tok.kind in ("id", "str") and tok.value == "url" and i + 1 < len(toks)
|
|
857
|
+
and (_is(toks[i + 1], "punct", ":") or tok.kind == "id"
|
|
858
|
+
and toks[i + 1].kind == "punct" and toks[i + 1].value in (",", "}"))
|
|
859
|
+
and prev is not None and prev.kind == "punct" and prev.value in "{,"):
|
|
860
|
+
start = _object_start(toks, i)
|
|
861
|
+
if start is None:
|
|
862
|
+
continue
|
|
863
|
+
body, _ = _split_args(toks, start)
|
|
864
|
+
obj = [toks[start], *[t for arg in body for t in arg + [Tok("punct", ",", 0)]]]
|
|
865
|
+
fields = _object_fields(obj)
|
|
866
|
+
value = fields.get("url")
|
|
867
|
+
if not value:
|
|
868
|
+
continue
|
|
869
|
+
path = resolve_path(value, consts)
|
|
870
|
+
if path is None:
|
|
871
|
+
continue # an object with a `url` that is not a path is not evidence of a call
|
|
872
|
+
default = None if _has_spread(obj) else "GET"
|
|
873
|
+
calls.append(_fact(_method_of(fields.get("method"), default), path, tok.line,
|
|
874
|
+
"object", value))
|
|
875
|
+
if depth < TEMPLATE_DEPTH:
|
|
876
|
+
for tok in toks:
|
|
877
|
+
if tok.kind == "tpl":
|
|
878
|
+
calls.extend(_template_calls(tok, consts, depth))
|
|
879
|
+
calls.sort(key=lambda c: c["line"])
|
|
880
|
+
return calls
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _template_calls(
|
|
884
|
+
tok: Tok, consts: dict[str, list[tuple[str, str]]], depth: int
|
|
885
|
+
) -> list[dict[str, Any]]:
|
|
886
|
+
"""Calls inside the ``${...}`` expressions of the template ``tok``; the literal text is never
|
|
887
|
+
scanned."""
|
|
888
|
+
found: list[dict[str, Any]] = []
|
|
889
|
+
for (kind, value), offset in zip(tok.value, tok.lines, strict=True):
|
|
890
|
+
if kind == "expr":
|
|
891
|
+
for call in extract_calls(tokenize(value), consts, depth + 1):
|
|
892
|
+
found.append({**call, "line": call["line"] + tok.line + offset - 1})
|
|
893
|
+
return found
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _object_start(toks: list[Tok], i: int) -> int | None:
|
|
897
|
+
"""The index of the ``{`` that opens the object literal holding the key at ``i``."""
|
|
898
|
+
depth = 0
|
|
899
|
+
j = i - 1
|
|
900
|
+
while j >= 0:
|
|
901
|
+
tok = toks[j]
|
|
902
|
+
if tok.kind == "punct" and tok.value in ")]}":
|
|
903
|
+
depth += 1
|
|
904
|
+
elif tok.kind == "punct" and tok.value in "([{":
|
|
905
|
+
if depth == 0:
|
|
906
|
+
_spend(i - j)
|
|
907
|
+
return j if tok.value == "{" else None
|
|
908
|
+
depth -= 1
|
|
909
|
+
j -= 1
|
|
910
|
+
_spend(i)
|
|
911
|
+
return None
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def resolve_path_is_external(expr: list[Tok]) -> bool:
|
|
915
|
+
"""``fetch('https://...')``, ``fetch('/config.json')``-style static files are not API calls:
|
|
916
|
+
an absolute URL literal is external."""
|
|
917
|
+
if len(expr) == 1 and expr[0].kind == "str":
|
|
918
|
+
return "://" in expr[0].value
|
|
919
|
+
if len(expr) == 1 and expr[0].kind == "tpl" and expr[0].value and expr[0].value[0][0] == "text":
|
|
920
|
+
return "://" in expr[0].value[0][1]
|
|
921
|
+
return False
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def parse_source(source: str) -> dict[str, Any]:
|
|
925
|
+
"""Facts for one file's text: ``{"calls": [...], "error": str|None}``."""
|
|
926
|
+
try:
|
|
927
|
+
return {"calls": extract_calls(tokenize(source)), "error": None}
|
|
928
|
+
except (RecursionError, MemoryError, IndexError, ScanBudgetExceeded) as exc:
|
|
929
|
+
return {"calls": [], "error": f"{type(exc).__name__}: {exc}"}
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
# --- the view ----------------------------------------------------------------------------------
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _typed(row: Any, types: dict[str, Any]) -> bool:
|
|
936
|
+
if not isinstance(row, dict) or set(row) != set(types):
|
|
937
|
+
return False
|
|
938
|
+
for key, kind in types.items():
|
|
939
|
+
value = row[key]
|
|
940
|
+
if kind == "int":
|
|
941
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
942
|
+
return False
|
|
943
|
+
elif not isinstance(value, kind):
|
|
944
|
+
return False
|
|
945
|
+
return True
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _valid_facts(facts: Any) -> bool:
|
|
949
|
+
return (
|
|
950
|
+
isinstance(facts, dict)
|
|
951
|
+
and set(facts) == {"calls", "error"}
|
|
952
|
+
and (facts["error"] is None or isinstance(facts["error"], str))
|
|
953
|
+
and isinstance(facts["calls"], list)
|
|
954
|
+
and all(_typed(c, CALL_FACT_TYPES) for c in facts["calls"])
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
#: What a language pack's ``read_calls`` may return for one file, past which it is refused.
|
|
959
|
+
MAX_PACK_CALLS = 20_000
|
|
960
|
+
_STYLES = frozenset({"client", "fetch", "object"})
|
|
961
|
+
_METHODS = frozenset({verb.upper() for verb in VERBS} | {"?"})
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _pack_call(call: Any) -> bool:
|
|
965
|
+
return (
|
|
966
|
+
_typed(call, CALL_FACT_TYPES)
|
|
967
|
+
and call["method"] in _METHODS and call["style"] in _STYLES and call["line"] >= 1
|
|
968
|
+
and len(call["path"]) <= MAX_PATH_TEXT and len(call["raw"]) <= _RAW_LIMIT
|
|
969
|
+
and call["resolved"] == bool(call["path"])
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def read_with_pack(reader: Language, source: str, suffix: str) -> dict[str, Any]:
|
|
974
|
+
"""The checked calls a language pack reads in one file: a pack that raises or returns
|
|
975
|
+
anything malformed gives a file error, never a crash or an unchecked field."""
|
|
976
|
+
assert reader.read_calls is not None
|
|
977
|
+
try:
|
|
978
|
+
got = reader.read_calls(source, suffix)
|
|
979
|
+
except Exception as exc: # the pack's own failure on one file
|
|
980
|
+
return {"calls": [], "error": f"{type(exc).__name__}: {str(exc)[:_RAW_LIMIT]}"}
|
|
981
|
+
if not (
|
|
982
|
+
isinstance(got, dict) and set(got) == {"calls", "error"}
|
|
983
|
+
and (got["error"] is None or isinstance(got["error"], str) and len(got["error"]) <= 1000)
|
|
984
|
+
and isinstance(got["calls"], list) and len(got["calls"]) <= MAX_PACK_CALLS
|
|
985
|
+
and all(_pack_call(c) for c in got["calls"])
|
|
986
|
+
):
|
|
987
|
+
return {"calls": [], "error": f"language pack {reader.name} returned malformed calls"}
|
|
988
|
+
return {"calls": sorted(got["calls"], key=lambda c: c["line"]), "error": got["error"]}
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _readers_for(readers: Sequence[Language]) -> dict[str, Language]:
|
|
992
|
+
"""The pack that reads each suffix: the first by name that offers it."""
|
|
993
|
+
by_suffix: dict[str, Language] = {}
|
|
994
|
+
for reader in sorted(readers, key=lambda r: r.name):
|
|
995
|
+
if reader.read_calls is None:
|
|
996
|
+
continue
|
|
997
|
+
for suffix in reader.call_suffixes:
|
|
998
|
+
by_suffix.setdefault(suffix, reader)
|
|
999
|
+
return by_suffix
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def object_kind(readers: Sequence[Language] = ()) -> str:
|
|
1003
|
+
"""The facts format: the built-in reader's, plus each pack that reads some suffix in its
|
|
1004
|
+
place and its version, so installing, upgrading or removing a pack re-reads the files."""
|
|
1005
|
+
chosen = sorted(
|
|
1006
|
+
{(r.name, r.version, suffix) for suffix, r in _readers_for(readers).items()}
|
|
1007
|
+
)
|
|
1008
|
+
if not chosen:
|
|
1009
|
+
return OBJECT_KIND
|
|
1010
|
+
tag = hashlib.sha256(repr(chosen).encode()).hexdigest()[:12]
|
|
1011
|
+
return f"{OBJECT_KIND}+{tag}"
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _valid_view(data: Any, kind: str = OBJECT_KIND) -> bool:
|
|
1015
|
+
"""A view built from facts of the current format: the facts of unchanged files written by
|
|
1016
|
+
an earlier scanner would otherwise outlive a scanner fix."""
|
|
1017
|
+
return (
|
|
1018
|
+
isinstance(data, dict)
|
|
1019
|
+
and data.get("format") == kind
|
|
1020
|
+
and isinstance(data.get("calls"), list)
|
|
1021
|
+
and isinstance(data.get("errors"), list)
|
|
1022
|
+
and all(_typed(c, {**CALL_FACT_TYPES, "file": str}) for c in data["calls"])
|
|
1023
|
+
and all(_typed(e, {"file": str, "error": str}) for e in data["errors"])
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def current_fingerprint(cache: Cache, config: Config) -> dict[str, str]:
|
|
1028
|
+
scanned = cache.scan(config.client_paths or None, SUFFIXES)
|
|
1029
|
+
return {rel: sha for rel, sha in scanned.items() if not is_skipped_client_file(rel)}
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _fact_key(rel: str, sha: str, by_suffix: dict[str, Language]) -> str:
|
|
1033
|
+
"""Where a file's facts are cached: by content, and for a file a pack reads also by its
|
|
1034
|
+
suffix, since the same text is read with another grammar, or by another reader, under
|
|
1035
|
+
another suffix."""
|
|
1036
|
+
suffix = PurePosixPath(rel).suffix
|
|
1037
|
+
return f"{sha}{suffix}" if suffix in by_suffix else sha
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def build(
|
|
1041
|
+
cache: Cache, config: Config, force: bool = False, readers: Sequence[Language] = ()
|
|
1042
|
+
) -> tuple[ViewRecord, bool]:
|
|
1043
|
+
"""The ``calls`` view, rebuilt incrementally when any client file changed. Test files
|
|
1044
|
+
(``*.test.ts``, ``__tests__/`` and the like) and declaration files are left out. A file
|
|
1045
|
+
whose suffix one of the language packs ``readers`` reads is read by that pack, any other by
|
|
1046
|
+
the built-in tokenizer."""
|
|
1047
|
+
fingerprint = current_fingerprint(cache, config)
|
|
1048
|
+
kind = object_kind(readers)
|
|
1049
|
+
by_suffix = _readers_for(readers)
|
|
1050
|
+
record = cache.read_view(VIEW)
|
|
1051
|
+
if (
|
|
1052
|
+
record is not None
|
|
1053
|
+
and not force
|
|
1054
|
+
and record.fingerprint == fingerprint
|
|
1055
|
+
and (record := cache.served(record, kind, lambda d: _valid_view(d, kind))) is not None
|
|
1056
|
+
):
|
|
1057
|
+
cache.flush()
|
|
1058
|
+
return record, False
|
|
1059
|
+
objects = cache.load_objects(kind)
|
|
1060
|
+
keys = {rel: _fact_key(rel, sha, by_suffix) for rel, sha in fingerprint.items()}
|
|
1061
|
+
added = False
|
|
1062
|
+
for rel, key in keys.items():
|
|
1063
|
+
if not _valid_facts(objects.get(key)):
|
|
1064
|
+
text = (cache.root / rel).read_bytes().decode("utf-8", errors="replace")
|
|
1065
|
+
suffix = PurePosixPath(rel).suffix
|
|
1066
|
+
reader = by_suffix.get(suffix)
|
|
1067
|
+
objects[key] = (
|
|
1068
|
+
read_with_pack(reader, text, suffix) if reader else parse_source(text)
|
|
1069
|
+
)
|
|
1070
|
+
added = True
|
|
1071
|
+
if added:
|
|
1072
|
+
cache.save_objects(kind, objects)
|
|
1073
|
+
calls: list[dict[str, Any]] = []
|
|
1074
|
+
errors: list[dict[str, str]] = []
|
|
1075
|
+
for rel in sorted(fingerprint):
|
|
1076
|
+
facts = objects[keys[rel]]
|
|
1077
|
+
if facts["error"]:
|
|
1078
|
+
errors.append({"file": rel, "error": facts["error"]})
|
|
1079
|
+
calls.extend({**c, "file": rel} for c in facts["calls"])
|
|
1080
|
+
record = cache.write_view(
|
|
1081
|
+
VIEW, {"format": kind, "calls": calls, "errors": errors}, fingerprint,
|
|
1082
|
+
sign=True, # built from checked facts: a warm read need not check every row again
|
|
1083
|
+
)
|
|
1084
|
+
cache.flush()
|
|
1085
|
+
return record, True
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def select(
|
|
1089
|
+
data: dict[str, Any], substring: str = "", method: str | None = None,
|
|
1090
|
+
unresolved: bool = False,
|
|
1091
|
+
) -> list[dict[str, Any]]:
|
|
1092
|
+
needle = substring.lower()
|
|
1093
|
+
return [
|
|
1094
|
+
c
|
|
1095
|
+
for c in data["calls"]
|
|
1096
|
+
if any(needle in c[key].lower() for key in ("path", "file", "raw"))
|
|
1097
|
+
and (method is None or c["method"] == method.upper())
|
|
1098
|
+
and (not unresolved or not c["resolved"])
|
|
1099
|
+
]
|