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,668 @@
1
+ #
2
+ # analyze.R — the ANALYSIS worker. It reads R; it never runs R.
3
+ #
4
+ # Stage 2 (docs/stages/stage-2-intelligence-v1.md): "intelligence must not
5
+ # depend on the execution kernel being idle". The evaluating session
6
+ # (spike/worker.R) services its stdin only between expressions, so while a cell
7
+ # is inside a 40-second model fit it answers nothing — and today that takes
8
+ # syntax checking, completion and hover down with it. This is the third
9
+ # process: same supervisor, same framing, no user code.
10
+ #
11
+ # browser ⇅ serve.R (supervisor) ⇅ worker.R — evaluates everything
12
+ # ⇅ analyze.R — evaluates NOTHING
13
+ #
14
+ # THE ONE RULE: nothing in this file may evaluate user source. `parse(text=)`
15
+ # builds a syntax tree without running a line of it, and that is the only thing
16
+ # here that touches the user's text. There is no eval(), no source(), no
17
+ # do.call on parsed input, and none may be added — the moment this process can
18
+ # run user code it becomes a second evaluating session with no Stop button and
19
+ # no session identity, which is exactly the door the deleted browser extension
20
+ # opened (see CLAUDE.md, "One transport").
21
+ #
22
+ # ── POSITIONS: R COUNTS CODE POINTS, THE BROWSER COUNTS UTF-16 UNITS ───────
23
+ # Measured, not assumed (the probe is reproduced in spike/test-analyze.R):
24
+ #
25
+ # parse(text = 'x <- "\U0001F469" z') -> <text>:1:10: unexpected symbol
26
+ #
27
+ # That 10 is a 1-based CODE POINT column: the emoji counts once. The same
28
+ # position is UTF-16 index 10 (0-based) in JavaScript, because the emoji is a
29
+ # surrogate pair — so a column shipped as "a number" and consumed as an offset
30
+ # lands one unit early on every line containing an astral character, and worse
31
+ # the further along the line the error sits. Every position this file emits is
32
+ # therefore labelled with its unit (`unit: "codepoint"`, 1-based line and col)
33
+ # and MUST be converted by the consumer against the real text, not cast.
34
+ #
35
+ # R reports only the FIRST syntax error in a document (it stops there), so a
36
+ # reply carries at most one parse diagnostic. That is honest for v1: a second
37
+ # error cannot be trusted while the first one is unresolved anyway.
38
+
39
+ suppressPackageStartupMessages(stopifnot(requireNamespace("jsonlite", quietly = TRUE)))
40
+
41
+ # How long a workspace scan may run, in seconds. Read from the environment
42
+ # because the right number depends on the tree: 3 s keeps the single-flight
43
+ # analyzer responsive on a project, and someone with a genuinely large one can
44
+ # raise it knowingly. Clamped to [0, 60] so a typo cannot remove the bound —
45
+ # and 0 is a real setting, meaning "scan nothing and report it truncated".
46
+ ANALYZE_BUDGET <- local({
47
+ v <- suppressWarnings(as.numeric(Sys.getenv("CARMAR_ANALYZE_BUDGET", "3")))
48
+ if (!is.finite(v)) v <- 3
49
+ max(0, min(v, 60))
50
+ })
51
+
52
+ # A private scope, for the same reason worker.R uses one: the protocol must not
53
+ # be clobberable, and nothing here should leak into any environment a future
54
+ # introspection command might report.
55
+ local({
56
+
57
+ args <- commandArgs(trailingOnly = TRUE)
58
+ stopifnot(length(args) >= 1L, nzchar(args[1]))
59
+ sentinel <- args[1]
60
+
61
+ `%||%` <- function(a, b) if (is.null(a)) b else a
62
+
63
+ #' Write one control frame: the session's random sentinel, then compact JSON.
64
+ #'
65
+ #' Identical framing to worker.R so the supervisor's existing reader works
66
+ #' unchanged. Nothing else is ever written to stdout by this process — unlike
67
+ #' the evaluating worker, which streams user output around its frames.
68
+ emit <- function(obj) {
69
+ cat(sentinel, jsonlite::toJSON(obj, auto_unbox = TRUE, null = "null",
70
+ na = "null", digits = NA), "\n", sep = "")
71
+ flush(stdout())
72
+ invisible(NULL)
73
+ }
74
+
75
+ #' Pull `<text>:LINE:COL:` off a parser error message.
76
+ #'
77
+ #' R puts the position in the message text rather than in structured fields of
78
+ #' the condition, so this is the supported way to get it. Messages that carry
79
+ #' no position at all (e.g. "invalid multibyte character in parser") are real
80
+ #' and must still produce a diagnostic — they come back with `line`/`col` NULL
81
+ #' and the consumer places them on the whole document.
82
+ #'
83
+ #' @param msg conditionMessage() of a parse error.
84
+ #' @return list(line, col, text) — line/col integer or NULL, text the message
85
+ #' with the position prefix and the parser's ASCII-art context stripped.
86
+ parse_error_position <- function(msg) {
87
+ first <- strsplit(msg, "\n", fixed = TRUE)[[1L]]
88
+ first <- if (length(first)) first[[1L]] else ""
89
+ m <- regmatches(first, regexec("^<text>:([0-9]+):([0-9]+):[ ]*(.*)$", first))[[1L]]
90
+ if (length(m) == 4L) {
91
+ return(list(line = as.integer(m[[2L]]), col = as.integer(m[[3L]]),
92
+ text = m[[4L]]))
93
+ }
94
+ list(line = NULL, col = NULL, text = first)
95
+ }
96
+
97
+ #' Would more input make this parse? (The console's continuation rule.)
98
+ #'
99
+ #' Kept identical to worker.R's emit_parse so the two never disagree about
100
+ #' what "incomplete" means — a REPL that thinks an expression is finished when
101
+ #' the analyzer thinks it is not would print the wrong prompt.
102
+ is_incomplete <- function(msg) {
103
+ grepl("unexpected end of input|unexpected INCOMPLETE_STRING", msg)
104
+ }
105
+
106
+ #' Top-level names a document defines, with the line each sits on.
107
+ #'
108
+ #' Walks the PARSED expressions — never evaluates them — for `name <- ...`,
109
+ #' `name = ...` and `name <<- ...` at top level, which is what a notebook's
110
+ #' chunk graph and an outline are actually made of. Feeds completion ranking
111
+ #' now and document symbols in Stage 3.
112
+ #'
113
+ #' @param exprs Result of parse(keep.source = TRUE).
114
+ #' @return A list of list(name, line, kind).
115
+ top_symbols <- function(exprs) {
116
+ refs <- attr(exprs, "srcref")
117
+ out <- list()
118
+ n <- length(exprs)
119
+ if (n == 0L) return(out)
120
+ for (i in seq_len(n)) {
121
+ e <- exprs[[i]]
122
+ if (!is.call(e) || length(e) < 3L) next
123
+ op <- as.character(e[[1L]])
124
+ if (!(op %in% c("<-", "=", "<<-"))) next
125
+ lhs <- e[[2L]]
126
+ if (!is.name(lhs)) next # skip f(x) <- and df$a <- forms
127
+ rhs <- e[[3L]]
128
+ is_fn <- is.call(rhs) && identical(as.character(rhs[[1L]]), "function")
129
+ kind <- if (is_fn) "function" else "variable"
130
+ line <- if (!is.null(refs) && length(refs) >= i && !is.null(refs[[i]])) {
131
+ as.integer(refs[[i]][[1L]])
132
+ } else {
133
+ NA_integer_
134
+ }
135
+ # Where the expression ENDS (srcref's third field), so a consumer can tell
136
+ # "the caret is inside this function" from "on its first line" — the
137
+ # roxygen skeleton (lib/chunk-structure.js) inserts above the function the
138
+ # caret is in, wherever in its body the caret sits.
139
+ end_line <- if (!is.null(refs) && length(refs) >= i && !is.null(refs[[i]])) {
140
+ as.integer(refs[[i]][[3L]])
141
+ } else {
142
+ NA_integer_
143
+ }
144
+ # A function's ARGUMENT NAMES, straight off the parse tree. This is the
145
+ # one piece of signature help that needs no session at all: `f <- function
146
+ # (data, n = 10)` states its own formals, so completing `f(` and showing
147
+ # `f(data, n = 10)` works with the worker busy, and works for a function
148
+ # the user wrote thirty seconds ago and has not run yet — which is exactly
149
+ # when a signature is most useful and least available from a live session.
150
+ params <- character(0)
151
+ signature <- NULL
152
+ if (is_fn) {
153
+ fmls <- tryCatch(as.list(rhs[[2L]]), error = function(e) NULL)
154
+ if (!is.null(fmls) && length(fmls)) {
155
+ params <- names(fmls)
156
+ # An argument with no default holds the EMPTY SYMBOL, and binding that
157
+ # to a variable raises "argument is missing" the moment it is used —
158
+ # so it is tested and deparsed in place, never assigned. `deparse` on
159
+ # the default expression, never eval: `n = nrow(d)` shows as written
160
+ # rather than as whatever nrow(d) would return.
161
+ parts <- vapply(seq_along(fmls), function(k) {
162
+ if (is.symbol(fmls[[k]]) && !nzchar(as.character(fmls[[k]]))) params[[k]]
163
+ else paste0(params[[k]], " = ", paste(deparse(fmls[[k]]), collapse = " "))
164
+ }, character(1))
165
+ signature <- paste0(as.character(lhs), "(", paste(parts, collapse = ", "), ")")
166
+ } else {
167
+ signature <- paste0(as.character(lhs), "()")
168
+ }
169
+ }
170
+ out[[length(out) + 1L]] <- list(name = as.character(lhs), line = line, endLine = end_line,
171
+ kind = kind, params = as.list(params), signature = signature)
172
+ }
173
+ out
174
+ }
175
+
176
+ #' Every symbol OCCURRENCE in a document, with its position and its role.
177
+ #'
178
+ #' This is the reference index Stage 3 navigates with and Stage 4 renames
179
+ #' through, and it comes from `getParseData()` rather than from a regex over
180
+ #' the text — which is the entire point. A text search for `f` finds it in
181
+ #' comments, inside strings, and in `d$f`; the parser knows which characters
182
+ #' are code and which are not, so strings and comments never enter the index
183
+ #' at all. Rename correctness is decided HERE, not in the rename.
184
+ #'
185
+ #' Roles, and why each is separate:
186
+ #' def `f <- ...` / `f = ...` at any depth — the thing to jump TO.
187
+ #' call `f(...)` — a use, but worth knowing it is a call.
188
+ #' use a plain mention of the value.
189
+ #' formal `function(f)` — a BINDING, so it shadows the outer `f`.
190
+ #' field `d$f`, `f = 1` inside a call, `obj@f` — a name in ANOTHER
191
+ #' namespace. It looks identical in the text and must never be
192
+ #' renamed with the variable; keeping it in the index (rather than
193
+ #' dropping it) lets a caller SHOW what it declined to touch.
194
+ #'
195
+ #' Columns are 1-based CODE POINTS, exactly like the parse-error position —
196
+ #' verified with the same astral probe (`getParseData` counts "👩" as one
197
+ #' column). The consumer converts; nothing here casts.
198
+ #'
199
+ #' @param exprs parse(keep.source = TRUE) result.
200
+ #' @return list of list(name, line, col, endCol, role).
201
+ references <- function(exprs) {
202
+ pd <- tryCatch(utils::getParseData(exprs), error = function(e) NULL)
203
+ if (is.null(pd) || !nrow(pd)) return(list())
204
+ full <- pd # the tree, for scope resolution
205
+ pd <- pd[pd$terminal, , drop = FALSE]
206
+ if (!nrow(pd)) return(list())
207
+ pd <- pd[order(pd$line1, pd$col1), , drop = FALSE]
208
+
209
+ tok <- pd$token
210
+ keep <- which(tok %in% c("SYMBOL", "SYMBOL_FUNCTION_CALL", "SYMBOL_FORMALS", "SYMBOL_SUB"))
211
+ if (!length(keep)) return(list())
212
+
213
+ # The token before and after each kept one, ignoring comments — that is how
214
+ # `d$f` (field) is told from `f` (variable) and `f <-` (definition) from a
215
+ # bare mention. Both are decided by the neighbour, never by the spelling.
216
+ code <- which(tok != "COMMENT")
217
+ pos_in_code <- match(seq_len(nrow(pd)), code)
218
+ prev_tok <- function(i) {
219
+ k <- pos_in_code[[i]]
220
+ if (is.na(k) || k <= 1L) "" else tok[[code[[k - 1L]]]]
221
+ }
222
+ next_tok <- function(i) {
223
+ k <- pos_in_code[[i]]
224
+ if (is.na(k) || k >= length(code)) "" else tok[[code[[k + 1L]]]]
225
+ }
226
+
227
+ scopes <- tryCatch(resolve_scopes(full, pd$id[keep]),
228
+ error = function(e) rep(0L, length(keep)))
229
+ out <- list()
230
+ for (n in seq_along(keep)) {
231
+ i <- keep[[n]]
232
+ before <- prev_tok(i)
233
+ after <- next_tok(i)
234
+ role <- if (identical(tok[[i]], "SYMBOL_FORMALS")) {
235
+ "formal"
236
+ } else if (identical(tok[[i]], "SYMBOL_SUB") || before %in% c("'$'", "'@'", "SLOT")) {
237
+ "field"
238
+ } else if (after %in% c("LEFT_ASSIGN", "EQ_ASSIGN") ||
239
+ before %in% c("RIGHT_ASSIGN", "SUPER_RIGHT_ASSIGN")) {
240
+ "def"
241
+ } else if (identical(tok[[i]], "SYMBOL_FUNCTION_CALL")) {
242
+ "call"
243
+ } else {
244
+ "use"
245
+ }
246
+ out[[length(out) + 1L]] <- list(
247
+ name = pd$text[[i]], line = pd$line1[[i]], col = pd$col1[[i]],
248
+ endCol = pd$col2[[i]] + 1L, role = role, unit = "codepoint",
249
+ # 0 = the document's top level. Two occurrences are the SAME symbol only
250
+ # when name and scope both match — the rule Stage 4's rename rests on.
251
+ scope = scopes[[n]]
252
+ )
253
+ }
254
+ out
255
+ }
256
+
257
+ #' Which BINDING each reference belongs to — the scope, resolved.
258
+ #'
259
+ #' Stage 4's rename exit criterion says unrelated locals and same-spelled
260
+ #' out-of-scope symbols must not be renamed. That cannot be decided from a
261
+ #' name: in
262
+ #'
263
+ #' total <- 0
264
+ #' f <- function(total) total + 1 # a DIFFERENT total
265
+ #'
266
+ #' there are two `total`s and renaming one must not touch the other. The parse
267
+ #' data carries the tree (`parent`), so the answer is computable rather than
268
+ #' guessable: walk each occurrence's ancestors outward and stop at the first
269
+ #' enclosing function that BINDS that name (as a formal or by assigning it);
270
+ #' if none does, the occurrence belongs to the document's top level.
271
+ #'
272
+ #' The result is a scope id per reference. Two occurrences may be renamed
273
+ #' together if and only if their name AND their scope id match. That rule is
274
+ #' the whole of rename safety, and it lives here rather than in the browser
275
+ #' because only the parser knows the tree.
276
+ #'
277
+ #' @param pd The FULL getParseData() output — terminals AND the interior
278
+ #' nodes, because the parent chain runs through the interior ones. Handing
279
+ #' this a terminal-only table silently resolves everything to the top level,
280
+ #' which looks like working code and renames the wrong things.
281
+ #' @param ids Ids of the symbol occurrences to resolve.
282
+ #' @return integer vector, parallel to `ids`: the id of the binding function
283
+ #' node, or 0 for the document's top level.
284
+ resolve_scopes <- function(pd, ids) {
285
+ # Parent and function-node lookups are BY INTEGER ID, not by name. They used
286
+ # to be `parent_of[[as.character(cur)]]` on a named vector, and R resolves a
287
+ # character index into a vector by LINEAR SCAN — so every step of every
288
+ # ancestor walk cost O(tokens), and the walk runs once per binding and once
289
+ # per symbol. On a 3,000-line file that is quadratic: a workspace Find
290
+ # References over this repo took 49 s, blowing past the client's 25 s
291
+ # timeout and stalling the single-flight analyzer's diagnostics behind it.
292
+ # getParseData ids are positive integers, so an integer-indexed vector makes
293
+ # each hop O(1) and changes no result. `parent` is NEGATIVE for a comment,
294
+ # so both the id map and the function-node mask keep to positives — a
295
+ # negative index in R means "drop", which would silently unset entries.
296
+ ids_pos <- pd$id[pd$id > 0L]
297
+ max_id <- max(c(ids_pos, pd$parent[pd$parent > 0L], 0L))
298
+ parent_vec <- integer(max_id)
299
+ if (length(ids_pos)) parent_vec[ids_pos] <- pd$parent[pd$id > 0L]
300
+ # A function's node is the parent of its FUNCTION token — `function(a) a`
301
+ # and `\(a) a` both produce one.
302
+ fn_nodes <- unique(pd$parent[pd$token %in% c("FUNCTION", "OP-LAMBDA")])
303
+ fn_nodes <- fn_nodes[fn_nodes > 0L]
304
+ is_fn <- logical(max_id)
305
+ if (length(fn_nodes)) is_fn[fn_nodes] <- TRUE
306
+
307
+ #' Ancestors of a node, innermost first, restricted to function nodes.
308
+ fn_ancestors <- function(id) {
309
+ out <- integer(0)
310
+ cur <- id
311
+ guard <- 0L
312
+ while (!is.na(cur) && cur > 0L && cur <= max_id && guard < 1000L) {
313
+ if (is_fn[[cur]]) out <- c(out, cur)
314
+ nxt <- parent_vec[[cur]]
315
+ cur <- if (is.na(nxt)) 0L else nxt
316
+ guard <- guard + 1L
317
+ }
318
+ out
319
+ }
320
+
321
+ # What each function binds: its formals, plus every name assigned anywhere
322
+ # inside it whose innermost enclosing function is that function.
323
+ binds <- new.env(parent = emptyenv())
324
+ add_bind <- function(node, name) {
325
+ key <- as.character(node)
326
+ cur <- if (exists(key, envir = binds, inherits = FALSE)) get(key, envir = binds) else character(0)
327
+ assign(key, unique(c(cur, name)), envir = binds)
328
+ }
329
+ # "Is this an assignment?" is a question about the token SEQUENCE, and the
330
+ # full table interleaves interior nodes with terminals — so the neighbour
331
+ # test runs over a terminal-only, source-ordered view while the ancestor
332
+ # walk keeps using the full tree. Mixing the two was a real bug: every
333
+ # binding resolved to the top level, which looks exactly like working code.
334
+ term <- pd[pd$terminal, , drop = FALSE]
335
+ term <- term[order(term$line1, term$col1), , drop = FALSE]
336
+ tok <- term$token
337
+ code <- which(tok != "COMMENT")
338
+ pos_in_code <- match(seq_len(nrow(term)), code)
339
+ next_tok <- function(i) {
340
+ k <- pos_in_code[[i]]
341
+ if (is.na(k) || k >= length(code)) "" else tok[[code[[k + 1L]]]]
342
+ }
343
+ prev_tok <- function(i) {
344
+ k <- pos_in_code[[i]]
345
+ if (is.na(k) || k <= 1L) "" else tok[[code[[k - 1L]]]]
346
+ }
347
+ for (i in seq_len(nrow(term))) {
348
+ is_formal <- identical(tok[[i]], "SYMBOL_FORMALS")
349
+ is_def <- identical(tok[[i]], "SYMBOL") &&
350
+ (next_tok(i) %in% c("LEFT_ASSIGN", "EQ_ASSIGN") ||
351
+ prev_tok(i) %in% c("RIGHT_ASSIGN", "SUPER_RIGHT_ASSIGN")) &&
352
+ !(prev_tok(i) %in% c("'$'", "'@'"))
353
+ if (!is_formal && !is_def) next
354
+ anc <- fn_ancestors(term$id[[i]])
355
+ if (!length(anc)) next # a top-level binding
356
+ # A formal belongs to ITS OWN function; a local assignment belongs to the
357
+ # innermost function containing it. Both are `anc[[1]]`, because a
358
+ # formal's ancestors start at the function it is declared on.
359
+ add_bind(anc[[1L]], term$text[[i]])
360
+ }
361
+
362
+ row_vec <- integer(max_id)
363
+ if (length(ids_pos)) row_vec[ids_pos] <- seq_len(nrow(pd))[pd$id > 0L]
364
+ vapply(ids, function(id) {
365
+ i <- row_vec[[id]]
366
+ name <- pd$text[[i]]
367
+ for (node in fn_ancestors(id)) {
368
+ key <- as.character(node)
369
+ if (exists(key, envir = binds, inherits = FALSE) &&
370
+ name %in% get(key, envir = binds)) return(as.integer(node))
371
+ }
372
+ 0L
373
+ }, integer(1))
374
+ }
375
+
376
+ #' Analyze one document: syntax diagnostics, and symbols when it parses.
377
+ #'
378
+ #' @param source The document text.
379
+ #' @return list(diagnostics, symbols, complete).
380
+ analyze_source <- function(source) {
381
+ src <- if (is.null(source)) "" else paste(as.character(source), collapse = "\n")
382
+ diags <- list()
383
+ symbols <- list()
384
+ refs <- list()
385
+ complete <- TRUE
386
+
387
+ exprs <- tryCatch(
388
+ parse(text = src, keep.source = TRUE),
389
+ error = function(e) e,
390
+ # A parse warning ("incomplete final line") is not a diagnostic users need.
391
+ warning = function(w) invokeRestart("muffleWarning")
392
+ )
393
+
394
+ if (inherits(exprs, "error")) {
395
+ msg <- conditionMessage(exprs)
396
+ pos <- parse_error_position(msg)
397
+ complete <- !is_incomplete(msg)
398
+ diags[[1L]] <- list(
399
+ severity = "error",
400
+ rule = "parse",
401
+ message = pos$text,
402
+ raw = msg, # the parser's own words, for details
403
+ # 1-based, CODE POINTS — see the header. `col` can be 0 when R points at
404
+ # end-of-input, and `line` can be one past the last line for the same
405
+ # reason; the consumer clamps against the real text.
406
+ unit = "codepoint",
407
+ line = pos$line, col = pos$col,
408
+ # v1 range: the point itself. The browser widens it to the token under
409
+ # that position with the tokenizer it already has (lib/r-highlight.js) —
410
+ # R cannot tell us the token's extent from an error message, and guessing
411
+ # a width here would underline the wrong text.
412
+ endLine = pos$line, endCol = if (is.null(pos$col)) NULL else pos$col + 1L,
413
+ incomplete = !complete
414
+ )
415
+ } else {
416
+ symbols <- top_symbols(exprs)
417
+ refs <- references(exprs)
418
+ }
419
+
420
+ list(diagnostics = diags, symbols = symbols, references = refs, complete = complete)
421
+ }
422
+
423
+ #' Every definition in the trusted `.R` files below a root.
424
+ #'
425
+ #' Stage 3, work item 3: workspace symbols. Three rules make this safe enough
426
+ #' to live in a process that is otherwise pure text analysis:
427
+ #'
428
+ #' BOUNDED. Depth, file count and file size are all capped. An analyzer that
429
+ #' walks an unbounded tree is a way to hang the supervisor's child on a home
430
+ #' directory, and "the user pointed at a big folder" is not an error case
431
+ #' worth crashing for.
432
+ #'
433
+ #' READ, NEVER RUN. Files are read and PARSED. `source()` appears nowhere in
434
+ #' this file and must not: a workspace scan that executed what it found would
435
+ #' run arbitrary code because someone opened a folder.
436
+ #'
437
+ #' ROOT-RELATIVE. Everything is reported relative to the root it was found
438
+ #' under, and nothing above the root is ever visited — the trusted-root rule
439
+ #' from lib/workdir.js, enforced where the reading happens rather than where
440
+ #' the asking happens.
441
+ #'
442
+ #' BOUNDED IN TIME, not only in size. The analyzer is single-flight: while a
443
+ #' scan runs it answers nothing else, so diagnostics, completion and hover for
444
+ #' the chunk being typed queue behind it. File, byte and match caps bound how
445
+ #' MUCH is read but not how LONG that takes — a deep tree of small files can
446
+ #' sit inside every count cap and still run past the client's timeout, which
447
+ #' is a hang with extra steps. `max_seconds` bounds the wall clock instead,
448
+ #' and stopping early sets the same `truncated` flag the count caps set. That
449
+ #' flag already means "this answer is incomplete" everywhere it is read:
450
+ #' navigation says the scan stopped at its limit, and folder rename REFUSES
451
+ #' (lib/refactor.js), so a partial scan can never produce a partial rename.
452
+ #'
453
+ #' @param root Directory to scan.
454
+ #' @param max_files,max_depth,max_bytes,max_seconds Caps.
455
+ #' @return list(files, symbols, truncated).
456
+ workspace_symbols <- function(root, max_files = 300L, max_depth = 4L,
457
+ max_bytes = 512000L,
458
+ max_seconds = ANALYZE_BUDGET) {
459
+ if (is.null(root) || !nzchar(root) || !dir.exists(root)) {
460
+ return(list(files = 0L, symbols = list(), truncated = FALSE,
461
+ error = "no such directory"))
462
+ }
463
+ # Wall clock, not the system clock: `proc.time()[["elapsed"]]` counts seconds
464
+ # since this R session started, so an NTP step or a DST change mid-scan cannot
465
+ # move the deadline. Read once here and compared, never re-based.
466
+ deadline <- proc.time()[["elapsed"]] + max_seconds
467
+ # `>=`, not `>`: elapsed never decreases, so a budget of 0 is already spent
468
+ # the instant it is set. That makes "scan nothing and say so" an exactly
469
+ # reachable state instead of one that depends on the clock's resolution.
470
+ over_budget <- function() proc.time()[["elapsed"]] >= deadline
471
+ base <- normalizePath(root, winslash = "/", mustWork = TRUE)
472
+ # Directories nobody wants indexed. Named rather than pattern-matched, so
473
+ # adding one is a decision someone can read.
474
+ skip <- c(".git", ".Rproj.user", "node_modules", "renv", "packrat",
475
+ ".venv", "__pycache__", ".quarto", "_site", "dist")
476
+
477
+ files <- character(0)
478
+ walk <- function(dir, depth) {
479
+ if (depth > max_depth || length(files) >= max_files || over_budget()) return(invisible(NULL))
480
+ entries <- tryCatch(list.files(dir, all.files = FALSE, full.names = TRUE,
481
+ no.. = TRUE), error = function(e) character(0))
482
+ for (e in entries) {
483
+ if (length(files) >= max_files || over_budget()) return(invisible(NULL))
484
+ if (dir.exists(e)) {
485
+ if (basename(e) %in% skip) next
486
+ walk(e, depth + 1L)
487
+ } else if (grepl("\\.(R|r)$", e)) {
488
+ files <<- c(files, e)
489
+ }
490
+ }
491
+ invisible(NULL)
492
+ }
493
+ walk(base, 1L)
494
+ truncated <- length(files) >= max_files || over_budget()
495
+
496
+ out <- list()
497
+ for (f in files) {
498
+ # Parsing dominates: one over-budget check per FILE, not per token, keeps
499
+ # the check itself off the hot path while still bounding the loop.
500
+ if (over_budget()) { truncated <- TRUE; break }
501
+ size <- tryCatch(file.info(f)$size, error = function(e) NA_real_)
502
+ if (is.na(size) || size > max_bytes) next
503
+ raw <- tryCatch(readBin(f, "raw", n = size), error = function(e) NULL)
504
+ txt <- if (is.null(raw) || any(raw == as.raw(0L))) NULL else
505
+ tryCatch(rawToChar(raw), error = function(e) NULL)
506
+ if (is.null(txt)) next
507
+ exprs <- tryCatch(parse(text = txt, keep.source = TRUE),
508
+ error = function(e) NULL, warning = function(w) invokeRestart("muffleWarning"))
509
+ if (is.null(exprs)) next # a file that does not parse
510
+ rel <- sub(paste0("^", gsub("([.|()\\^{}+$*?]|\\[|\\])", "\\\\\\1", base), "/?"), "", f)
511
+ for (sym in top_symbols(exprs)) {
512
+ sym$path <- f
513
+ sym$file <- rel
514
+ out[[length(out) + 1L]] <- sym
515
+ }
516
+ }
517
+ list(files = length(files), symbols = out, truncated = truncated, error = NULL)
518
+ }
519
+
520
+ #' Every parser-confirmed occurrence of one R name below a trusted root.
521
+ #'
522
+ #' Only matching documents carry source back to the browser. This keeps the
523
+ #' response bounded while giving rename a byte-for-byte precondition: a file
524
+ #' changed after this scan is refused before any write begins.
525
+ workspace_references <- function(root, name, max_files = 300L, max_depth = 4L,
526
+ max_bytes = 512000L, max_matches = 50L,
527
+ max_reply_bytes = 4194304L,
528
+ max_seconds = ANALYZE_BUDGET) {
529
+ if (is.null(root) || !nzchar(root) || !dir.exists(root)) {
530
+ return(list(files = 0L, documents = list(), truncated = FALSE,
531
+ error = "no such directory"))
532
+ }
533
+ want <- if (is.null(name)) "" else as.character(name)[[1L]]
534
+ if (!nzchar(want)) return(list(files = 0L, documents = list(), truncated = FALSE,
535
+ error = "name is required"))
536
+ # Wall clock, not the system clock: `proc.time()[["elapsed"]]` counts seconds
537
+ # since this R session started, so an NTP step or a DST change mid-scan cannot
538
+ # move the deadline. Read once here and compared, never re-based.
539
+ deadline <- proc.time()[["elapsed"]] + max_seconds
540
+ # `>=`, not `>`: elapsed never decreases, so a budget of 0 is already spent
541
+ # the instant it is set. That makes "scan nothing and say so" an exactly
542
+ # reachable state instead of one that depends on the clock's resolution.
543
+ over_budget <- function() proc.time()[["elapsed"]] >= deadline
544
+ base <- normalizePath(root, winslash = "/", mustWork = TRUE)
545
+ skip <- c(".git", ".Rproj.user", "node_modules", "renv", "packrat",
546
+ ".venv", "__pycache__", ".quarto", "_site", "dist")
547
+ files <- character(0)
548
+ walk <- function(dir, depth) {
549
+ if (depth > max_depth || length(files) >= max_files || over_budget()) return(invisible(NULL))
550
+ entries <- tryCatch(list.files(dir, all.files = FALSE, full.names = TRUE,
551
+ no.. = TRUE), error = function(e) character(0))
552
+ for (e in entries) {
553
+ if (length(files) >= max_files || over_budget()) return(invisible(NULL))
554
+ if (dir.exists(e)) {
555
+ if (basename(e) %in% skip) next
556
+ walk(e, depth + 1L)
557
+ } else if (grepl("\\.(R|r)$", e)) files <<- c(files, e)
558
+ }
559
+ invisible(NULL)
560
+ }
561
+ walk(base, 1L)
562
+ truncated <- length(files) >= max_files || over_budget()
563
+ bytes <- 0L
564
+ documents <- list()
565
+ for (f in files) {
566
+ if (over_budget()) { truncated <- TRUE; break }
567
+ size <- tryCatch(file.info(f)$size, error = function(e) NA_real_)
568
+ if (is.na(size) || size > max_bytes) next
569
+ raw <- tryCatch(readBin(f, "raw", n = size), error = function(e) NULL)
570
+ txt <- if (is.null(raw) || any(raw == as.raw(0L))) NULL else
571
+ tryCatch(rawToChar(raw), error = function(e) NULL)
572
+ if (is.null(txt)) next
573
+ # Reject before parsing. `references()` walks getParseData() for the whole
574
+ # file, so without this every `.R` under the root was parsed and walked on
575
+ # every lookup. A parsed reference to `want` requires the name to appear
576
+ # literally in the source, so a file not containing the substring cannot
577
+ # contain the symbol and skipping it changes no result.
578
+ if (!grepl(want, txt, fixed = TRUE)) next
579
+ exprs <- tryCatch(parse(text = txt, keep.source = TRUE),
580
+ error = function(e) NULL,
581
+ warning = function(w) invokeRestart("muffleWarning"))
582
+ if (is.null(exprs)) next
583
+ refs <- references(exprs)
584
+ hits <- Filter(function(ref) identical(as.character(ref$name), want), refs)
585
+ if (!length(hits)) next
586
+ if (length(documents) >= max_matches || bytes + nchar(txt, type = "bytes") > max_reply_bytes) {
587
+ truncated <- TRUE
588
+ break
589
+ }
590
+ rel <- sub(paste0("^", gsub("([.|()\\^{}+$*?]|\\[|\\])", "\\\\\\1", base), "/?"), "", f)
591
+ documents[[length(documents) + 1L]] <- list(
592
+ path = f, file = rel, source = txt, references = hits,
593
+ symbols = top_symbols(exprs))
594
+ bytes <- bytes + nchar(txt, type = "bytes")
595
+ }
596
+ list(files = length(files), documents = documents, truncated = truncated, error = NULL)
597
+ }
598
+
599
+ # ── command loop ───────────────────────────────────────────────────────────
600
+ # NDJSON in, framed JSON out. Every reply echoes the request's `id`, `uri` and
601
+ # `version` so a consumer can discard a stale answer by strict version equality
602
+ # without keeping a side table (stage-0-editor-adapter.md's stale rule).
603
+ con <- file("stdin", open = "rt", encoding = "UTF-8")
604
+ emit(list(type = "analyze-ready", pid = Sys.getpid(),
605
+ rversion = paste0(R.version$major, ".", R.version$minor)))
606
+
607
+ repeat {
608
+ line <- tryCatch(readLines(con, n = 1L, warn = FALSE),
609
+ interrupt = function(i) NA_character_)
610
+ if (length(line) == 0L) break # EOF: the supervisor left
611
+ if (length(line) != 1L || is.na(line) || !nzchar(line)) next
612
+ cmd <- tryCatch(jsonlite::fromJSON(line, simplifyVector = TRUE),
613
+ error = function(e) NULL)
614
+ if (is.null(cmd) || is.null(cmd$type)) next
615
+ type <- as.character(cmd$type)[[1L]]
616
+ id <- if (is.null(cmd$id)) NULL else as.character(cmd$id)[[1L]]
617
+
618
+ if (identical(type, "ping")) {
619
+ emit(list(type = "pong", id = id))
620
+ next
621
+ }
622
+ if (identical(type, "analyze")) {
623
+ res <- tryCatch(
624
+ analyze_source(cmd$source),
625
+ # A crash in analysis must not take the process down: the editor would
626
+ # lose diagnostics for the rest of the session over one odd document.
627
+ error = function(e) list(diagnostics = list(), symbols = list(),
628
+ references = list(), complete = TRUE,
629
+ failed = conditionMessage(e))
630
+ )
631
+ emit(list(type = "analyze", id = id,
632
+ uri = cmd$uri %||% NULL,
633
+ version = cmd$version %||% NULL,
634
+ diagnostics = res$diagnostics, symbols = res$symbols,
635
+ references = res$references, complete = res$complete,
636
+ failed = res$failed %||% NULL))
637
+ next
638
+ }
639
+ if (identical(type, "workspace")) {
640
+ res <- tryCatch(
641
+ workspace_symbols(if (is.null(cmd$root)) "" else as.character(cmd$root)[[1L]]),
642
+ error = function(e) list(files = 0L, symbols = list(), truncated = FALSE,
643
+ error = conditionMessage(e))
644
+ )
645
+ emit(list(type = "workspace", id = id, root = cmd$root %||% NULL,
646
+ files = res$files, symbols = res$symbols,
647
+ truncated = res$truncated, error = res$error %||% NULL))
648
+ next
649
+ }
650
+ if (identical(type, "workspace_references")) {
651
+ res <- tryCatch(
652
+ workspace_references(if (is.null(cmd$root)) "" else as.character(cmd$root)[[1L]],
653
+ cmd$name %||% ""),
654
+ error = function(e) list(files = 0L, documents = list(), truncated = FALSE,
655
+ error = conditionMessage(e))
656
+ )
657
+ emit(list(type = "workspace_references", id = id, root = cmd$root %||% NULL,
658
+ name = cmd$name %||% NULL, files = res$files,
659
+ documents = res$documents, truncated = res$truncated,
660
+ error = res$error %||% NULL))
661
+ next
662
+ }
663
+ # An unknown command is answered, not ignored: a consumer waiting on an id
664
+ # must never hang because it asked for something this build does not have.
665
+ emit(list(type = "analyze-error", id = id, message = paste0("unknown command: ", type)))
666
+ }
667
+
668
+ })