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.
@@ -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
+ ]