beatrina 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,814 @@
1
+ """analyze.py — the Python ANALYSIS child. It reads Python; it never runs Python.
2
+
3
+ Behind Beatrix's analysis plane (host/planes/analyze.mjs), beside spike/analyze.R
4
+ and for the same reason one language over: the evaluating session (the Python
5
+ engine's ipykernel) answers nothing while a cell is inside a long computation,
6
+ and that must not take syntax checking, navigation and rename down with it.
7
+
8
+ browser ⇅ host/main.mjs ⇅ ipykernel — evaluates everything
9
+ ⇅ analyze.py — evaluates NOTHING
10
+
11
+ Ported from carmapy's analyze.py (../carmar-python/py/carmapy/analyze.py), with
12
+ one deliberate change recorded in docs/wp/wp8a-python-analyzer.md: the
13
+ reference index is built from `ast`, not from `jedi`, and jedi is not imported.
14
+
15
+ THE ONE RULE: nothing in this file may evaluate user source.
16
+
17
+ · `ast.parse()` builds a syntax tree and runs no line of it.
18
+ · `codeop.compile_command()` COMPILES (bytecode) and never executes; it is
19
+ here only to answer "would more input finish this?".
20
+ · There is no `eval`, no `exec`, no `compile(..., "exec")` handed to a
21
+ runner, no `import` of anything the user wrote, and none may be added.
22
+ test/python-analyzer.test.mjs greps this file for them.
23
+ · The process runs `python -I`: no PYTHONPATH, no user site-packages, no
24
+ script directory on sys.path. It imports the standard library and nothing
25
+ else, so there is no third-party module whose import could run code.
26
+
27
+ ── WHY AST AND NOT JEDI ─────────────────────────────────────────────────────
28
+ The page's rename (lib/rename.js) rests on analyze.R's contract: two
29
+ occurrences are the SAME symbol when name and `scope` match, where scope 0 is a
30
+ document's top level and any other scope is the innermost FUNCTION THAT BINDS
31
+ the name. carmapy took `scope` from jedi's `parent()`, which is the enclosing
32
+ definition, not the binding — its own docstring says so and says the rule is
33
+ therefore "not claimed for Python". Fed to lib/rename.js that is a silent
34
+ partial rename: a read of a top-level name inside a function would carry the
35
+ function's id and be skipped as "a local of the same name". A binder over the
36
+ syntax tree answers the real question with Python's own rules (parameters,
37
+ assignment, `global`, `nonlocal`, class bodies that do not enclose methods,
38
+ comprehension scopes), costs nothing per name, and needs no third-party code.
39
+
40
+ The ops this child answers are exactly the four the host forwards
41
+ (`analyze`, `ping`, `workspace`, `workspace_references`) — the same as
42
+ analyze.R. Hover, definition and rename are the PAGE's, computed from the
43
+ reference index and the symbols, as they are for R.
44
+
45
+ ── POSITIONS ────────────────────────────────────────────────────────────────
46
+ Every position emitted is 1-based line, 1-based CODE POINT column,
47
+ `unit: "codepoint"` — analyze.R's contract, so lib/r-intelligence.js converts a
48
+ Python reply with no change. `ast` reports columns as 0-based UTF-8 BYTE
49
+ offsets, which are converted against the line's own bytes (`_cp`) and never
50
+ emitted raw. `SyntaxError.offset` is already 1-based code points (measured in
51
+ carmapy: `x = "👩" z` reports 9). Only "\n" starts a line, as in the page; the
52
+ notebook normalises CRLF before a source is analysed.
53
+
54
+ Python, like R, reports only the FIRST syntax error, so a reply carries at most
55
+ one parse diagnostic.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import ast
61
+ import codeop
62
+ import json
63
+ import os
64
+ import re
65
+ import sys
66
+ import time
67
+ import warnings
68
+
69
+
70
+ def _budget() -> float:
71
+ try:
72
+ v = float(os.environ.get("CARMAR_ANALYZE_BUDGET", "3"))
73
+ except ValueError:
74
+ v = 3.0
75
+ if v != v or v in (float("inf"), float("-inf")):
76
+ v = 3.0
77
+ return max(0.0, min(v, 60.0))
78
+
79
+
80
+ ANALYZE_BUDGET = _budget()
81
+
82
+ MAX_FILES = 300
83
+ MAX_DEPTH = 4
84
+ MAX_BYTES = 512_000
85
+ MAX_MATCHES = 50
86
+ MAX_REPLY_BYTES = 4_194_304
87
+
88
+ SKIP_DIRS = frozenset({
89
+ ".git", ".hg", ".svn", "node_modules", ".venv", "venv", "env",
90
+ "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox",
91
+ ".ipynb_checkpoints", ".quarto", "_site", "dist", "build", ".eggs",
92
+ "site-packages", ".Rproj.user", "renv", "packrat",
93
+ })
94
+
95
+
96
+ # ── framing ────────────────────────────────────────────────────────────────
97
+
98
+ class Emitter:
99
+ """One control frame: the session's random sentinel, then compact JSON."""
100
+
101
+ def __init__(self, sentinel: str, out=None):
102
+ self.sentinel = sentinel
103
+ self.out = out if out is not None else sys.stdout
104
+
105
+ def __call__(self, obj: dict) -> None:
106
+ self.out.write(self.sentinel + json.dumps(obj, separators=(",", ":"),
107
+ ensure_ascii=False) + "\n")
108
+ self.out.flush()
109
+
110
+
111
+ # ── positions ──────────────────────────────────────────────────────────────
112
+
113
+ class Lines:
114
+ """A source's lines, with byte-offset -> code-point conversion per line."""
115
+
116
+ def __init__(self, source: str):
117
+ self.text = source.split("\n")
118
+ self._bytes: dict = {}
119
+
120
+ def line(self, lineno: int) -> str:
121
+ i = (lineno or 1) - 1
122
+ return self.text[i] if 0 <= i < len(self.text) else ""
123
+
124
+ def cp(self, lineno: int, byte_col: int) -> int:
125
+ """0-based UTF-8 byte offset on a line -> 0-based code-point offset."""
126
+ raw = self._bytes.get(lineno)
127
+ if raw is None:
128
+ raw = self.line(lineno).encode("utf-8")
129
+ self._bytes[lineno] = raw
130
+ return len(raw[:max(0, byte_col)].decode("utf-8", errors="ignore"))
131
+
132
+
133
+ # ── diagnostics ────────────────────────────────────────────────────────────
134
+
135
+ def _syntax_diagnostic(err: SyntaxError, incomplete: bool) -> dict:
136
+ line = err.lineno
137
+ col = err.offset # 1-based CODE POINTS
138
+ end_line = getattr(err, "end_lineno", None)
139
+ end_col = getattr(err, "end_offset", None)
140
+ # `end_offset` is -1 on an IndentationError and 0 on "'[' was never closed".
141
+ # Those are not positions: an end that does not lie strictly after the start
142
+ # is dropped, and the page widens the point to the token under it.
143
+ if col is None or end_col is None or end_line is None:
144
+ end_line, end_col = line, None
145
+ elif end_line < (line or 0) or (end_line == line and end_col <= col):
146
+ end_line, end_col = line, None
147
+ return {
148
+ "severity": "error", "rule": "parse",
149
+ "message": err.msg or "invalid syntax",
150
+ # A NUL byte is a SyntaxError with no position (3.12+); the raw text then
151
+ # says the message alone rather than "(None:None)".
152
+ "raw": f"{err.msg} ({err.lineno}:{err.offset})" if line is not None else str(err.msg),
153
+ "unit": "codepoint", "line": line, "col": col,
154
+ "endLine": end_line, "endCol": end_col, "incomplete": incomplete,
155
+ }
156
+
157
+
158
+ def _is_incomplete(source: str) -> bool:
159
+ """Would more input make this parse? `codeop` compiles; it never executes."""
160
+ try:
161
+ with warnings.catch_warnings():
162
+ warnings.simplefilter("ignore")
163
+ return codeop.compile_command(source, "<analyze>", "exec") is None
164
+ except (SyntaxError, ValueError, OverflowError, MemoryError, RecursionError):
165
+ return False
166
+
167
+
168
+ def _parse(source: str):
169
+ # SyntaxWarnings (an invalid escape, `is` with a literal) are not errors and
170
+ # must not reach stderr as noise; they are silenced, never turned into
171
+ # diagnostics the page would underline as broken code.
172
+ with warnings.catch_warnings():
173
+ warnings.simplefilter("ignore")
174
+ return ast.parse(source)
175
+
176
+
177
+ # ── symbols (lines only, as analyze.R) ─────────────────────────────────────
178
+
179
+ def _signature_of(node) -> tuple:
180
+ a = node.args
181
+ names: list = []
182
+ parts: list = []
183
+
184
+ def render(arg, default=None, prefix=""):
185
+ text = prefix + arg.arg
186
+ if arg.annotation is not None:
187
+ text += ": " + ast.unparse(arg.annotation)
188
+ if default is not None:
189
+ text += ("=" if arg.annotation is None else " = ") + ast.unparse(default)
190
+ names.append(arg.arg)
191
+ parts.append(text)
192
+
193
+ posonly = list(a.posonlyargs)
194
+ normal = list(a.args)
195
+ defaults = list(a.defaults)
196
+ positional = posonly + normal
197
+ pad = [None] * (len(positional) - len(defaults)) + defaults
198
+ for arg, dflt in zip(posonly, pad[:len(posonly)]):
199
+ render(arg, dflt)
200
+ if posonly:
201
+ parts.append("/")
202
+ for arg, dflt in zip(normal, pad[len(posonly):]):
203
+ render(arg, dflt)
204
+ if a.vararg is not None:
205
+ render(a.vararg, None, "*")
206
+ elif a.kwonlyargs:
207
+ parts.append("*")
208
+ for arg, dflt in zip(a.kwonlyargs, a.kw_defaults):
209
+ render(arg, dflt)
210
+ if a.kwarg is not None:
211
+ render(a.kwarg, None, "**")
212
+ return node.name + "(" + ", ".join(parts) + ")", names
213
+
214
+
215
+ def top_symbols(tree) -> list:
216
+ """Top-level names a document defines, with the line each sits on."""
217
+ out = []
218
+ for node in getattr(tree, "body", []):
219
+ end = getattr(node, "end_lineno", node.lineno)
220
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
221
+ signature, params = _signature_of(node)
222
+ out.append({"name": node.name, "line": node.lineno, "endLine": end,
223
+ "kind": "function", "params": params, "signature": signature})
224
+ elif isinstance(node, ast.ClassDef):
225
+ out.append({"name": node.name, "line": node.lineno, "endLine": end,
226
+ "kind": "class", "params": [], "signature": node.name})
227
+ elif isinstance(node, ast.Assign):
228
+ for target in node.targets:
229
+ if isinstance(target, ast.Name):
230
+ out.append({"name": target.id, "line": node.lineno, "endLine": end,
231
+ "kind": "variable", "params": [], "signature": None})
232
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
233
+ out.append({"name": node.target.id, "line": node.lineno, "endLine": end,
234
+ "kind": "variable", "params": [], "signature": None})
235
+ return out
236
+
237
+
238
+ # ── the reference index: a binder over the syntax tree ─────────────────────
239
+
240
+ _FUNC_KINDS = ("function", "lambda", "comprehension")
241
+ _COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
242
+
243
+
244
+ class Scope:
245
+ __slots__ = ("kind", "id", "parent", "bound", "globals", "nonlocals")
246
+
247
+ def __init__(self, kind, sid, parent):
248
+ self.kind = kind
249
+ self.id = sid
250
+ self.parent = parent
251
+ self.bound: set = set()
252
+ self.globals: set = set()
253
+ self.nonlocals: set = set()
254
+
255
+ def binder(self):
256
+ """Where a walrus binds: the nearest scope that is not a comprehension."""
257
+ s = self
258
+ while s is not None and s.kind == "comprehension":
259
+ s = s.parent
260
+ return s
261
+
262
+
263
+ def _targets(node):
264
+ """Every plain name an assignment target binds (tuples, lists, stars)."""
265
+ if isinstance(node, ast.Name):
266
+ yield node.id
267
+ elif isinstance(node, (ast.Tuple, ast.List)):
268
+ for elt in node.elts:
269
+ yield from _targets(elt)
270
+ elif isinstance(node, ast.Starred):
271
+ yield from _targets(node.value)
272
+
273
+
274
+ class References:
275
+ """Every parser-confirmed name occurrence, with its role and its binding scope.
276
+
277
+ Roles (analyze.R's list, analyze.R's reasons):
278
+ def a binding — the thing to jump TO (assignment, def, class, import,
279
+ `except ... as`, a `for`/`with` target, a match capture).
280
+ formal a parameter: a binding, so it shadows an outer name.
281
+ field `obj.attr` and `f(name=...)` — a name in ANOTHER namespace, kept in
282
+ the index so a rename can say what it declined to touch.
283
+ call `f(...)` — a use, and a call.
284
+ use any other mention, including `global x` / `nonlocal x`.
285
+
286
+ SCOPE is analyze.R's: 0 for a document's top level, otherwise the id of the
287
+ innermost function (or lambda, or comprehension) that BINDS the name. A
288
+ class body binds only for occurrences directly inside it — methods do not see
289
+ class names, which is Python's rule. Ids are `line * 10000 + column` of the
290
+ scope's node, stable within one parse, which is all analyze.R's are.
291
+ """
292
+
293
+ def __init__(self, source: str):
294
+ self.lines = Lines(source)
295
+ self.scopes: dict = {}
296
+ self.out: list = []
297
+
298
+ # -- pass 1: what each scope binds -------------------------------------
299
+
300
+ def _sid(self, node) -> int:
301
+ return (node.lineno or 0) * 10000 + self.lines.cp(node.lineno, node.col_offset)
302
+
303
+ def collect(self, tree) -> None:
304
+ module = Scope("module", 0, None)
305
+ self.scopes[id(tree)] = module
306
+ self._collect_body(tree.body, module)
307
+
308
+ def _collect_body(self, nodes, scope) -> None:
309
+ for node in nodes:
310
+ self._collect(node, scope)
311
+
312
+ def _collect_args(self, args, scope) -> None:
313
+ for a in (list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs)
314
+ + [x for x in (args.vararg, args.kwarg) if x is not None]):
315
+ scope.bound.add(a.arg)
316
+
317
+ def _collect(self, node, scope) -> None:
318
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
319
+ scope.bound.add(node.name)
320
+ for d in node.decorator_list:
321
+ self._collect(d, scope)
322
+ for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x is not None]:
323
+ self._collect(d, scope)
324
+ inner = Scope("function", self._sid(node), scope)
325
+ self.scopes[id(node)] = inner
326
+ self._collect_args(node.args, inner)
327
+ self._collect_body(node.body, inner)
328
+ return
329
+ if isinstance(node, ast.ClassDef):
330
+ scope.bound.add(node.name)
331
+ for d in list(node.decorator_list) + list(node.bases) + [k.value for k in node.keywords]:
332
+ self._collect(d, scope)
333
+ inner = Scope("class", self._sid(node), scope)
334
+ self.scopes[id(node)] = inner
335
+ self._collect_body(node.body, inner)
336
+ return
337
+ if isinstance(node, ast.Lambda):
338
+ for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x is not None]:
339
+ self._collect(d, scope)
340
+ inner = Scope("lambda", self._sid(node), scope)
341
+ self.scopes[id(node)] = inner
342
+ self._collect_args(node.args, inner)
343
+ self._collect(node.body, inner)
344
+ return
345
+ if isinstance(node, _COMPREHENSIONS):
346
+ inner = Scope("comprehension", self._sid(node), scope)
347
+ self.scopes[id(node)] = inner
348
+ for gen in node.generators:
349
+ for name in _targets(gen.target):
350
+ inner.bound.add(name)
351
+ self._collect(gen.iter, inner)
352
+ for cond in gen.ifs:
353
+ self._collect(cond, inner)
354
+ for part in ("elt", "key", "value"):
355
+ if hasattr(node, part):
356
+ self._collect(getattr(node, part), inner)
357
+ return
358
+ if isinstance(node, ast.Global):
359
+ scope.globals.update(node.names)
360
+ return
361
+ if isinstance(node, ast.Nonlocal):
362
+ scope.nonlocals.update(node.names)
363
+ return
364
+ if isinstance(node, ast.NamedExpr):
365
+ binder = scope.binder()
366
+ for name in _targets(node.target):
367
+ binder.bound.add(name)
368
+ self._collect(node.value, scope)
369
+ return
370
+ if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
371
+ scope.bound.add(node.id)
372
+ return
373
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
374
+ for alias in node.names:
375
+ if alias.name == "*":
376
+ continue
377
+ scope.bound.add(alias.asname or alias.name.split(".")[0])
378
+ return
379
+ if isinstance(node, ast.ExceptHandler) and node.name:
380
+ scope.bound.add(node.name)
381
+ if isinstance(node, ast.MatchAs) and node.name:
382
+ scope.bound.add(node.name)
383
+ if isinstance(node, ast.MatchStar) and node.name:
384
+ scope.bound.add(node.name)
385
+ if isinstance(node, ast.MatchMapping) and node.rest:
386
+ scope.bound.add(node.rest)
387
+ for child in ast.iter_child_nodes(node):
388
+ self._collect(child, scope)
389
+
390
+ # -- resolution ---------------------------------------------------------
391
+
392
+ def resolve(self, name: str, scope):
393
+ """The id and kind of the scope that binds `name` as seen from `scope`."""
394
+ # Python's rule, in the order Python applies it. `global x` sends x to the
395
+ # module; `nonlocal x` says "not bound HERE", so the walk moves outward;
396
+ # a class body binds only what is written directly in it (a method does
397
+ # not see its class's names).
398
+ s = scope
399
+ while s is not None:
400
+ if s.kind == "module":
401
+ return 0, "module"
402
+ if s.kind == "class":
403
+ if s is scope and name in s.bound:
404
+ return s.id, "class"
405
+ else:
406
+ if name in s.globals:
407
+ return 0, "module"
408
+ if name not in s.nonlocals and name in s.bound:
409
+ return s.id, s.kind
410
+ s = s.parent
411
+ return 0, "module"
412
+
413
+ # -- pass 2: occurrences ------------------------------------------------
414
+
415
+ def emit(self, name, lineno, cp_col, role, scope) -> None:
416
+ if lineno is None or cp_col is None:
417
+ return
418
+ sid, kind = self.resolve(name, scope)
419
+ self.out.append({
420
+ "name": name, "line": lineno, "col": cp_col + 1,
421
+ "endCol": cp_col + 1 + len(name), "role": role, "unit": "codepoint",
422
+ "scope": sid, "scopeKind": kind,
423
+ })
424
+
425
+ def emit_field(self, name, lineno, cp_col, scope) -> None:
426
+ if lineno is None or cp_col is None:
427
+ return
428
+ self.out.append({
429
+ "name": name, "line": lineno, "col": cp_col + 1,
430
+ "endCol": cp_col + 1 + len(name), "role": "field", "unit": "codepoint",
431
+ "scope": scope.id if scope.kind != "module" else 0, "scopeKind": scope.kind,
432
+ })
433
+
434
+ def _find(self, lineno, byte_start, pattern, name):
435
+ """The code-point column of `name` matched by `pattern` on a line."""
436
+ line = self.lines.line(lineno)
437
+ start = self.lines.cp(lineno, byte_start)
438
+ m = re.compile(pattern.format(re.escape(name))).search(line, start)
439
+ return m.start(1) if m else None
440
+
441
+ def walk(self, tree) -> list:
442
+ self._walk_body(tree.body, self.scopes[id(tree)])
443
+ self.out.sort(key=lambda r: (r["line"], r["col"]))
444
+ return self.out
445
+
446
+ def _walk_body(self, nodes, scope) -> None:
447
+ for node in nodes:
448
+ self._walk(node, scope, None)
449
+
450
+ def _walk_args(self, args, scope) -> None:
451
+ for a in (list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs)
452
+ + [x for x in (args.vararg, args.kwarg) if x is not None]):
453
+ if a.annotation is not None:
454
+ self._walk(a.annotation, scope.parent, None)
455
+ self.emit(a.arg, a.lineno, self.lines.cp(a.lineno, a.col_offset), "formal", scope)
456
+
457
+ def _walk(self, node, scope, parent) -> None:
458
+ if node is None:
459
+ return
460
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
461
+ for d in node.decorator_list:
462
+ self._walk(d, scope, node)
463
+ col = self._find(node.lineno, node.col_offset, r"(?:async\s+)?def\s+({})\b", node.name)
464
+ self.emit(node.name, node.lineno, col, "def", scope)
465
+ inner = self.scopes[id(node)]
466
+ for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x is not None]:
467
+ self._walk(d, scope, node)
468
+ self._walk_args(node.args, inner)
469
+ if node.returns is not None:
470
+ self._walk(node.returns, scope, node)
471
+ self._walk_body(node.body, inner)
472
+ return
473
+ if isinstance(node, ast.ClassDef):
474
+ for d in node.decorator_list:
475
+ self._walk(d, scope, node)
476
+ col = self._find(node.lineno, node.col_offset, r"class\s+({})\b", node.name)
477
+ self.emit(node.name, node.lineno, col, "def", scope)
478
+ for b in node.bases:
479
+ self._walk(b, scope, node)
480
+ for k in node.keywords:
481
+ self._walk(k, scope, node)
482
+ self._walk_body(node.body, self.scopes[id(node)])
483
+ return
484
+ if isinstance(node, ast.Lambda):
485
+ inner = self.scopes[id(node)]
486
+ for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x is not None]:
487
+ self._walk(d, scope, node)
488
+ self._walk_args(node.args, inner)
489
+ self._walk(node.body, inner, node)
490
+ return
491
+ if isinstance(node, _COMPREHENSIONS):
492
+ inner = self.scopes[id(node)]
493
+ for gen in node.generators:
494
+ self._walk(gen.target, inner, gen)
495
+ self._walk(gen.iter, inner, gen)
496
+ for cond in gen.ifs:
497
+ self._walk(cond, inner, gen)
498
+ for part in ("elt", "key", "value"):
499
+ if hasattr(node, part):
500
+ self._walk(getattr(node, part), inner, node)
501
+ return
502
+ if isinstance(node, ast.NamedExpr):
503
+ binder = scope.binder()
504
+ if isinstance(node.target, ast.Name):
505
+ t = node.target
506
+ self.emit(t.id, t.lineno, self.lines.cp(t.lineno, t.col_offset), "def", binder)
507
+ self._walk(node.value, scope, node)
508
+ return
509
+ if isinstance(node, ast.Name):
510
+ col = self.lines.cp(node.lineno, node.col_offset)
511
+ if isinstance(node.ctx, (ast.Store, ast.Del)):
512
+ role = "def"
513
+ elif isinstance(parent, ast.Call) and parent.func is node:
514
+ role = "call"
515
+ else:
516
+ role = "use"
517
+ self.emit(node.id, node.lineno, col, role, scope)
518
+ return
519
+ if isinstance(node, ast.Attribute):
520
+ self._walk(node.value, scope, node)
521
+ end_line = getattr(node, "end_lineno", None)
522
+ end_col = getattr(node, "end_col_offset", None)
523
+ if end_line is not None and end_col is not None:
524
+ cp_end = self.lines.cp(end_line, end_col)
525
+ start = cp_end - len(node.attr)
526
+ if self.lines.line(end_line)[start:cp_end] == node.attr:
527
+ self.emit_field(node.attr, end_line, start, scope)
528
+ return
529
+ if isinstance(node, ast.keyword):
530
+ if node.arg is not None and getattr(node, "lineno", None) is not None:
531
+ col = self.lines.cp(node.lineno, node.col_offset)
532
+ if self.lines.line(node.lineno)[col:col + len(node.arg)] == node.arg:
533
+ self.emit_field(node.arg, node.lineno, col, scope)
534
+ self._walk(node.value, scope, node)
535
+ return
536
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
537
+ for alias in node.names:
538
+ if alias.name == "*":
539
+ continue
540
+ bound = alias.asname or alias.name.split(".")[0]
541
+ lineno = getattr(alias, "lineno", node.lineno)
542
+ byte = getattr(alias, "col_offset", node.col_offset)
543
+ if alias.asname:
544
+ col = self._find(lineno, byte, r"\bas\s+({})\b", bound)
545
+ else:
546
+ col = self.lines.cp(lineno, byte)
547
+ if self.lines.line(lineno)[col:col + len(bound)] != bound:
548
+ col = self._find(lineno, byte, r"\b({})\b", bound)
549
+ self.emit(bound, lineno, col, "def", scope)
550
+ return
551
+ if isinstance(node, (ast.Global, ast.Nonlocal)):
552
+ keyword = "global" if isinstance(node, ast.Global) else "nonlocal"
553
+ line = self.lines.line(node.lineno)
554
+ pos = self.lines.cp(node.lineno, node.col_offset) + len(keyword)
555
+ for name in node.names:
556
+ m = re.compile(r"\b({})\b".format(re.escape(name))).search(line, pos)
557
+ if m:
558
+ self.emit(name, node.lineno, m.start(1), "use", scope)
559
+ pos = m.end(1)
560
+ return
561
+ if isinstance(node, ast.ExceptHandler):
562
+ if node.type is not None:
563
+ self._walk(node.type, scope, node)
564
+ if node.name:
565
+ col = self._find(node.lineno, node.col_offset, r"\bas\s+({})\b", node.name)
566
+ self.emit(node.name, node.lineno, col, "def", scope)
567
+ self._walk_body(node.body, scope)
568
+ return
569
+ if isinstance(node, ast.MatchAs):
570
+ if node.pattern is not None:
571
+ self._walk(node.pattern, scope, node)
572
+ if node.name:
573
+ if node.pattern is None:
574
+ col = self.lines.cp(node.lineno, node.col_offset)
575
+ else:
576
+ col = self._find(node.lineno, node.col_offset, r"\bas\s+({})\b", node.name)
577
+ self.emit(node.name, node.lineno, col, "def", scope)
578
+ return
579
+ if isinstance(node, ast.MatchStar):
580
+ if node.name:
581
+ col = self._find(node.lineno, node.col_offset, r"\*\s*({})\b", node.name)
582
+ self.emit(node.name, node.lineno, col, "def", scope)
583
+ return
584
+ if isinstance(node, ast.MatchMapping):
585
+ for key in node.keys:
586
+ self._walk(key, scope, node)
587
+ for pat in node.patterns:
588
+ self._walk(pat, scope, node)
589
+ if node.rest:
590
+ end_line = getattr(node, "end_lineno", node.lineno)
591
+ col = self._find(node.lineno, node.col_offset, r"\*\*\s*({})\b", node.rest)
592
+ self.emit(node.rest, node.lineno if col is not None else end_line, col, "def", scope)
593
+ return
594
+ for child in ast.iter_child_nodes(node):
595
+ self._walk(child, scope, node)
596
+
597
+
598
+ def document_references(tree, source: str) -> list:
599
+ refs = References(source)
600
+ refs.collect(tree)
601
+ return refs.walk(tree)
602
+
603
+
604
+ # ── the ops ────────────────────────────────────────────────────────────────
605
+
606
+ def analyze_source(source) -> dict:
607
+ """Diagnostics always; symbols and the reference index when it parses."""
608
+ src = source if isinstance(source, str) else ""
609
+ diagnostics: list = []
610
+ symbols: list = []
611
+ references: list = []
612
+ complete = True
613
+ failed = None
614
+ try:
615
+ tree = _parse(src)
616
+ except SyntaxError as err:
617
+ complete = not _is_incomplete(src)
618
+ diagnostics.append(_syntax_diagnostic(err, incomplete=not complete))
619
+ tree = None
620
+ except (ValueError, OverflowError, MemoryError, RecursionError) as err:
621
+ diagnostics.append({
622
+ "severity": "error", "rule": "parse", "message": str(err),
623
+ "raw": f"{type(err).__name__}: {err}", "unit": "codepoint",
624
+ "line": None, "col": None, "endLine": None, "endCol": None,
625
+ "incomplete": False,
626
+ })
627
+ tree = None
628
+ if tree is not None:
629
+ symbols = top_symbols(tree)
630
+ try:
631
+ references = document_references(tree, src)
632
+ except RecursionError:
633
+ failed = "references unavailable: the document nests too deeply to index"
634
+ return {"diagnostics": diagnostics, "symbols": symbols,
635
+ "references": references, "complete": complete, "failed": failed}
636
+
637
+
638
+ def _walk_files(root: str, deadline: float, max_files: int, max_depth: int):
639
+ found: list = []
640
+ truncated = False
641
+ stack = [(root, 1)]
642
+ while stack:
643
+ directory, depth = stack.pop()
644
+ if depth > max_depth:
645
+ continue
646
+ if len(found) >= max_files or time.monotonic() >= deadline:
647
+ truncated = True
648
+ break
649
+ try:
650
+ entries = sorted(os.scandir(directory), key=lambda e: e.name)
651
+ except OSError:
652
+ continue
653
+ for entry in entries:
654
+ if len(found) >= max_files or time.monotonic() >= deadline:
655
+ truncated = True
656
+ break
657
+ try:
658
+ if entry.is_dir(follow_symlinks=False):
659
+ if entry.name in SKIP_DIRS or entry.name.startswith("."):
660
+ continue
661
+ stack.append((entry.path, depth + 1))
662
+ elif entry.is_file(follow_symlinks=False) and entry.name.endswith(".py"):
663
+ found.append(entry.path)
664
+ except OSError:
665
+ continue
666
+ return found, truncated
667
+
668
+
669
+ def _read_text(path: str, max_bytes: int):
670
+ try:
671
+ size = os.path.getsize(path)
672
+ except OSError:
673
+ return None
674
+ if size > max_bytes:
675
+ return None
676
+ try:
677
+ with open(path, "rb") as fh:
678
+ raw = fh.read(size)
679
+ except OSError:
680
+ return None
681
+ if b"\0" in raw:
682
+ return None
683
+ try:
684
+ return raw.decode("utf-8").replace("\r\n", "\n")
685
+ except UnicodeDecodeError:
686
+ return None
687
+
688
+
689
+ def workspace_symbols(root, max_files=MAX_FILES, max_depth=MAX_DEPTH,
690
+ max_bytes=MAX_BYTES, max_seconds=None) -> dict:
691
+ """Every top-level definition in the `.py` files below a root. Read, never run."""
692
+ budget = ANALYZE_BUDGET if max_seconds is None else max_seconds
693
+ if not root or not os.path.isdir(root):
694
+ return {"files": 0, "symbols": [], "truncated": False, "error": "no such directory"}
695
+ deadline = time.monotonic() + budget
696
+ base = os.path.realpath(root)
697
+ files, truncated = _walk_files(base, deadline, max_files, max_depth)
698
+ out = []
699
+ for path in files:
700
+ if time.monotonic() >= deadline:
701
+ truncated = True
702
+ break
703
+ text = _read_text(path, max_bytes)
704
+ if text is None:
705
+ continue
706
+ try:
707
+ tree = _parse(text)
708
+ except (SyntaxError, ValueError, OverflowError, MemoryError, RecursionError):
709
+ continue
710
+ rel = os.path.relpath(path, base)
711
+ for sym in top_symbols(tree):
712
+ out.append({**sym, "path": path, "file": rel})
713
+ return {"files": len(files), "symbols": out, "truncated": truncated, "error": None}
714
+
715
+
716
+ def workspace_references(root, name, max_files=MAX_FILES, max_depth=MAX_DEPTH,
717
+ max_bytes=MAX_BYTES, max_matches=MAX_MATCHES,
718
+ max_reply_bytes=MAX_REPLY_BYTES, max_seconds=None) -> dict:
719
+ """Every parser-confirmed occurrence of one name in bounded `.py` files."""
720
+ budget = ANALYZE_BUDGET if max_seconds is None else max_seconds
721
+ if not root or not os.path.isdir(root):
722
+ return {"files": 0, "documents": [], "truncated": False, "error": "no such directory"}
723
+ want = str(name or "")
724
+ if not want:
725
+ return {"files": 0, "documents": [], "truncated": False, "error": "name is required"}
726
+ deadline = time.monotonic() + budget
727
+ base = os.path.realpath(root)
728
+ files, truncated = _walk_files(base, deadline, max_files, max_depth)
729
+ documents = []
730
+ total = 0
731
+ for path in files:
732
+ if time.monotonic() >= deadline:
733
+ truncated = True
734
+ break
735
+ text = _read_text(path, max_bytes)
736
+ if text is None or want not in text:
737
+ continue
738
+ try:
739
+ tree = _parse(text)
740
+ refs = document_references(tree, text)
741
+ except (SyntaxError, ValueError, OverflowError, MemoryError, RecursionError):
742
+ continue
743
+ hits = [r for r in refs if r["name"] == want]
744
+ if not hits:
745
+ continue
746
+ size = len(text.encode("utf-8"))
747
+ if len(documents) >= max_matches or total + size > max_reply_bytes:
748
+ truncated = True
749
+ break
750
+ documents.append({"path": path, "file": os.path.relpath(path, base),
751
+ "source": text, "references": hits, "symbols": top_symbols(tree)})
752
+ total += size
753
+ return {"files": len(files), "documents": documents, "truncated": truncated, "error": None}
754
+
755
+
756
+ # ── the command loop ───────────────────────────────────────────────────────
757
+ # NDJSON in, sentinel-framed JSON out, exactly as analyze.R: `analyze` echoes
758
+ # `uri` and `version` so the page can discard a stale answer by strict equality.
759
+
760
+ def handle(cmd: dict) -> dict:
761
+ op = cmd.get("type")
762
+ rid = cmd.get("id")
763
+ if op == "ping":
764
+ return {"type": "pong", "id": rid, "python": sys.version.split()[0]}
765
+ if op == "analyze":
766
+ return {"type": "analyze", "id": rid, "uri": cmd.get("uri"),
767
+ "version": cmd.get("version"), **analyze_source(cmd.get("source"))}
768
+ if op == "workspace":
769
+ root = cmd.get("root")
770
+ res = workspace_symbols(str(root) if root else "")
771
+ return {"type": "workspace", "id": rid, "root": root, **res}
772
+ if op == "workspace_references":
773
+ root = cmd.get("root")
774
+ res = workspace_references(str(root) if root else "", cmd.get("name"))
775
+ return {"type": "workspace_references", "id": rid, "root": root,
776
+ "name": cmd.get("name"), **res}
777
+ return {"type": "analyze-error", "id": rid, "message": f"unknown command: {op}"}
778
+
779
+
780
+ def main(argv=None, stdin=None, stdout=None) -> int:
781
+ argv = sys.argv[1:] if argv is None else argv
782
+ if not argv or not argv[0]:
783
+ print("analyze.py needs a frame sentinel as its first argument", file=sys.stderr)
784
+ return 2
785
+ emit = Emitter(argv[0], stdout)
786
+ stdin = stdin if stdin is not None else sys.stdin
787
+ emit({"type": "analyze-ready", "pid": os.getpid(),
788
+ "python": sys.version.split()[0], "unit": "codepoint", "budget": ANALYZE_BUDGET})
789
+ for line in stdin:
790
+ line = line.strip()
791
+ if not line:
792
+ continue
793
+ try:
794
+ cmd = json.loads(line)
795
+ except ValueError:
796
+ continue
797
+ if not isinstance(cmd, dict) or not isinstance(cmd.get("type"), str):
798
+ continue
799
+ try:
800
+ emit(handle(cmd))
801
+ except RecursionError:
802
+ emit({"type": cmd["type"], "id": cmd.get("id"), "uri": cmd.get("uri"),
803
+ "version": cmd.get("version"),
804
+ "failed": "the analyzer ran out of stack on this document"})
805
+ except Exception as err: # noqa: BLE001
806
+ # Reported, never swallowed: `failed` is how the editor says what it
807
+ # could not do, and the process lives on for the next request.
808
+ emit({"type": cmd["type"], "id": cmd.get("id"), "uri": cmd.get("uri"),
809
+ "version": cmd.get("version"), "failed": f"{type(err).__name__}: {err}"})
810
+ return 0
811
+
812
+
813
+ if __name__ == "__main__":
814
+ raise SystemExit(main())