codeterrain-typescript 0.1.0__tar.gz

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,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .codeterrain/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+
11
+ # Local, private overlays (not part of the project)
12
+ CLAUDE.local.md
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: codeterrain-typescript
3
+ Version: 0.1.0
4
+ Summary: TypeScript and JavaScript for codeterrain: front-end HTTP calls read with tree-sitter.
5
+ Project-URL: Homepage, https://github.com/benchli-sports/codeterrain
6
+ Project-URL: Issues, https://github.com/benchli-sports/codeterrain/issues
7
+ Author: Mitbolas GmbH
8
+ License: Apache-2.0
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: codeterrain>=0.3
11
+ Requires-Dist: tree-sitter-javascript==0.25.0
12
+ Requires-Dist: tree-sitter-typescript==0.23.2
13
+ Requires-Dist: tree-sitter==0.26.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # codeterrain-typescript
17
+
18
+ TypeScript and JavaScript for [codeterrain](https://github.com/benchli-sports/codeterrain): the HTTP
19
+ calls of a front end, read with [tree-sitter](https://tree-sitter.github.io/), in `terrain calls` and
20
+ `terrain callers`.
21
+
22
+ ```
23
+ uv tool install "<path-to-the-codeterrain-checkout>[typescript]"
24
+ terrain calls orders # GET /v1/orders src/api/orders.ts:12 client
25
+ terrain callers POST /v1/orders # the front-end calls that reach a route
26
+ ```
27
+
28
+ Not published yet: install from a checkout of the codeterrain repository, where this pack lives under
29
+ `packages/`. Once published, `pip install "codeterrain[typescript]"` will do the same.
30
+ `terrain doctor` lists the installed packs (`language packs: typescript 0.1.0`). Without the pack,
31
+ `terrain calls` reads the same files with the core's own tokenizer.
32
+
33
+ Why install it: it is both more accurate and faster than the tokenizer. It parses the full syntax
34
+ and resolves names through scopes, and a cold read takes about 0.6 to 0.7 times the tokenizer's time,
35
+ since a tree-sitter query hands the reader only the nodes it needs. tree-sitter loads on the first
36
+ front-end file read, so commands that read none barely notice the pack.
37
+
38
+ The facts are the core's: `<receiver>.<verb>(path, ...)`, `fetch(path, {method})` and object literals
39
+ with a `url:` path. The syntax tree makes strings, templates, comments, type arguments and assertions,
40
+ spreads and computed keys exact, and names resolve through scopes: a name is read from the declaration
41
+ it refers to, so a `const url` inside a function is that function's, and a parameter hides a module
42
+ constant of the same name only inside its function. A file tree-sitter cannot parse whole is reported
43
+ with the lines of its syntax errors, and a call inside one counts as unresolved.
44
+
45
+ It depends on `tree-sitter` and the TypeScript and JavaScript grammars, which ship prebuilt wheels
46
+ for Linux, macOS and Windows; the versions are pinned exactly, since grammars rename nodes between
47
+ releases. The pack only parses the text it is given: it reads no other file and runs nothing.
@@ -0,0 +1,32 @@
1
+ # codeterrain-typescript
2
+
3
+ TypeScript and JavaScript for [codeterrain](https://github.com/benchli-sports/codeterrain): the HTTP
4
+ calls of a front end, read with [tree-sitter](https://tree-sitter.github.io/), in `terrain calls` and
5
+ `terrain callers`.
6
+
7
+ ```
8
+ uv tool install "<path-to-the-codeterrain-checkout>[typescript]"
9
+ terrain calls orders # GET /v1/orders src/api/orders.ts:12 client
10
+ terrain callers POST /v1/orders # the front-end calls that reach a route
11
+ ```
12
+
13
+ Not published yet: install from a checkout of the codeterrain repository, where this pack lives under
14
+ `packages/`. Once published, `pip install "codeterrain[typescript]"` will do the same.
15
+ `terrain doctor` lists the installed packs (`language packs: typescript 0.1.0`). Without the pack,
16
+ `terrain calls` reads the same files with the core's own tokenizer.
17
+
18
+ Why install it: it is both more accurate and faster than the tokenizer. It parses the full syntax
19
+ and resolves names through scopes, and a cold read takes about 0.6 to 0.7 times the tokenizer's time,
20
+ since a tree-sitter query hands the reader only the nodes it needs. tree-sitter loads on the first
21
+ front-end file read, so commands that read none barely notice the pack.
22
+
23
+ The facts are the core's: `<receiver>.<verb>(path, ...)`, `fetch(path, {method})` and object literals
24
+ with a `url:` path. The syntax tree makes strings, templates, comments, type arguments and assertions,
25
+ spreads and computed keys exact, and names resolve through scopes: a name is read from the declaration
26
+ it refers to, so a `const url` inside a function is that function's, and a parameter hides a module
27
+ constant of the same name only inside its function. A file tree-sitter cannot parse whole is reported
28
+ with the lines of its syntax errors, and a call inside one counts as unresolved.
29
+
30
+ It depends on `tree-sitter` and the TypeScript and JavaScript grammars, which ship prebuilt wheels
31
+ for Linux, macOS and Windows; the versions are pinned exactly, since grammars rename nodes between
32
+ releases. The pack only parses the text it is given: it reads no other file and runs nothing.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codeterrain-typescript"
7
+ version = "0.1.0"
8
+ description = "TypeScript and JavaScript for codeterrain: front-end HTTP calls read with tree-sitter."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Mitbolas GmbH" }]
13
+ # Exact pins: tree-sitter and its grammars version separately and rename nodes between
14
+ # releases, and the pack's version is what keys its cached facts.
15
+ dependencies = [
16
+ "codeterrain>=0.3",
17
+ "tree-sitter==0.26.0",
18
+ "tree-sitter-typescript==0.23.2",
19
+ "tree-sitter-javascript==0.25.0",
20
+ ]
21
+
22
+ [project.entry-points."codeterrain.languages"]
23
+ typescript = "codeterrain_typescript:language"
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/benchli-sports/codeterrain"
27
+ Issues = "https://github.com/benchli-sports/codeterrain/issues"
28
+
29
+ [tool.uv.sources]
30
+ codeterrain = { workspace = true }
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/codeterrain_typescript"]
@@ -0,0 +1,736 @@
1
+ """TypeScript and JavaScript for codeterrain: the HTTP calls of a front end, read with tree-sitter.
2
+
3
+ ``terrain calls`` and ``terrain callers`` read ``.ts``, ``.tsx``, ``.js``, ``.jsx``, ``.mjs``,
4
+ ``.cjs``, ``.mts`` and ``.cts`` files through this pack when it is installed, in place of the
5
+ core's tokenizer. The facts are the same: ``<receiver>.<verb>(path, ...)``, ``fetch(path,
6
+ {method})`` and object literals with a ``url:`` path, each with its method, its path (``${expr}``
7
+ as ``{}``, a leading base URL dropped) or the expression when the path is not static.
8
+
9
+ A syntax tree makes exact what a tokenizer guesses: strings, templates and comments, type
10
+ arguments and assertions, object fields, spreads and computed keys. Names resolve through scopes:
11
+ a name is read from the declaration it refers to (a ``const`` in the function around the call, a
12
+ parameter, a module constant declared further down), not from every declaration of that name in
13
+ the file. A ``let`` or ``var`` bound to a string, a name declared twice with different values,
14
+ and a cycle stay unresolved rather than read as a wrong path.
15
+
16
+ The pack only parses the text it is given: it reads no other file and runs nothing. A file
17
+ tree-sitter cannot parse whole is reported with the lines of its syntax errors, and a call inside
18
+ one counts as unresolved, so a partial parse never looks whole.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from collections.abc import Iterator
25
+ from dataclasses import dataclass, field
26
+ from functools import cache
27
+ from importlib.metadata import PackageNotFoundError, version
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ # tree-sitter and its grammars load on the first file read, not when the pack is discovered:
31
+ # every terrain command discovers the packs, and most never read a front-end file.
32
+ if TYPE_CHECKING:
33
+ from tree_sitter import Language, Node, Query
34
+
35
+ try:
36
+ __version__ = version("codeterrain-typescript")
37
+ except PackageNotFoundError: # run from a source tree
38
+ __version__ = "0"
39
+
40
+ VERBS = frozenset({"get", "post", "put", "patch", "delete", "head", "options"})
41
+ METHODS = frozenset(verb.upper() for verb in VERBS)
42
+ #: Receivers whose ``.get(x)`` is an HTTP call even when ``x`` cannot be read statically; the
43
+ #: core's tokenizer uses the same set.
44
+ CLIENT_NAMES = frozenset(
45
+ {"api", "apiClient", "axios", "axiosInstance", "client", "http", "httpClient", "request"}
46
+ )
47
+ GLOBALS = frozenset({"window", "globalThis", "self"})
48
+ #: A file larger than this is not read (the core's code index uses the same bound).
49
+ MAX_FILE_BYTES = 2_000_000
50
+ #: A string expression with more pieces or text than this is not a path this reader reads.
51
+ MAX_PIECES = 64
52
+ MAX_PATH_TEXT = 2048
53
+ RAW_LIMIT = 120
54
+ #: Steps one file may cost (nodes walked, scopes searched, pieces built), per byte plus a floor.
55
+ WORK_PER_BYTE = 32
56
+ WORK_FLOOR = 200_000
57
+ #: Lines of syntax errors named in a file's error.
58
+ MAX_ERROR_LINES = 5
59
+
60
+ #: The grammar each suffix is read with.
61
+ GRAMMAR_OF = {
62
+ ".ts": "typescript", ".mts": "typescript", ".cts": "typescript", ".tsx": "tsx",
63
+ ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
64
+ }
65
+
66
+
67
+ @cache
68
+ def grammar(name: str) -> Language:
69
+ from tree_sitter import Language
70
+
71
+ if name == "javascript":
72
+ import tree_sitter_javascript
73
+
74
+ return Language(tree_sitter_javascript.language())
75
+ import tree_sitter_typescript
76
+
77
+ return Language(
78
+ tree_sitter_typescript.language_tsx() if name == "tsx"
79
+ else tree_sitter_typescript.language_typescript()
80
+ )
81
+
82
+ #: Nodes that open a function scope (parameters, ``var``) and those that open a block scope.
83
+ _FUNCTIONS = frozenset({
84
+ "program", "function_declaration", "function_expression", "function", "arrow_function",
85
+ "method_definition", "generator_function_declaration", "generator_function",
86
+ })
87
+ _BLOCKS = frozenset({
88
+ "statement_block", "for_statement", "for_in_statement", "catch_clause", "switch_body",
89
+ "class_body",
90
+ })
91
+ #: Wrappers that say nothing about a value: ``(x)``, ``x as T``, ``x satisfies T``, ``x!``.
92
+ _TRANSPARENT = frozenset({
93
+ "parenthesized_expression", "as_expression", "satisfies_expression", "non_null_expression",
94
+ })
95
+ _STRINGS = frozenset({"string", "template_string"})
96
+
97
+
98
+ def is_jsx_ampersand(node: Node) -> bool:
99
+ """An ``ERROR`` the grammar makes of JSX text holding a bare ``&`` (``Staff & roles``):
100
+ valid JSX that the grammar takes for the start of a character reference. It is text, not a
101
+ syntax error."""
102
+ parent = node.parent
103
+ return (
104
+ node.type == "ERROR" and parent is not None
105
+ and parent.type in ("jsx_element", "jsx_fragment")
106
+ and bool(node.children) and node.children[0].type == "&"
107
+ )
108
+
109
+
110
+ def has_syntax_error(node: Node) -> bool:
111
+ """Whether a syntax error lies in ``node``, a JSX ampersand (``is_jsx_ampersand``) aside."""
112
+ stack = [node] if node.has_error else []
113
+ while stack:
114
+ current = stack.pop()
115
+ if current.is_missing or current.type == "ERROR" and not is_jsx_ampersand(current):
116
+ return True
117
+ stack.extend(c for c in current.children if c.has_error)
118
+ return False
119
+
120
+
121
+ def _inside_error(node: Node) -> bool:
122
+ """Whether ``node`` lies inside a syntax error (a JSX ampersand aside)."""
123
+ current = node.parent
124
+ while current is not None:
125
+ if current.type == "ERROR" and not is_jsx_ampersand(current):
126
+ return True
127
+ current = current.parent
128
+ return False
129
+
130
+
131
+ #: The node kinds ``_Reader.walk`` visits: scopes, bindings and call sites.
132
+ _VISITED = _FUNCTIONS | _BLOCKS | frozenset({
133
+ "lexical_declaration", "variable_declaration", "import_statement", "class_declaration",
134
+ "call_expression", "object",
135
+ })
136
+ _QUERIES: dict[int, Query] = {}
137
+
138
+
139
+ def _query(language: Language) -> Query:
140
+ """One capture per visited node kind the grammar has (the JavaScript grammar has no
141
+ ``satisfies_expression``, for example), built once per grammar."""
142
+ from tree_sitter import Query
143
+
144
+ found = _QUERIES.get(id(language))
145
+ if found is None:
146
+ kinds = sorted(k for k in _VISITED if language.id_for_node_kind(k, True) is not None)
147
+ found = _QUERIES[id(language)] = Query(language, " ".join(f"({k}) @node" for k in kinds))
148
+ return found
149
+
150
+
151
+ class WorkBoundExceeded(Exception):
152
+ """A file that costs more than ``WORK_PER_BYTE`` steps per byte to read."""
153
+
154
+
155
+ @dataclass
156
+ class Binding:
157
+ kind: str # "const", "let", "var" or "other" (a parameter, a pattern, an import, a function)
158
+ value: Node | None
159
+ scope: Scope
160
+
161
+
162
+ @dataclass
163
+ class Scope:
164
+ parent: Scope | None
165
+ function: bool
166
+ names: dict[str, list[Binding]] = field(default_factory=dict)
167
+
168
+ def bind(self, name: str, binding: Binding) -> None:
169
+ self.names.setdefault(name, []).append(binding)
170
+
171
+
172
+ Pieces = list[tuple[str, str]]
173
+ _UNKNOWN = "unknown"
174
+
175
+
176
+ class _Reader:
177
+ def __init__(self, data: bytes, language: Language) -> None:
178
+ self.data = data
179
+ self.language = language
180
+ self.work = WORK_FLOOR + WORK_PER_BYTE * len(data)
181
+ self.done: dict[int, Pieces | None] = {}
182
+ self.busy: set[int] = set()
183
+
184
+ def spend(self, steps: int = 1) -> None:
185
+ self.work -= steps
186
+ if self.work < 0:
187
+ raise WorkBoundExceeded("too costly to read within the work bound; the file is skipped")
188
+
189
+ def text(self, node: Node) -> str:
190
+ return self.data[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
191
+
192
+ # --- scopes ------------------------------------------------------------------------------
193
+
194
+ def walk(self, root: Node) -> list[tuple[Node, Scope, bool]]:
195
+ """Every call and object literal with the scope it sits in and whether it lies inside a
196
+ syntax error; every declaration is bound into its scope on the way."""
197
+ sites: list[tuple[Node, Scope, bool]] = []
198
+ top = Scope(None, True)
199
+ # Only the nodes that open a scope, bind a name or may be a call are visited: the query
200
+ # finds them in C, and one pass in source order (outer before inner) gives each the
201
+ # innermost scope still open where it starts.
202
+ from tree_sitter import QueryCursor
203
+
204
+ nodes = QueryCursor(_query(self.language)).captures(root).get("node", [])
205
+ nodes.sort(key=lambda n: (n.start_byte, -n.end_byte))
206
+ open_scopes: list[tuple[int, Scope]] = [(len(self.data) + 1, top)]
207
+ damaged = root.has_error
208
+ for node in nodes:
209
+ self.spend()
210
+ while open_scopes[-1][0] <= node.start_byte:
211
+ open_scopes.pop()
212
+ scope = open_scopes[-1][1]
213
+ kind = node.type
214
+ if kind == "program":
215
+ continue
216
+ if kind in ("function_declaration", "generator_function_declaration",
217
+ "class_declaration"):
218
+ name = node.child_by_field_name("name")
219
+ if name is not None:
220
+ scope.bind(self.text(name), Binding("other", None, scope))
221
+ if kind in _FUNCTIONS:
222
+ scope = Scope(scope, True)
223
+ self._bind_parameters(node, scope)
224
+ open_scopes.append((node.end_byte, scope))
225
+ elif kind in _BLOCKS:
226
+ scope = Scope(scope, False)
227
+ open_scopes.append((node.end_byte, scope))
228
+ if kind in ("lexical_declaration", "variable_declaration"):
229
+ self._bind_declaration(node, scope)
230
+ elif kind == "for_in_statement":
231
+ left = node.child_by_field_name("left")
232
+ keyword = node.child_by_field_name("kind")
233
+ if left is not None and keyword is not None:
234
+ # `for (var x of xs)` binds x in the function, `let` and `const` in the loop
235
+ target = scope
236
+ if keyword.type == "var":
237
+ while not target.function and target.parent is not None:
238
+ target = target.parent
239
+ for name in self._pattern_names(left):
240
+ target.bind(name, Binding("other", None, scope))
241
+ elif kind == "catch_clause":
242
+ parameter = node.child_by_field_name("parameter")
243
+ if parameter is not None:
244
+ for name in self._pattern_names(parameter):
245
+ scope.bind(name, Binding("other", None, scope))
246
+ elif kind == "import_statement":
247
+ for name in self._import_names(node):
248
+ top.bind(name, Binding("other", None, top))
249
+ elif kind in ("call_expression", "object"):
250
+ sites.append((node, scope, damaged and _inside_error(node)))
251
+ return sites
252
+
253
+ def _bind_parameters(self, node: Node, scope: Scope) -> None:
254
+ name = node.child_by_field_name("name")
255
+ if name is not None and node.type in ("function_expression", "function"):
256
+ scope.bind(self.text(name), Binding("other", None, scope))
257
+ single = node.child_by_field_name("parameter") # `url => ...`
258
+ params = node.child_by_field_name("parameters")
259
+ for holder in (single, params):
260
+ if holder is not None:
261
+ for found in self._pattern_names(holder):
262
+ scope.bind(found, Binding("other", None, scope))
263
+
264
+ def _bind_declaration(self, node: Node, scope: Scope) -> None:
265
+ keyword = node.children[0].type if node.children else ""
266
+ kind = keyword if keyword in ("const", "let") else "var"
267
+ target = scope
268
+ if kind == "var":
269
+ while not target.function and target.parent is not None:
270
+ target = target.parent
271
+ for declarator in node.named_children:
272
+ if declarator.type != "variable_declarator":
273
+ continue
274
+ name = declarator.child_by_field_name("name")
275
+ value = declarator.child_by_field_name("value")
276
+ if name is None:
277
+ continue
278
+ if name.type == "identifier":
279
+ target.bind(self.text(name), Binding(kind, value, scope))
280
+ else:
281
+ for found in self._pattern_names(name):
282
+ target.bind(found, Binding("other", None, scope))
283
+
284
+ def _pattern_names(self, node: Node) -> Iterator[str]:
285
+ """The names a parameter list or a binding pattern binds; default values and types are
286
+ not names it binds."""
287
+ stack = [node]
288
+ while stack:
289
+ current = stack.pop()
290
+ self.spend()
291
+ kind = current.type
292
+ if kind in ("identifier", "shorthand_property_identifier_pattern"):
293
+ yield self.text(current)
294
+ elif kind in ("assignment_pattern", "object_assignment_pattern"):
295
+ left = current.child_by_field_name("left")
296
+ if left is not None:
297
+ stack.append(left)
298
+ elif kind == "pair_pattern":
299
+ value = current.child_by_field_name("value")
300
+ if value is not None:
301
+ stack.append(value)
302
+ elif kind in ("required_parameter", "optional_parameter"):
303
+ pattern = current.child_by_field_name("pattern")
304
+ if pattern is not None:
305
+ stack.append(pattern)
306
+ elif kind in ("formal_parameters", "object_pattern", "array_pattern", "rest_pattern"):
307
+ stack.extend(current.named_children)
308
+
309
+ def _import_names(self, node: Node) -> Iterator[str]:
310
+ stack = [c for c in node.named_children if c.type == "import_clause"]
311
+ while stack:
312
+ current = stack.pop()
313
+ self.spend()
314
+ if current.type == "identifier":
315
+ yield self.text(current)
316
+ elif current.type == "import_specifier":
317
+ alias = current.child_by_field_name("alias") or current.child_by_field_name("name")
318
+ if alias is not None:
319
+ yield self.text(alias)
320
+ else:
321
+ stack.extend(current.named_children)
322
+
323
+ def lookup(self, name: str, scope: Scope) -> list[Binding] | None:
324
+ current: Scope | None = scope
325
+ while current is not None:
326
+ self.spend()
327
+ if name in current.names:
328
+ return current.names[name]
329
+ current = current.parent
330
+ return None
331
+
332
+ # --- values ------------------------------------------------------------------------------
333
+
334
+ def name_pieces(self, name: str, scope: Scope) -> Pieces | None:
335
+ """What the name ``name`` holds where ``scope`` reads it: its pieces when it is a
336
+ constant this reader can read; ``unknown`` when it is bound to a string this reader
337
+ cannot pin down; None when it is not a string constant (a parameter, an import, a base
338
+ URL from the environment)."""
339
+ bindings = self.lookup(name, scope)
340
+ if not bindings:
341
+ return None
342
+ key = id(bindings)
343
+ if key in self.done:
344
+ return self.done[key]
345
+ if key in self.busy:
346
+ return [(_UNKNOWN, name)]
347
+ constants = [b for b in bindings if b.kind == "const" and b.value is not None]
348
+ others = [b for b in bindings if not (b.kind == "const" and b.value is not None)]
349
+ result: Pieces | None
350
+ if constants and others:
351
+ result = [(_UNKNOWN, name)]
352
+ elif others:
353
+ mutable = [b for b in others if b.kind in ("let", "var") and b.value is not None]
354
+ result = [(_UNKNOWN, name)] if any(self.stringish(b.value) for b in mutable) else None
355
+ else:
356
+ self.busy.add(key)
357
+ found = [self.pieces(b.value, b.scope) for b in constants]
358
+ self.busy.discard(key)
359
+ readable = [p for p in found if p is not None and any(k != "expr" for k, _ in p)]
360
+ if readable and len(readable) == len(found) and all(p == readable[0] for p in found):
361
+ result = readable[0]
362
+ elif readable or any(self.stringish(b.value) for b in constants):
363
+ result = [(_UNKNOWN, name)]
364
+ else:
365
+ result = None
366
+ self.done[key] = result
367
+ return result
368
+
369
+ def stringish(self, node: Node | None) -> bool:
370
+ """Whether a string or template literal appears anywhere in ``node``."""
371
+ stack = [node] if node is not None else []
372
+ while stack:
373
+ current = stack.pop()
374
+ self.spend()
375
+ if current.type in _STRINGS:
376
+ return True
377
+ stack.extend(current.named_children)
378
+ return False
379
+
380
+ def unwrap(self, node: Node) -> Node:
381
+ while node.type in _TRANSPARENT and node.named_children:
382
+ self.spend()
383
+ node = node.named_children[0]
384
+ if node.type == "type_assertion" and node.named_children: # `<T>value`
385
+ return self.unwrap(node.named_children[-1])
386
+ return node
387
+
388
+ def pieces(self, node: Node, scope: Scope) -> Pieces | None:
389
+ """``node`` as ("text", s) / ("expr", source) / ("unknown", name) pieces, or None when
390
+ it is not a string expression this reader reads: a string, a template, a name, or a
391
+ ``+`` of those."""
392
+ out: Pieces = []
393
+ size = 0
394
+ for more in self._operands(node, scope):
395
+ if more is None:
396
+ return None
397
+ size += sum(len(value) for _, value in more)
398
+ if len(out) + len(more) > MAX_PIECES or size > MAX_PATH_TEXT:
399
+ return None
400
+ out.extend(more)
401
+ return out
402
+
403
+ def _operands(self, node: Node, scope: Scope) -> Iterator[Pieces | None]:
404
+ stack = [self.unwrap(node)]
405
+ while stack:
406
+ current = stack.pop()
407
+ self.spend()
408
+ kind = current.type
409
+ if kind == "binary_expression":
410
+ operator = current.child_by_field_name("operator")
411
+ left = current.child_by_field_name("left")
412
+ right = current.child_by_field_name("right")
413
+ if operator is None or operator.type != "+" or left is None or right is None:
414
+ yield None
415
+ return
416
+ stack.extend((self.unwrap(right), self.unwrap(left)))
417
+ elif kind == "string":
418
+ yield [("text", decode(self.text(current)[1:-1]))]
419
+ elif kind == "template_string":
420
+ yield self._template(current, scope)
421
+ elif kind == "identifier":
422
+ name = self.text(current)
423
+ yield self.name_pieces(name, scope) or [("expr", name)]
424
+ elif kind in ("member_expression", "number"):
425
+ yield [("expr", self.text(current))]
426
+ else:
427
+ yield None
428
+ return
429
+
430
+ def _template(self, node: Node, scope: Scope) -> Pieces:
431
+ out: Pieces = []
432
+ at = node.start_byte + 1
433
+ for child in node.named_children:
434
+ if child.type != "template_substitution":
435
+ continue
436
+ if child.start_byte > at:
437
+ out.append(("text", decode(self.data[at:child.start_byte].decode(
438
+ "utf-8", errors="replace"))))
439
+ inner = [c for c in child.named_children if c.type != "comment"]
440
+ expr = self.unwrap(inner[0]) if len(inner) == 1 else None
441
+ found = None
442
+ if expr is not None and expr.type == "identifier":
443
+ found = self.name_pieces(self.text(expr), scope)
444
+ out.extend(found or [("expr", " ".join(self.text(child)[2:-1].split()))])
445
+ at = child.end_byte
446
+ end = node.end_byte - 1
447
+ if end > at:
448
+ out.append(("text", decode(self.data[at:end].decode("utf-8", errors="replace"))))
449
+ return out
450
+
451
+ def path(self, node: Node, scope: Scope) -> str | None:
452
+ pieces = self.pieces(node, scope)
453
+ return None if pieces is None else path_from_pieces(pieces)
454
+
455
+ def external(self, node: Node) -> bool:
456
+ """``fetch('https://...')``: an absolute URL literal is not a call to the backend."""
457
+ node = self.unwrap(node)
458
+ if node.type == "string":
459
+ return "://" in decode(self.text(node)[1:-1])
460
+ if node.type == "template_string":
461
+ first = next((c for c in node.named_children), None)
462
+ stop = first.start_byte if first is not None and \
463
+ first.type == "template_substitution" else node.end_byte - 1
464
+ return "://" in self.data[node.start_byte + 1:stop].decode("utf-8", errors="replace")
465
+ return False
466
+
467
+ def raw(self, node: Node) -> str:
468
+ return " ".join(self.text(node).split())[:RAW_LIMIT]
469
+
470
+ # --- call sites --------------------------------------------------------------------------
471
+
472
+ def fields(self, node: Node, scope: Scope) -> tuple[dict[str, Node], bool]:
473
+ """Top-level fields of an object literal, and whether an entry could set any field: a
474
+ ``...spread`` or a computed key that is not one string. Such an entry drops the fields
475
+ before it; those after it are kept."""
476
+ found: dict[str, Node] = {}
477
+ overwrites = False
478
+ for entry in node.named_children:
479
+ self.spend()
480
+ if entry.type == "spread_element":
481
+ found.clear()
482
+ overwrites = True
483
+ elif entry.type == "shorthand_property_identifier":
484
+ found[self.text(entry)] = entry
485
+ elif entry.type == "pair":
486
+ key = entry.child_by_field_name("key")
487
+ value = entry.child_by_field_name("value")
488
+ if key is None or value is None:
489
+ continue
490
+ name = self.key(key)
491
+ if name is None:
492
+ found.clear()
493
+ overwrites = True
494
+ else:
495
+ found[name] = value
496
+ return found, overwrites
497
+
498
+ def key(self, key: Node) -> str | None:
499
+ if key.type in ("property_identifier", "number"):
500
+ return self.text(key)
501
+ if key.type == "string":
502
+ return decode(self.text(key)[1:-1])
503
+ if key.type == "computed_property_name":
504
+ inner = [self.unwrap(c) for c in key.named_children]
505
+ if len(inner) == 1 and inner[0].type == "string":
506
+ return decode(self.text(inner[0])[1:-1])
507
+ return None
508
+
509
+ def method(self, value: Node | None, scope: Scope, default: str | None) -> str:
510
+ if value is None:
511
+ return default or "?"
512
+ if value.type == "shorthand_property_identifier":
513
+ pieces = self.name_pieces(self.text(value), scope)
514
+ else:
515
+ pieces = self.pieces(value, scope)
516
+ if pieces and all(kind == "text" for kind, _ in pieces):
517
+ method = "".join(text for _, text in pieces).upper()
518
+ return method if method in METHODS else "?"
519
+ return "?"
520
+
521
+ def calls(self, sites: list[tuple[Node, Scope, bool]]) -> list[dict[str, Any]]:
522
+ out: list[dict[str, Any]] = []
523
+ for node, scope, broken in sites:
524
+ call = self._client(node, scope) if node.type == "call_expression" else None
525
+ if call is None and node.type == "call_expression":
526
+ call = self._fetch(node, scope)
527
+ if call is None and node.type == "object":
528
+ call = self._object(node, scope)
529
+ if call is None:
530
+ continue
531
+ method, path, line, style, arg = call
532
+ if broken or has_syntax_error(node): # a syntax error in the call or around it
533
+ path = None
534
+ out.append({
535
+ "method": method, "path": path or "", "line": line, "style": style,
536
+ "resolved": path is not None, "raw": "" if path is not None else self.raw(arg),
537
+ })
538
+ out.sort(key=lambda c: c["line"])
539
+ return out
540
+
541
+ def _callee(self, node: Node) -> Node | None:
542
+ """What ``node`` calls. The TypeScript grammar reads ``await api.get<T>(path)`` as a
543
+ call of ``await api.get`` (with type arguments), so the ``await`` is looked through."""
544
+ function = node.child_by_field_name("function")
545
+ if function is not None and function.type == "await_expression" and \
546
+ node.child_by_field_name("type_arguments") is not None:
547
+ inner = [c for c in function.named_children if c.type != "comment"]
548
+ return inner[0] if len(inner) == 1 else None
549
+ return function
550
+
551
+ def _arguments(self, node: Node) -> list[Node] | None:
552
+ args = node.child_by_field_name("arguments")
553
+ if args is None or args.type != "arguments":
554
+ return None
555
+ found = [c for c in args.named_children if c.type != "comment"]
556
+ return found or None
557
+
558
+ def _client(self, node: Node, scope: Scope) -> tuple[str, str | None, int, str, Node] | None:
559
+ """``<receiver>.<verb>[<T>](path, ...)``."""
560
+ function = self._callee(node)
561
+ if function is None or function.type != "member_expression":
562
+ return None
563
+ prop = function.child_by_field_name("property")
564
+ receiver = self._receiver(function.child_by_field_name("object"))
565
+ if prop is None or prop.type != "property_identifier" or receiver is None:
566
+ return None
567
+ verb = self.text(prop)
568
+ args = self._arguments(node)
569
+ if verb not in VERBS or args is None:
570
+ return None
571
+ path = self.path(args[0], scope)
572
+ if path is None and (self.external(args[0]) or receiver not in CLIENT_NAMES):
573
+ return None
574
+ return verb.upper(), path, prop.start_point[0] + 1, "client", args[0]
575
+
576
+ def _receiver(self, node: Node | None) -> str | None:
577
+ """The last name of a plain receiver chain (``this.api`` is ``api``), or None."""
578
+ if node is None:
579
+ return None
580
+ if node.type in ("identifier", "this"):
581
+ return self.text(node)
582
+ last = None
583
+ current = node
584
+ while current.type == "member_expression":
585
+ self.spend()
586
+ prop = current.child_by_field_name("property")
587
+ if prop is None or prop.type != "property_identifier":
588
+ return None
589
+ last = last or self.text(prop)
590
+ current = current.child_by_field_name("object")
591
+ if current is None:
592
+ return None
593
+ return last if current.type in ("identifier", "this") else None
594
+
595
+ def _fetch(self, node: Node, scope: Scope) -> tuple[str, str | None, int, str, Node] | None:
596
+ """``fetch(path, {method})`` and ``window.fetch(...)``; ``client.fetch`` is someone
597
+ else's method."""
598
+ function = self._callee(node)
599
+ if function is None:
600
+ return None
601
+ if function.type == "identifier" and self.text(function) == "fetch":
602
+ name = function
603
+ elif function.type == "member_expression":
604
+ name = function.child_by_field_name("property")
605
+ owner = function.child_by_field_name("object")
606
+ if name is None or self.text(name) != "fetch" or owner is None or \
607
+ owner.type != "identifier" or self.text(owner) not in GLOBALS:
608
+ return None
609
+ else:
610
+ return None
611
+ args = self._arguments(node)
612
+ if args is None:
613
+ return None
614
+ path = self.path(args[0], scope)
615
+ if path is None and self.external(args[0]):
616
+ return None
617
+ options = self.unwrap(args[1]) if len(args) > 1 else None
618
+ fields: dict[str, Node] = {}
619
+ default: str | None
620
+ if options is None:
621
+ default = "GET"
622
+ elif options.type == "object":
623
+ fields, overwrites = self.fields(options, scope)
624
+ default = None if overwrites else "GET"
625
+ else:
626
+ default = None
627
+ method = self.method(fields.get("method"), scope, default)
628
+ return method, path, name.start_point[0] + 1, "fetch", args[0]
629
+
630
+ def _object(self, node: Node, scope: Scope) -> tuple[str, str | None, int, str, Node] | None:
631
+ """``{ url: path, method: 'post' }`` and the shorthand ``{ url, method: 'post' }``."""
632
+ fields, overwrites = self.fields(node, scope)
633
+ value = fields.get("url")
634
+ if value is None:
635
+ return None
636
+ if value.type == "shorthand_property_identifier":
637
+ pieces = self.name_pieces(self.text(value), scope)
638
+ path = None if pieces is None else path_from_pieces(pieces)
639
+ line = value.start_point[0] + 1
640
+ else:
641
+ path = self.path(value, scope)
642
+ line = (value.parent or value).start_point[0] + 1
643
+ if path is None:
644
+ return None # an object with a `url` that is not a path is not evidence of a call
645
+ method = self.method(fields.get("method"), scope, None if overwrites else "GET")
646
+ return method, path, line, "object", value
647
+
648
+
649
+ def path_from_pieces(pieces: Pieces) -> str | None:
650
+ """The path the pieces spell, ``${expr}`` as ``{}``; a leading expression followed by ``/``
651
+ is a base URL and dropped. None unless the result starts with ``/``, and None when a piece is
652
+ a name bound to a string this reader cannot read."""
653
+ if any(kind == _UNKNOWN for kind, _ in pieces):
654
+ return None
655
+ if len(pieces) > 1 and pieces[0][0] == "expr" and pieces[1][0] == "text" and \
656
+ pieces[1][1].startswith("/"):
657
+ pieces = pieces[1:]
658
+ path = "".join(value if kind == "text" else "{}" for kind, value in pieces)
659
+ if not path.startswith("/") or path.startswith("//"):
660
+ return None
661
+ return path
662
+
663
+
664
+ _SIMPLE_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}
665
+ _ESCAPE = re.compile(
666
+ r"\\(?:x([0-9A-Fa-f]{2})|u([0-9A-Fa-f]{4})|u\{([0-9A-Fa-f]{1,6})\}|(\r\n|[\s\S]))"
667
+ )
668
+
669
+
670
+ def decode(text: str) -> str:
671
+ """The value of a string's or template text's source: ``\\n`` and the like, ``\\xHH``,
672
+ ``\\uHHHH``, ``\\u{H...}``, a line continuation (nothing), any other character as itself. A
673
+ malformed hex escape keeps its letters, so the path it spells cannot match a route by
674
+ accident."""
675
+ def one(match: re.Match[str]) -> str:
676
+ hexed = next((g for g in match.groups()[:3] if g is not None), None)
677
+ if hexed is not None:
678
+ code = int(hexed, 16)
679
+ return chr(code) if code <= 0x10FFFF else match.group(0)[1:]
680
+ char = match.group(4)
681
+ if char in ("\n", "\r\n", "\r", "\u2028", "\u2029"):
682
+ return ""
683
+ return _SIMPLE_ESCAPES.get(char, char)
684
+
685
+ return _ESCAPE.sub(one, text) if "\\" in text else text
686
+
687
+
688
+ def _syntax_errors(root: Node, reader: _Reader) -> list[int]:
689
+ lines: list[int] = []
690
+ stack = [root]
691
+ while stack and len(lines) < MAX_ERROR_LINES:
692
+ node = stack.pop()
693
+ reader.spend()
694
+ if is_jsx_ampersand(node):
695
+ continue
696
+ if node.type == "ERROR" or node.is_missing:
697
+ line = node.start_point[0] + 1
698
+ if line not in lines:
699
+ lines.append(line)
700
+ continue
701
+ if node.has_error:
702
+ stack.extend(reversed(node.children))
703
+ return sorted(lines)
704
+
705
+
706
+ def read_calls(source: str, suffix: str) -> dict[str, Any]:
707
+ """The HTTP calls of one file's text: ``{"calls": [...], "error": str | None}``."""
708
+ data = source.encode("utf-8", errors="replace")
709
+ if len(data) > MAX_FILE_BYTES:
710
+ return {"calls": [], "error": f"file larger than {MAX_FILE_BYTES} bytes; not read"}
711
+ from tree_sitter import Parser
712
+
713
+ language = grammar(GRAMMAR_OF[suffix])
714
+ tree = Parser(language).parse(data)
715
+ reader = _Reader(data, language)
716
+ try:
717
+ calls = reader.calls(reader.walk(tree.root_node))
718
+ error = None
719
+ lines = ", ".join(str(n) for n in _syntax_errors(tree.root_node, reader))
720
+ if lines:
721
+ error = f"syntax errors at line {lines}; calls there are unresolved"
722
+ except (WorkBoundExceeded, RecursionError) as exc:
723
+ return {"calls": [], "error": f"{type(exc).__name__}: {exc}"}
724
+ return {"calls": calls, "error": error}
725
+
726
+
727
+ class _Language:
728
+ interface = 1
729
+ call_suffixes = tuple(GRAMMAR_OF)
730
+
731
+ @staticmethod
732
+ def read_calls(source: str, suffix: str) -> dict[str, Any]:
733
+ return read_calls(source, suffix)
734
+
735
+
736
+ language = _Language()
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import inspect
5
+ import json
6
+ import random
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import codeterrain_typescript
11
+ import pytest
12
+ from codeterrain_typescript import language, read_calls
13
+
14
+ from codeterrain import calls_static, languages
15
+ from codeterrain.cli import main
16
+
17
+ CORE_TESTS = Path(__file__).parents[3] / "tests" / "test_calls_static.py"
18
+
19
+
20
+ def calls(source: str, suffix: str = ".tsx") -> list[tuple[str, str, int, str]]:
21
+ facts = read_calls(source, suffix)
22
+ assert facts["error"] is None
23
+ return [
24
+ (c["method"], c["path"] or "?" + c["raw"], c["line"], c["style"]) for c in facts["calls"]
25
+ ]
26
+
27
+
28
+ # --- the core's tokenizer cases ------------------------------------------------------------------
29
+
30
+ #: Cases of the core's tokenizer whose expectation this reader changes, each asserted below.
31
+ CHANGED = {
32
+ # scopes: each function's own `const url` is read
33
+ "test_a_constant_declared_twice_with_different_values_is_not_resolved",
34
+ # `'/v1' as string + '/items'` is a sum of two strings, read whole
35
+ "test_an_assertion_followed_by_more_expression_leaves_the_constant_unresolved",
36
+ # unterminated text and an invalid escape are syntax errors, reported with their lines
37
+ "test_an_unterminated_string_or_template_does_not_raise",
38
+ "test_escapes_in_strings_and_templates_are_decoded",
39
+ # the parser is linear: thousands of nested calls are read, not refused
40
+ "test_thousands_of_nested_calls_are_refused_quickly_not_read_in_quadratic_time",
41
+ }
42
+
43
+
44
+ def _core_cases() -> list[tuple[str, object]]:
45
+ spec = importlib.util.spec_from_file_location("core_calls_cases", CORE_TESTS)
46
+ assert spec is not None and spec.loader is not None
47
+ module = importlib.util.module_from_spec(spec)
48
+ spec.loader.exec_module(module)
49
+ module.parse_source = lambda source: read_calls(source, ".tsx")
50
+ return [
51
+ (name, fn) for name, fn in inspect.getmembers(module, inspect.isfunction)
52
+ if name.startswith("test_") and not inspect.signature(fn).parameters
53
+ ]
54
+
55
+
56
+ @pytest.mark.skipif(not CORE_TESTS.is_file(), reason="run from a checkout of codeterrain")
57
+ def test_the_core_tokenizer_cases_hold_except_the_changed_ones():
58
+ cases = _core_cases()
59
+ assert CHANGED <= {name for name, _ in cases}
60
+ for name, case in cases:
61
+ if name not in CHANGED:
62
+ case()
63
+
64
+
65
+ def test_scopes_read_each_function_s_own_constant():
66
+ source = (
67
+ "function a() {\n const url = '/a';\n return fetch(url);\n}\n"
68
+ "function b() {\n const url = '/b';\n return fetch(url);\n}\n"
69
+ "const SAME = '/same';\nconst SAME = '/same';\nfetch(SAME);\n"
70
+ )
71
+ assert calls(source) == [
72
+ ("GET", "/a", 3, "fetch"), ("GET", "/b", 7, "fetch"), ("GET", "/same", 11, "fetch"),
73
+ ]
74
+
75
+
76
+ def test_an_assertion_inside_a_sum_is_read_through():
77
+ source = (
78
+ "const endpoint = '/v1' as string + '/items';\n"
79
+ "const pick = '/v1' as string ? '/v2' : '/v3';\n"
80
+ "fetch(endpoint);\n"
81
+ "fetch(pick);\n"
82
+ )
83
+ assert calls(source) == [("GET", "/v1/items", 3, "fetch"), ("GET", "?pick", 4, "fetch")]
84
+
85
+
86
+ def test_a_syntax_error_is_reported_and_a_call_in_it_is_unresolved():
87
+ for source in ("apiClient.get('/v1/x\n", "fetch(`/v1/${a", "apiClient.get<"):
88
+ facts = read_calls(source, ".tsx")
89
+ assert facts["error"].startswith("syntax errors at line 1")
90
+ assert all(not c["resolved"] for c in facts["calls"])
91
+ facts = read_calls("fetch('/v1/ok');\nfetch('/v1/\\uZZZZ');\n", ".ts")
92
+ assert facts["error"] == "syntax errors at line 2; calls there are unresolved"
93
+ assert [(c["path"], c["resolved"]) for c in facts["calls"]] == [
94
+ ("/v1/ok", True), ("", False),
95
+ ]
96
+
97
+
98
+ def test_escapes_are_decoded():
99
+ source = (
100
+ "fetch('/v1/\\u0069tems');\n"
101
+ "fetch(\"/v1/\\x6frders\");\n"
102
+ "fetch(`/v1/\\u{6c}ines/${id}`);\n"
103
+ "fetch('/v1/a\\\n"
104
+ "b');\n"
105
+ )
106
+ assert calls(source) == [
107
+ ("GET", "/v1/items", 1, "fetch"),
108
+ ("GET", "/v1/orders", 2, "fetch"),
109
+ ("GET", "/v1/lines/{}", 3, "fetch"),
110
+ ("GET", "/v1/ab", 4, "fetch"),
111
+ ]
112
+
113
+
114
+ def test_thousands_of_nested_calls_are_read_in_linear_time():
115
+ nested = "function f() { return " + "api.get(" * 3000 + "'/v1/x'" + ")" * 3000 + "; }\n"
116
+ started = time.perf_counter()
117
+ facts = read_calls(nested, ".ts")
118
+ assert facts["error"] is None and len(facts["calls"]) == 3000
119
+ assert [c["path"] for c in facts["calls"] if c["resolved"]] == ["/v1/x"]
120
+ assert time.perf_counter() - started < 5
121
+
122
+
123
+ # --- scopes --------------------------------------------------------------------------------------
124
+
125
+
126
+ def test_a_constant_inside_an_interpolated_function_is_that_function_s():
127
+ # The acceptance case of the tree-sitter reader.
128
+ source = (
129
+ 'const url = "/outer";\n'
130
+ 'const s = `${(() => { const url = "/inner"; return fetch(url); })()}`;\n'
131
+ "fetch(url);\n"
132
+ )
133
+ assert calls(source) == [("GET", "/inner", 2, "fetch"), ("GET", "/outer", 3, "fetch")]
134
+
135
+
136
+ def test_a_parameter_or_block_binding_hides_a_constant_only_where_it_is_bound():
137
+ source = (
138
+ "const url = '/v1/default';\n"
139
+ "export function load(url: string) { return fetch(url); }\n"
140
+ "fetch(url);\n"
141
+ "if (ok) { const url = '/v1/block'; fetch(url); }\n"
142
+ "for (const url of urls) { fetch(url); }\n"
143
+ "try { go(); } catch (url) { fetch(url); }\n"
144
+ "fetch(url);\n"
145
+ )
146
+ assert calls(source) == [
147
+ ("GET", "?url", 2, "fetch"),
148
+ ("GET", "/v1/default", 3, "fetch"),
149
+ ("GET", "/v1/block", 4, "fetch"),
150
+ ("GET", "?url", 5, "fetch"),
151
+ ("GET", "?url", 6, "fetch"),
152
+ ("GET", "/v1/default", 7, "fetch"),
153
+ ]
154
+
155
+
156
+ def test_a_var_belongs_to_its_function_and_an_import_is_not_a_constant():
157
+ source = (
158
+ "import { BASE } from './config';\n"
159
+ "import API from './api';\n"
160
+ "const url = '/v1/outer';\n"
161
+ "function load() {\n"
162
+ " if (x) { var url = '/v1/var'; }\n"
163
+ " return fetch(url);\n"
164
+ "}\n"
165
+ "fetch(`${BASE}/items`);\n"
166
+ "fetch(`${API}/orders`);\n"
167
+ "function each() { for (var url of urls) {} fetch(url); }\n"
168
+ "function keys() { for (var url in map) {} fetch(url); }\n"
169
+ "function scoped() { for (let url of urls) {} fetch(url); }\n"
170
+ )
171
+ assert calls(source) == [
172
+ ("GET", "?url", 6, "fetch"),
173
+ ("GET", "/items", 8, "fetch"),
174
+ ("GET", "/orders", 9, "fetch"),
175
+ ("GET", "?url", 10, "fetch"),
176
+ ("GET", "?url", 11, "fetch"),
177
+ ("GET", "/v1/outer", 12, "fetch"),
178
+ ]
179
+
180
+
181
+ def test_a_method_held_in_a_constant_is_read():
182
+ source = (
183
+ "const POST = 'post';\n"
184
+ "fetch('/v1/a', { method: POST });\n"
185
+ "axios({ url: '/v1/b', method: 'purge' });\n"
186
+ )
187
+ assert calls(source) == [("POST", "/v1/a", 2, "fetch"), ("?", "/v1/b", 3, "object")]
188
+
189
+
190
+ # --- grammars ------------------------------------------------------------------------------------
191
+
192
+
193
+ def test_each_suffix_is_read_with_its_grammar():
194
+ # `<T>value` is an assertion in .ts and an element in .tsx; JSX in a .js file.
195
+ assert calls("const u = <string>'/v1/a';\nfetch(u);\n", ".ts") == [("GET", "/v1/a", 2, "fetch")]
196
+ assert calls(
197
+ "const El = () => <button onClick={() => fetch('/v1/b')}>Don't</button>;\n", ".js"
198
+ ) == [("GET", "/v1/b", 1, "fetch")]
199
+ for suffix in language.call_suffixes:
200
+ assert calls("api.post('/v1/c', body);\n", suffix) == [("POST", "/v1/c", 1, "client")]
201
+
202
+
203
+ def test_an_awaited_call_with_type_arguments_is_read():
204
+ # The grammar reads `await api.get<T>(...)` as a call of `await api.get`.
205
+ source = (
206
+ "const load = async (id: string) => {\n"
207
+ " const r = await apiClient.get<Result>(`/v1/teams/${id}/tasks`);\n"
208
+ " const s = await fetch<Other>('/v1/b');\n"
209
+ " return (await getClient()).get<T>('/v1/c');\n"
210
+ "};\n"
211
+ )
212
+ assert calls(source, ".ts") == [
213
+ ("GET", "/v1/teams/{}/tasks", 2, "client"), ("GET", "/v1/b", 3, "fetch"),
214
+ ]
215
+
216
+
217
+ def test_a_bare_ampersand_in_jsx_text_is_text_not_a_syntax_error():
218
+ source = (
219
+ "const Page = () => (\n"
220
+ " <div onClick={() => fetch('/v1/a')}>\n"
221
+ " <p>Staff & privilege levels</p>\n"
222
+ " {render({ url: '/v1/b', label: <b>Retry & cancel</b> })}\n"
223
+ " </div>\n"
224
+ ");\n"
225
+ )
226
+ assert calls(source) == [("GET", "/v1/a", 2, "fetch"), ("GET", "/v1/b", 4, "object")]
227
+ assert calls(source, ".jsx") == [("GET", "/v1/a", 2, "fetch"), ("GET", "/v1/b", 4, "object")]
228
+
229
+
230
+ def test_discovering_the_pack_does_not_load_tree_sitter():
231
+ # Every terrain command discovers the packs; only the ones reading front-end files need the
232
+ # parser.
233
+ import subprocess
234
+ import sys
235
+
236
+ code = (
237
+ "import sys, codeterrain_typescript as p; assert 'tree_sitter' not in sys.modules; "
238
+ "p.read_calls('fetch(\"/v1/a\");', '.ts'); assert 'tree_sitter' in sys.modules"
239
+ )
240
+ subprocess.run([sys.executable, "-c", code], check=True)
241
+
242
+
243
+ # --- bounds --------------------------------------------------------------------------------------
244
+
245
+
246
+ def test_a_file_over_the_size_bound_is_not_read():
247
+ facts = read_calls("x" * (codeterrain_typescript.MAX_FILE_BYTES + 1), ".ts")
248
+ assert facts == {"calls": [], "error": "file larger than 2000000 bytes; not read"}
249
+
250
+
251
+ def test_a_constant_that_doubles_at_every_declaration_stays_cheap_and_the_bound_holds(
252
+ monkeypatch,
253
+ ):
254
+ lines = ["const p0 = '/a';"] + [f"const p{n} = p{n - 1} + p{n - 1};" for n in range(1, 60)]
255
+ source = "\n".join([*lines, "fetch(p59);", "fetch(p3);"]) + "\n"
256
+ assert calls(source, ".ts") == [("GET", "?p59", 61, "fetch"), ("GET", "/a" * 8, 62, "fetch")]
257
+ # past the bound, the file is an error, not a slow answer
258
+ monkeypatch.setattr(codeterrain_typescript, "WORK_FLOOR", 50)
259
+ monkeypatch.setattr(codeterrain_typescript, "WORK_PER_BYTE", 0)
260
+ facts = read_calls(source, ".ts")
261
+ assert facts["calls"] == [] and "work bound" in facts["error"]
262
+
263
+
264
+ @pytest.mark.parametrize("seed", range(4))
265
+ def test_random_and_hostile_input_never_raises(seed):
266
+ rng = random.Random(seed)
267
+ alphabet = "`'\"${}()[]<>/\\=+;:,.!?\n abcfetchurlgetpost0123" + chr(0) + "
\udcff"
268
+ for _ in range(50):
269
+ text = "".join(rng.choice(alphabet) for _ in range(rng.randrange(1, 400)))
270
+ for suffix in (".ts", ".tsx", ".js"):
271
+ facts = read_calls(text, suffix)
272
+ assert set(facts) == {"calls", "error"}
273
+ assert all(calls_static._pack_call(c) for c in facts["calls"])
274
+
275
+
276
+ # --- through the core ----------------------------------------------------------------------------
277
+
278
+
279
+ def test_the_core_reads_front_end_calls_through_the_pack(tmp_path, capsys):
280
+ (found,) = [lang for lang in languages.discover()[0] if lang.name == "typescript"]
281
+ assert found.read is None and set(found.call_suffixes) == set(calls_static.SUFFIXES)
282
+ root = tmp_path / "web"
283
+ (root / "src").mkdir(parents=True)
284
+ (root / "src" / "orders.ts").write_text(
285
+ "function a() { const url = '/v1/a'; return fetch(url); }\n"
286
+ "function b() { const url = '/v1/b'; return fetch(url); }\n"
287
+ "fetch(`/v1/${\n",
288
+ encoding="utf-8",
289
+ )
290
+ code = main(["calls", "--json", "--root", str(root), "--cache-dir", str(tmp_path / "c")])
291
+ data = json.loads(capsys.readouterr().out)
292
+ assert code == 0
293
+ assert [(c["path"], c["line"]) for c in data["rows"] if c["resolved"]] == [
294
+ ("/v1/a", 1), ("/v1/b", 2),
295
+ ]
296
+ assert data["errors"] == [
297
+ {"file": "src/orders.ts", "error": "syntax errors at line 3; calls there are unresolved"},
298
+ ]