Files
codegraph/docs/design/r-kernel-port-checklist.md
T
b2f9ab1800 feat(kernel): R7b R walker — rlang module, tree-sitter-r 1.2.0 crate pin, r default-routed (#1383)
R7b batch 4 #1 (docs/design/r-kernel-port-checklist.md is the authoritative
quirk list; survey + probe record therein). The lightest-shared-surface,
heaviest-hook port: languages/r.ts works entirely through the visitNode hook
(every type list empty except callTypes:['call']), so the walker is a file
node + a faithful hook transcription + the generic extractCall + pre-order
recursion — four shared machineries (value-refs, static-member reads, type
annotations, fn-ref capture) are dead by language gates and stay dead.

Grammar prep is the first true no-op of the arc: the crates.io tree-sitter-r
1.2.0 tarball ships parser.c AND scanner.c sha-identical to the r-lib v1.2.0
tag the vendored wasm was built from — crate pin only, no wasm change, no
bump gate; kernel-grammar-parity gains the r row (ABI 14, same-revision).

Preserved bug-for-bug (all probe-pinned): calls "return" on every return(x)
(named node in v1.2.0), the import quintet's silent dynamic-arg consumption
vs class/generic fall-through asymmetry, library(help = pkg) importing the
named arg, class-idiom variable suppression by callee name, chained/right-
assign/precedence-ghost gaps, env$fn body-leak-to-file, raw-text callees
verbatim (pkg::fn, obj$meth, "strfn" quotes kept, (handler) conversion),
duplicate same-(kind,name,line) ids, roxygen dropped entirely, UTF-16
columns/slices.

Gates: parity sweeps first-run 0-diff on AnomalyDetection/dplyr/ggplot2/
shiny (838 files; deferrals exactly 0/0/0/1 — the 1 is the moustache-
template pseudo-R file, both-arm) — kernel-parity.mjs gained lowercased-
extension matching so .R files sweep (matches detectLanguage routing);
full-init dumps byte-identical kernel-vs-wasm on dplyr/ggplot2/shiny;
kernel-r-parity suite (torture fixture + in-memory CRLF + BOM variants +
defer pin + kernel-arm quirk pins); full suite 2,638 green ×2 with
CODEGRAPH_KERNEL_EXPECT=1. DEFAULT_ROUTED += r (16 langs).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:28:28 -05:00

632 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# R kernel port (R7b batch 4) — the bug-for-bug checklist
**Status: SURVEY COMPLETE (2026-07-20)** — no walker exists yet. Survey basis:
every TS-side branch a `.R`/`.r` file exercises, with file:line anchors as of
**`45a53eb`** (HEAD at survey time, clean main). Every grammar-shape claim was
probed against the production vendored wasm
(`src/extraction/wasm/tree-sitter-r.wasm`, r-lib v1.2.0), and every
extraction-behavior claim was pinned against the real `dist/` extractor
(`extract-*.txt` ground-truth dumps in the session scratchpad `svy-r/`
see §Probe artifacts) — not derived from code reading alone. Read WITH
`docs/design/rust-kernel-migration-plan.md` (§0a recipe, §2 boundary, §4
tracker row "r", §5 gates) and the format precedents
(`kotlin-kernel-port-checklist.md`, `swift-kernel-port-checklist.md`,
`ruby-kernel-port-checklist.md`).
**Blocking findings: none — and this is the LIGHTEST-shared-surface port so
far, but the HEAVIEST hook port.** Three structural facts shape everything:
1. **R has no declaration syntax**`languages/r.ts` (310 lines) works
ENTIRELY through the `visitNode` hook. Every type list except
`callTypes: ['call']` is EMPTY, so the generic dispatch ladder, extract-
Function/Method/Class/Enum/Interface/Variable/Field/Property/Import/
TypeAlias, `visitFunctionBody`, docstrings, decorators, inheritance,
instantiation — the entire shared extraction machine — **never runs**.
The walker is: file node + a faithful transcription of the hook + the
generic `extractCall` + pre-order recursion. Nothing else.
2. **R is in NONE of the cross-cutting language sets**: not in
`VALUE_REF_LANGS` (tree-sitter.ts:401), not in `STATIC_MEMBER_LANGS`
(:345-347), not in `TYPE_ANNOTATION_LANGUAGES` (:5752-5754), not in
`FN_REF_SPECS` (function-ref.ts:376-398 — no `r` key). Four whole
machineries (value-reference edges + the shadow prune, static-member
reads, type-annotation refs, function-as-value capture) are **dead** and
must STAY dead — the walker needs cheap early-outs that emit exactly
nothing.
3. **Grammar prep is csharp-style: crate pin, zero grammar change.** The
vendored wasm IS r-lib v1.2.0 and the crates.io `tree-sitter-r = "=1.2.0"`
tarball is sha-identical on BOTH generated artifacts (§Grammar prep). No
bump gate, no wasm re-vendor, no C vendoring.
Error incidence on the gate repos is **~0%** (ruby-class): any deferral on an
R sweep is a walker-bug signal, not grammar reality (§Error incidence).
## Grammar prep (crate pin — NO grammar change)
r IS already in `VENDORED_WASM_LANGS` (grammars.ts:292) and maps
`r: 'tree-sitter-r.wasm'` (grammars.ts:40). `.r``r` at EXTENSION_MAP
(grammars.ts:113); `detectLanguage` lowercases the extension (grammars.ts:473)
so **`.R` (the dominant real-world casing) resolves identically** — probed:
`extract.cjs smoke.R``language=r`. No content sniffing, no dialect.
(`.Rmd`/`.qmd`/`.Rprofile` are not in EXTENSION_MAP → `unknown` → never
extracted; out of scope.)
- **Provenance (verified, batch-4 grammar probe + this survey):** the
production wasm is **r-lib/tree-sitter-r v1.2.0** — ABI 14, 2,072 states,
135 node types (134 symbols + 1 alias), **20 fields** — positionally
identical to the tag's `src/parser.c`. The crates.io **`tree-sitter-r`
1.2.0** tarball ships BOTH generated artifacts sha-identical to the tag
(R has an external scanner):
- `src/parser.c` `622165734714c6e81f70d99d522d78cf98915fdf2b1032f8bb33e775a0b971c0`
- `src/scanner.c` `1209d1107616b470a4292c18d57b56b44990c39792acf7af6ccada0af563dbff`
Crate file list: `bindings/rust/{build.rs,lib.rs}`, `grammar.js`,
`queries/{highlights,locals,tags}.scm`, `src/{parser.c,scanner.c,
grammar.json,node-types.json}`, `src/tree_sitter/{alloc,array,parser}.h`,
`tree-sitter.json`, Cargo metadata.
- **Crate usability (kotlin-lesson check): USABLE.** `[dependencies]` is
`tree-sitter-language = "0.1"` only (`tree-sitter` 0.24.7 is a DEV-dep,
`cc` 1.1.22 a build-dep) — no pin conflict with the kernel's tree-sitter
0.25. Port route: **`tree-sitter-r = "=1.2.0"` in codegraph-kernel's
Cargo.toml** (csharp-style), `langs.rs` `"r" => tree_sitter_r::LANGUAGE`,
`LANGUAGES` 15→16.
- **v1.3.0 exists and is NOT this batch** (future accuracy PR only):
return-as-identifier (changes the `return` callee quirk's CST shape —
§extractCall), raw-string lexing split, and the **CRLF comment-node fix**
(v1.2.0 comment nodes INCLUDE the trailing `\r` — probed:
`"#' roxy for cf\r"` spans cols 0-15 on a CRLF file, `cst.cjs
crlf-crlf.R`). Extraction-invisible today (R reads no comments — §Docstrings)
but a bump would be a behavior change on three axes; treat it like any
grammar bump with its own gate, never fold it into the walker PR.
- **Field table (1-based, all 20):** alternative, argument, arguments, body,
close, condition, consequence, content, default, function, lhs, name, open,
operator, parameter, parameters, rhs, sequence, value, variable
(`table-dump.out`). **r.ts reads 9 of them**: `function`, `arguments`,
`value`, `name`, `operator`, `lhs`, `rhs`, `parameters`, `body` — all via
`childForFieldName` semantics (getChildByField, tree-sitter-helpers).
Unlike kotlin's zero-field world, R's field lookups are LIVE — the walker
uses real field IDs.
- **Named kinds that matter** (135 total, `table-dump.out`): `program`,
`binary_operator`, `unary_operator`, `call`, `subset` (`x[i]`), `subset2`
(`x[[i]]`), `arguments` (three positional kind ids 97/98/99, aliased to one
name — call/`[`/`[[` argument lists), `argument`, `function_definition`
(covers BOTH `function(x)` and the `\(x)` lambda — probed), `parameters`,
`parameter`, `identifier`, `string`, `string_content`, `escape_sequence`,
`namespace_operator` (`pkg::fn`, `pkg:::fn`), `extract_operator` (`x$y`
AND `x@y`), `braced_expression`, `parenthesized_expression`,
`if_statement`, `for_statement`, `while_statement`, `repeat_statement`,
`comment`, `comma`, literals (`integer`, `float`, `complex`, `true`,
`false`, `null`, `inf`, `nan`, `na`, `dots`, `dot_dot_i`), and **`return`,
`next`, `break` as NAMED nodes** (v1.2.0 — the source of the `calls
"return"` quirk below).
- `__tests__/kernel-grammar-parity.test.ts:39` `GRAMMAR_LANGUAGES += 'r'`
the id-by-id ABI/kind/field-table compare against the vendored wasm proves
crate and wasm are the same revision.
## Architecture decisions
1. **No preParse, no POST_PASSES.** `rExtractor` has no `preParse` hook
(languages/r.ts — whole file) → `preParsedSource` (kernel/index.ts:109) is
identity; no `POST_PASSES` entry → `tryKernelExtractRaw` stays eligible
(raw buffers-to-store transport on the bulk path).
2. **No framework resolver touches R.** Grep of `src/resolution/` finds `'r'`
only in name-matcher.ts (two RESOLUTION-side branches — §Frameworks); no
`frameworks/*.ts` lists r in `languages:` → parse-worker's
framework-force-to-decoded-path never fires for R. All parity repos ride
the raw path.
3. **One walker module** (suggest `codegraph-kernel/src/rlang.rs``r.rs`
works too; no crate-name collision since the dep is `tree-sitter-r`),
registered in langs.rs; per-file `has_error()``defer:` like every
walker. **No existing walker is a close crib** — the nearest are the
hook-branch parts of kotlin.rs/swift.rs, but R's walker is structurally
simpler: ONE visit function implementing §The hook, calling extract_call
for unconsumed `call` nodes, recursing namedChildren otherwise. There is
no class-stack (`pushScope` is explicit in the hook), no
isInsideClassLikeNode consult (nothing in the ladder needs it), no
receiver machinery, no modifiers/visibility/async/static (§Extractor
config — every hook absent).
4. **Deferral expectations: ~0%.** AnomalyDetection 0/11, dplyr 0/195,
ggplot2 0/357, shiny 1/275 (0.36% — one moustache-template pseudo-R file,
`inst/app_template/app.R`, `{{ … }}` placeholders; errors on BOTH arms).
Keep the sweep default `--max-deferral 0.1`; **any deferral count above
~1 on an R sweep is a walker-bug signal** (ruby precedent, not swift's).
The error battery (45 shapes, `err-battery.out`) found **NO phantom-error
class**: trailing commas in calls/lists, `\(x)` lambdas, underscore pipe
`f(y = _)`, raw strings `r"(...)"`/`r"#(...)#"`, BOM, CRLF, `x<-1`
spacing, empty file — all parse clean; `hasError` was true only for
genuinely-broken source (`x <-` MISSING node, stray `)` ERROR nodes).
Trust `has_error()` exactly as elsewhere.
5. **REF_FLAG_FILE_PATH (wire v2 slot) is NOT needed.** No R ref carries
`filePath` — the hook's imports/extends refs pass only {fromNodeId,
referenceName, referenceKind, line, column} (r.ts:198-204, 122-129,
138-145), and extractCall's calls refs likewise (tree-sitter.ts:4572-4580).
Verified across every ground-truth dump — zero refs printed a filePath.
`source("helpers.R")` resolves file-linking on the RESOLUTION side from
the ref's `imports` kind + name text (`helpers.R`), not from a filePath
field.
6. **Four dead machineries — reproduce the nothing.** (a) value-reference
edges: capture may run TS-side but `flushValueRefs` exits at the language
gate (tree-sitter.ts:784) → **zero `references` edges with
metadata.valueRef, ever** — the assignment shadow-prune (:803+) is
unreachable for R despite R being all-assignments; (b) static-member
reads: gate at :4751 (`STATIC_MEMBER_LANGS`) → nothing (R's
`extract_operator`/`namespace_operator` aren't in MEMBER_ACCESS_TYPES:323
anyway); (c) type annotations: gates at :1285/:5790/:6075 → nothing;
(d) function-as-value: `fnRefSpec` undefined → `maybeCaptureFnRefs`
(:585-595) and `scanFnRefSubtree` (:603-620) are no-ops → **zero
`function_ref` refs** — the post-hook `scanFnRefSubtree(node, 0)` at :951
does nothing for R. Also: **zero `instantiates`** (no R kind in
INSTANTIATION_KINDS:354-361 — `new("Patient")`/`Cls$new()` are plain
calls), **zero `decorates`**, **zero docstrings** (§Docstrings), **zero
`namespace` nodes** (no `packageTypes` → extractFilePackage:1397-1399
returns null), **zero import nodes from the generic path**
(importTypes=[] → :1209 never matches — R imports come ONLY from the
hook).
## Extractor config (languages/r.ts — 310 lines, read it whole)
Types: `functionTypes=[]`, `classTypes=[]`, `methodTypes=[]`,
`interfaceTypes=[]`, `structTypes=[]`, `enumTypes=[]`, `typeAliasTypes=[]`,
`importTypes=[]`, **`callTypes=['call']`**, `variableTypes=[]`.
`nameField='name'`, `bodyField='body'`, `paramsField='parameters'` — all
three are **DEAD** (read only by extractName/extractFunction-family and
createNode's `resolveBody?.()` endLine extension, none of which run for R:
no `resolveBody` hook, and function/method nodes are hook-created, so
createNode:1329-1334's extension is skipped by the optional-chain on the
absent hook).
Hooks ABSENT (the walker must NOT invent them): `preParse`, `resolveName`,
`resolveBody`, `classifyClassNode`, `classifyMethodNode`, `getVisibility`,
`isStatic`, `isAsync`, `isConst`, `isExported`, `extractModifiers`,
`extractImport`, `extractPackage`/`packageTypes`, `getReceiverType`,
`extractPropertyName`, `propertyTypes`, `fieldTypes`,
`extraClassNodeTypes`, `synthesizeMembers`, `skipBodilessClass`,
`methodsAreTopLevel`, `resolveTypeAliasKind`, `getReturnType`. Consequences
on every R node: **no visibility, no isStatic, no isAsync, no returnType,
no decorators, no docstring, no isExported** (except the file node's literal
`false`). The ONLY extras ever set: `signature` (functions/methods/imports —
see per-construct rules), and nothing else (`{}` for classes/variables/
constants).
Module-level constants (transcribe exactly):
- `ASSIGN_LEFT = {'<-', '<<-', '='}`; `ASSIGN_RIGHT = {'->', '->>'}`
(matched against the `operator` FIELD child's TEXT, r.ts:255).
- `IMPORT_FNS = {'library', 'require', 'requireNamespace', 'loadNamespace'}`
(+ `'source'` checked alongside, r.ts:189).
- `CLASS_FNS = {'setClass', 'setRefClass', 'R6Class', 'ggproto'}`.
- `GENERIC_FNS = {'setGeneric', 'setMethod'}`.
- `CONSTANT_NAME = /^[A-Z][A-Z0-9._]*$/` — ALL-CAPS/dotted-caps → `constant`,
else `variable`. Anchored full-match; `A.CONST` and `RIGHT.CONST` qualify,
`Gen`/`dotted.var` don't (probed).
Helper semantics (r.ts:46-98, the hook building blocks):
- **calleeName(call)**: `function` field → `identifier` → its text;
`namespace_operator` → its `rhs` field's text (**so `methods::setClass`,
`R6::R6Class`, `base::library` all trigger their special branches** —
pinned, `extract-classes2.txt`/`extract-imports.txt`); anything else
(`extract_operator`, `subset2`, `string`, `call`, `return`…) → null.
- **firstArgValue(call)**: the FIRST namedChild of `arguments` whose type is
`argument`, → its `value` field. **Named arguments are NOT skipped**:
`library(help = docpkg)` → the first argument IS `help = docpkg` → value
`docpkg`**import node "docpkg"** (pinned — bug, PRESERVE). Note the
`arguments` node's namedChildren include `comment` nodes in principle —
the type filter skips them.
- **literalOrIdentifier(node)**: `identifier` → text (INCLUDING backticks if
present); `string` → first `string_content` child's text, **`''` when the
string is empty** (`library("")``''` → falsy → consumed silently —
pinned); anything else (call, namespace_operator, TRUE, NULL…) → null.
- **emitMethodArg(entry)**: entry is an `argument` node; needs `name` field
present AND `value` field of type `function_definition`, else returns
silently (a positional `list(function() 1)` entry or `items = NULL` field
emits NOTHING — pinned). Creates
`createNode('method', nameText, entry, { signature: paramsText | undefined })`
**positioned at the ARGUMENT node** (`deposit = function(x)…` — L13 C4
in `extract-classes.txt`), signature = the `parameters` node's raw text
(`"(x)"`). Then `pushScope(method.id)`; **`ctx.visitNode(body)`** (the
full hook-aware walker — NOT visitFunctionBody); `popScope()`.
- **extractClassMembers(classCall, classId)**: iterate `arguments`'
namedChildren of type `argument` IN SOURCE ORDER, tracking a `positional`
counter for unnamed args:
- unnamed arg: `positional++`; **when positional===2 AND value is an
`identifier`** → `extends` ref {from: classId, name: value text, line/
col: the VALUE node} (ggproto's parent — `ggproto("GeomX", Geom, …)`
extends Geom; pinned). A string 2nd positional (`setClass("Patient",
"rep")`) emits nothing (identifier-only).
- named `inherit` or `contains`: value through literalOrIdentifier →
`extends` ref at the value node (R6 `inherit = AbstractCollection`
identifier, S4 `contains = "Person"` string — both pinned).
**`inherit = pkg::Parent` → literalOrIdentifier null → NO ref** (pinned,
`extract-classes2.txt` NoInherit).
- named arg whose value is a `function_definition` → emitMethodArg
(ggproto's direct-method style).
- named arg whose value is a `call` with calleeName `list` → iterate ITS
`arguments`' `argument` children → emitMethodArg each (R5 `methods =`,
R6 `public =`/`private =`/`active =` — ALL list-valued named args
qualify, name is not checked: `fields = list(balance = "numeric")`
contributes nothing only because the entries aren't function_definitions).
- everything else: skipped — **non-method argument subtrees are NEVER
visited**: `representation(name = "character")` inside setClass emits no
calls ref (pinned), `signature("Cls")` inside setMethod emits nothing
(pinned).
Emission order = argument order: `inherit` after `public` → the extends
ref lands AFTER the methods' body refs (pinned, `extract-leftovers2.txt`
Late: `calls p_call` precedes `extends LateBase`).
## The visitNode hook — reproduction semantics (the whole game)
**Where the hook runs**: `visitNode` (tree-sitter.ts:936) calls it FIRST for
**every node the main walker visits** (:943-953) — including the root
`program` node and every node reached by plain recursion (:1295-1301,
namedChildren in order) AND every node reached re-entrantly through the
hook's own `ctx.visitNode(body)` calls (makeExtractorContext:1465-1480 wires
`visitNode` back to the real walker). **`visitFunctionBody` (:5129+) NEVER
RUNS for R** — nothing reachable calls it (functionTypes/methodTypes empty,
no swift/TS branches, and r.ts uses `ctx.visitNode`, never
`ctx.visitFunctionBody`). Every "body walk" in R is the ordinary
hook-consulting walk — this is DELIBERATE (r.ts:260-266 comment): nested
definitions are assignments only the hook can recognize.
**When the hook returns true** (consumed): the dispatcher runs
`scanFnRefSubtree(node, 0)` — a **NO-OP for R** (no fnRefSpec) — and returns
WITHOUT descending. When false: `maybeCaptureFnRefs` (no-op), then the
ladder — for R only `callTypes` can match (:1248 → extractCall, **children
still recursed afterward** — no skipChildren), else plain recursion.
**Truth table — `call` nodes** (hook r.ts:183-252). `fname = calleeName(node)`;
null (dollar/subset/string/return/call callees) → **false** → extractCall +
recursion.
| fname | branch | consumed? | emits |
|---|---|---|---|
| `library`/`require`/`requireNamespace`/`loadNamespace`/`source`, first-arg value a string/identifier | import (:189-208) | YES | `import` node (name = module/file text; extra `{signature: full call text .trim().slice(0,100)}` — UTF-16 slice) + `imports` ref {from: nodeStack TOP (file or enclosing function/method/class), name: module, line/col: CALL node start}. Node parent = stack top (contains edge), QN scope-prefixed (`use_it::inside_fn` — pinned) |
| same fns, first-arg missing/dynamic/empty-string/non-literal | import (:191) | **YES — silently** | **NOTHING AT ALL** — no import, no `calls` ref, and the argument subtree is never visited: `library()`, `source(file.path("R","dyn.R"))` (the `file.path` call vanishes), `requireNamespace(quietly = TRUE, package = "namedpkg")` (TRUE is first → null → the real package is LOST), `library("")` — all pinned in `extract-imports.txt` |
| `setClass`/`setRefClass`/`R6Class`/`ggproto`, first-arg value a string/identifier | class (:211-221) | YES | `class` node at the CALL node (extra `{}` — NO signature key) + pushScope + extractClassMembers (§above) + popScope. **An IDENTIFIER first arg names the class from the identifier text**: `R6Class(GenName, …)` → class "GenName" (pinned) |
| same fns, first-arg missing/dynamic/NULL | class (:213) | **NO — falls through** | generic call: `calls ggproto` + full recursion into arguments — method-arg bodies are walked at the ENCLOSING scope with NO method nodes (`BadGG <- ggproto(NULL, Geom, draw_key = function(x) render_key(x))``calls ggproto` + `calls render_key` both from FILE — pinned). Asymmetry vs imports, PRESERVE |
| `setGeneric`/`setMethod`, first-arg literal/identifier | generic (:224-249) | YES | `function` node at the CALL node, name = first-arg text, signature = params text of the FIRST argument (any position) whose value is a `function_definition` (else undefined); body walk via pushScope + `ctx.visitNode(body)` + popScope when that impl exists. `setMethod("area","Sq",area_impl)` → function node only, identifier impl NOT visited (pinned). `setGeneric("area")` → bodiless function node (pinned) |
| same fns, first-arg non-literal | generic (:226) | NO | falls to generic call (`calls setGeneric` + recursion) |
| any other fname (incl. `setValidity`, `assign`, `delayedAssign`, `makeActiveBinding`, `do.call`, `match.fun`, `Recall`, `UseMethod`, `structure`) | — | NO | generic extractCall + recursion — NO symbol nodes (all pinned in `extract-edge.txt`; `assign("via_assign", 11)` mints NO variable — known gap, PRESERVE) |
**Truth table — `binary_operator` nodes** (hook r.ts:254-306). `op` = the
`operator` field child's TEXT; missing → false (cannot occur).
| shape | branch | consumed? | emits |
|---|---|---|---|
| `op ∈ ASSIGN_LEFT` AND lhs `identifier` AND rhs `function_definition`**ANY scope** | fn (:267-279) | YES | `function` node AT THE binary_operator (name = lhs text WITH backticks — `` `%+%` `` pinned; extra `{signature: parameters raw text or undefined}`) + pushScope + `ctx.visitNode(body field)` + popScope. Nested defs stack QNs: `nester::inner::innermost`; fires inside `if`/braces/bodies too (`f_in_if`, `braced_fn`, `local_fn` — all pinned, attributed to the nodeStack top which is still the FILE for statement-level nesting) |
| `parent.type === 'program'` AND `op ∈ ASSIGN_LEFT` AND lhs `identifier` AND rhs present (non-function) | var (:284-296) | YES | UNLESS rhs is a `call` whose calleeName ∈ CLASS_FNS GENERIC_FNS (the class-idiom suppression): `variable`/`constant` node (CONSTANT_NAME on the lhs text) at the binary_operator, extra `{}`. **Then `ctx.visitNode(rhs)` ALWAYS runs** (suppressed or not) → rhs calls/imports/classes extract with the FILE on top: `ans <- helper(x)` → variable + `calls helper` from file; `Account <- setRefClass("Account", …)` → class node ONLY (no variable — pinned); `BadGG <- ggproto(NULL, …)` → **NEITHER node** (suppression checks the callee NAME, the class branch then declines on the NULL — pinned) |
| `parent.type === 'program'` AND `op ∈ ASSIGN_RIGHT` AND rhs `identifier` AND lhs present | right-var (:298-303) | YES | variable/constant from RHS text + `ctx.visitNode(lhs)`. `(function(x) x*3) -> trpl` → variable trpl, NO function node (the paren'd function_definition recurses without a hook match — pinned) |
| anything else (`<-` with call/subset/string/`$`/`@` lhs, non-top-level value assigns, `:=`, `%>%`, `\|>`, `~`, arithmetic…) | (:305) | NO | plain recursion — rhs/lhs calls extract at the current scope; no nodes. Pinned: `x[1] <- 7` nothing; `attr(x,"who") <- 8` → `calls attr` (lhs IS a call node); `obj$field <- 9` nothing; `"strassign" <- 6` nothing; `env$attached <- function(x) side_call(x)` → **NO function node, `calls side_call` leaks to FILE scope** (the function_definition recurses, body walked at stack top — pinned, common R idiom, known gap); `dt[, b := compute_b(a)]` → `calls compute_b` from file |
**Precedence trap (probed, r.ts:13-16 comment is accurate):**
`function(y) y - 1 -> ghost` parses as `function_definition` whose BODY is
`(y-1) -> ghost` — top level sees a bare function_definition → no hook
match anywhere → **NOTHING extracted** (pinned). Chained
`chain_a <- chain_b <- 5`: rhs of the outer is a binary_operator (not
function_definition, rhs truthy) → **only `chain_a` minted**; the inner
`chain_b <- 5` is visited via `ctx.visitNode(rhs)` but its parent is the
OUTER binary_operator, not `program` → skipped (pinned).
**All other node types** return false immediately (:308) — `program`,
`braced_expression`, `if/for/while/repeat`, `function_definition` (bare),
`extract_operator`, `subset`/`subset2`, literals, comments — plain
recursion. Consequences pinned: top-level `{ hidden_var <- 42 }` loses the
variable (parent is braced_expression) but keeps a nested `braced_fn <-
function()` (fn branch is scope-free); for/while/if conditions and bodies
emit their calls at the enclosing scope.
## extractCall (tree-sitter.ts:3684) — the R paths
Entry gates: not vbnet/erlang (:3698/:3746), not ruby/arkts. R `call` nodes
reach the generic tail: `func = getChildByField(node, 'function') ??
node.namedChild(0)` (:4313) — **the `function` FIELD is always present** for
R calls (subset/subset2 carry the same fields but are not callTypes). The
cpp operator recovery (:4324) is language-gated off.
- **The member branch (:4364) is UNREACHABLE**: R func types are
`identifier`, `namespace_operator`, `extract_operator`, `call`, `subset`/
`subset2`, `string`, `parenthesized_expression`, `function_definition`,
`return` — none is in the member list
(member_expression/attribute/selector_expression/navigation_expression/
field_expression), none is scoped_identifier, csharp branch is gated. So
LITERAL_RECEIVER_TYPES, SKIP_RECEIVERS, the #750 re-encode, and receiver
logic NEVER fire for R.
- **Everything lands in the else (:4518-4520): calleeName = RAW func text**
(UTF-16 substring, verbatim — internal whitespace/newlines included).
Pinned callee texts (`extract-edge.txt`):
- bare `helper`; namespace `pkg::fn_q`, `pkg:::fn_h` (kept qualified —
resolution matches them via the `^(\w+)::(\w+)$` colonMatch);
- dollar `obj$meth`, chained `lst$a$b`, mixed slot `o@s$m` (feeds
name-matcher's rDollarMatch `^([\w.]+)\$(\w+)$`);
- call-of-call ``Negate(`%in%`)`` (raw inner text, backticks kept) — plus
the INNER `calls Negate` from post-extractCall recursion (both at their
own node starts);
- `match.fun("fun_by_name")` + inner `match.fun` (same pattern);
- string callee `"strfn"` — **QUOTES INCLUDED**;
- subset callee `lst[[1]]`;
- **`return`** — `return`/`next`/`break` are named nodes; `return(g(x))`
emits `calls "return"` + `calls "g"` (pinned; HIGH VOLUME — every
R file). Reproduce byte-for-byte.
- **The parenthesized-conversion regex (:4529-4532) IS live**: `(handler)(8)`
→ func text `(handler)` matches `/^\(\s*\*?\s*([A-Za-z_][\w.]*)\s*\)$/` →
rewritten to **`handler`** (pinned). JS `\s` ⊇ `\r` — Rust `\s` parity
holds. An IIFE `(function(x) x)(1)` does NOT match (inner text isn't a
plain name) → raw text callee `(function(x) x)`; `(\(x) x)(1)` likewise —
backslash isn't in the regex's first class.
- cpp template-strip (:4542) and fn-ptr fan-out (:4556) are c/cpp-gated —
no.
- Final ref (:4572-4580): {fromNodeId: nodeStack top, referenceName,
referenceKind `calls`, line: CALL node startRow+1, column: CALL node
startColumn (UTF-16)}. **No skipChildren** → inner calls of chains/args
are ALSO visited (each gets the hook first — a nested
`suppressPackageStartupMessages(library(quietpkg))` emits `calls
suppressPackageStartupMessages` THEN `import quietpkg` at the inner call's
position C31, pinned).
- Pipes: `%>%` (operator token kind `special`) and `\|>` are plain
binary_operators → no ref for the pipe itself; each rhs `call` node emits
normally (`x %>% p_one() %>% p_two()` → calls p_one C6 + p_two C18 from
the enclosing scope, pinned). The piped LHS argument is invisible —
`$`-dispatch/pipe-flow resolution is a known runtime-semantics gap
(r.ts:31-34 doc comment).
## Node creation, IDs, qualified names, order
- createNode (:1308): id = `generateNodeId(filePath, kind, name,
startRow+1)` = `` `${kind}:${sha256(`${filePath}:${kind}:${name}:${line}`).hex.slice(0,32)}` ``
(tree-sitter-helpers.ts:18-30). **Name keeps backticks** (`` `weird
name` ``, `` `%+%` ``). The endLine body-extension (:1329-1334) is dead
(no resolveBody hook) — endLine = the anchor node's endRow+1 (functions:
the binary_operator; hook classes/generics: the call; methods: the
argument; imports: the call).
- **Duplicate IDs are legal and must be reproduced**: two same-(kind, name,
line) symbols (`f <- function() 1; f <- function() 2` on one line) push
TWO node rows with the IDENTICAL id, differing only in columns (pinned,
`leftovers.R --json`). No dedupe anywhere in extraction; the store upserts
later. The parity harness compares the RAW arrays — emit both.
- File node (:508-521): id `file:${filePath}`, name basename, qualifiedName
= filePath **verbatim as passed** (relative or absolute — pinned
`gate-repos/ggplot2/R/geom-point.R`), endLine =
`source.split('\n').length` (CRLF-safe — \r\n contains \n), startColumn/
endColumn 0, isExported false. **The only node with isExported set.**
- qualifiedName = buildQualifiedName (:1447-1460): nodeStack names joined
`::`, file kind skipped, namespacePrefix always empty for R. Top-level →
bare (`top_fn`); nested → `nester::inner::innermost`, `Account::deposit`,
`factory::LocalCls::lm`, `use_it::inside_fn` (all pinned). NOTE
buildQualifiedName resolves stack ids through `this.nodes.find` — walker
equivalent: maintain the name stack alongside the id stack.
- contains edge from stack top for EVERY created node (:1363-1372), pushed
interleaved with node creation. extractModifiers merge (:1355) — hook
absent, no-op. captureValueRefScope (:1374) — flush-gated dead (§arch-6).
- **Emission order contract** (the harness is rowid-order-sensitive): one
pre-order walk; per construct — node row, then its contains edge, then
(for classes) extractClassMembers output in ARGUMENT ORDER (extends refs
and method nodes interleave chronologically; a method's body refs flush
before a later argument's extends ref — pinned by Late), then (for
fn/generic nodes) the body walk's refs. There is NO end-of-file flush for
R (fn-ref and value-ref flushes emit nothing) — nodes[], edges[], refs[]
are each strictly walk-ordered.
## Signatures — the only extra
- Function/generic nodes: raw `parameters` text — parens included, defaults/
`...`/newlines verbatim (`"(a, b = 2, ...)"`;
`"(\n x,\n y,\n by = NULL,…)"` pinned on dplyr/join.R). Absent
parameters → the `signature` key is passed with value `undefined` (wire:
NONE — indistinguishable from omitted; keep it NONE).
- Method nodes: same, from the entry's function_definition.
- Import nodes: `getNodeText(call).trim().slice(0, 100)` — **UTF-16 slice of
the WHOLE call text** including `base::` qualifiers and interior newlines;
a >100-unit call truncates mid-text (multi-line `library(\n crlfpkg\n)` →
`"library(\n crlfpkg\n)"`, and under CRLF `"library(\r\n crlfpkg\r\n)"`
— the ONLY LF↔CRLF extraction difference, §CRLF).
- Class/variable/constant nodes: NO signature key (`{}`).
## Docstrings — roxygen is DROPPED entirely
`getPrecedingDocstring` (tree-sitter-helpers.ts:95+) is called ONLY inside
extractFunction/Method/Class/… — none of which run for R; the hook's
createNode calls pass no docstring. **No R node ever carries a docstring**
— `#'` roxygen blocks, plain `#` comment runs, everything is invisible
(pinned: torture.R's roxygen'd `top_fn` and comment-run'd `eq_fn` both have
doc=undefined). The walker needs no comment handling at all — and must NOT
add any. (Roxygen-as-docstring would be an accuracy improvement for a later
TS+kernel PAIRED change, never walker-side alone.)
## CRLF & encoding
- **End-to-end probe** (`extract-crlf-lf.txt` vs `extract-crlf-crlf.txt`):
identical nodes/edges/refs/lines/columns; the ONLY byte difference is
`\r\n` inside a multi-line import signature. Comment nodes include the
trailing `\r` at grammar level (v1.2.0) — extraction-invisible for R.
The parity suite derives CRLF variants in-memory (kernel-tsjs-parity
pattern); both arms parse the same bytes with the same grammar revision,
so parity holds by construction — assert it anyway.
- **Columns are UTF-16 code units** (pinned `extract-utf16.txt`:
`after_emoji("🎉🎉", target_fn())` → target_fn at C47 — each emoji counts
2). All getNodeText slices and the signature `.trim().slice(0,100)` are
UTF-16 (textutil::col16/slice_utf16 in the kernel).
- No regexes over multi-line source run for R except the
parenthesized-conversion (single-name, `\s` semantics identical in Rust
regex) — no `(?m)` hazards, no docstring.rs use.
## tree-sitter.ts anchor table (45a53eb)
| Path | Lines | R status |
|---|---|---|
| extract() wrap: file node, package, walk, flushes | 454-577 | live; extractFilePackage:1397 returns null (no packageTypes); both flushes no-op |
| visitNode hook dispatch + consumed-scan | 936-953 | live; scanFnRefSubtree no-op (:604 `!this.fnRefSpec`) |
| pascal/cpp-namespace/fn-ref capture | 957-990 | all no-op for r |
| dispatch ladder | 994-1292 | ONLY :1248 callTypes matches; everything else falls through |
| recursion | 1295-1301 | namedChildren in order — the walk R actually uses |
| createNode / findChildByTypes / extractFilePackage / buildQualifiedName / makeExtractorContext | 1308-1480 | live (§Node creation) |
| isInsideClassLikeNode / isClassScopeConstantAssignment | 1486-1516 | never consulted for r (ladder rows that use them don't match) |
| extractFunction…extractTypeAlias, extractImport, extractVariable/Field/Property | 1517-3236 | ALL DEAD for r |
| extractCall | 3684, 4313, 4518-4532, 4572-4580 | live (§extractCall) |
| extractInstantiation | 4610+ | dead (no kinds) |
| extractStaticMemberRef | 4750-4808 | dead (:4751 gate) |
| extractDecoratorsFor | 4897+ | dead (no callers run) |
| visitFunctionBody | 5129-5286 | NEVER RUNS |
| extractInheritance | 5595+ | dead (hook emits extends itself) |
| extractTypeAnnotations / extractVariableTypeAnnotation | 5788+, 6074+ | dead (gates) |
| flushFnRefCandidates / flushValueRefs | 622-728, 777-931 | no-op (no spec / :784 language gate) |
## Frameworks & synthesis consumers (stay TS-side — pin the contract)
- **No framework resolver, no synthesizer, no closure-collection** touches R
(CC_LANGUAGES = {swift, kotlin}; no expect/actual; no R entry in any
`frameworks/*.ts` languages list). Nothing to smoke-test on the decoded
path — R always rides raw buffers.
- **name-matcher.ts consumes two R ref/node shapes** (resolution-side, no
port — but the walker's output feeds them):
- :1521 `rDollarMatch = /^([\w.]+)\$(\w+)$/` on `calls` ref names — the
`obj$meth` raw-text encoding above is load-bearing (receiver/method
split for #1108 local-receiver inference). Backticked or `@`-containing
callees deliberately don't match.
- :1235 local-receiver type inference regex `` `\b${r}\b\s*(?:<-|<<-|=)\s*([A-Z][\w.]*)\$new\b` `` — reads raw SOURCE lines, not extraction
output; only node names/positions feed it.
- The `^(\w+)::(\w+)$` colonMatch (shared) consumes `pkg::fn` callee
texts.
- `source("helpers.R")` linking rides the ordinary `imports` ref
(name = `helpers.R`) through import resolution — same store input from
either arm.
## Error incidence & gate repos
Swept with the production wasm (`error-sweep.cjs`, all `.R`/`.r` ≤1 MiB):
| Repo | files | hasError | rate | notes |
|---|---|---|---|---|
| twitter/AnomalyDetection | 11 | 0 | 0.00% | the #839 small bench; pure base R |
| tidyverse/dplyr | 195 | 0 | 0.00% | medium; S3-heavy, 1,262 native pipes, S4 (3 setClass/3 setMethod), 2 R6Class, 25 `<<-` |
| tidyverse/ggplot2 | 357 | 0 | 0.00% | **the ggproto exerciser** — 239 ggproto sites |
| rstudio/shiny | 275 | 1 | 0.36% | **the R6 exerciser** — 33 R6Class, 9 source(), 480 `<<-`; the 1 error is `inst/app_template/app.R`, a moustache template (both-arm) |
**Gate repos: dplyr (small-medium), ggplot2 (medium, ggproto), shiny
(medium-large, R6)** — between them: S3 dotted functions, S4, R6, ggproto,
pipes both spellings, source(), heavy `<<-`. AnomalyDetection as the
30-second smoke (11 files). Rejected: r-lib/devtools (thin wrappers, no
distinctive shapes). **Flagged ambiguity: `setRefClass` count is 0 across
all four repos** — RefClass parity rides the fixture suite only; if the
maintainer wants a live RefClass sweep, add a Bioconductor repo
(e.g. Bioconductor/S4Vectors — also deepens S4 setMethod density) as an
optional 4th, not a gate.
Sweep policy: default `--max-deferral 0.1`; expect deferral counts of
**0/0/1** — anything more is a walker bug (ruby-style signal, §arch-4).
## Parity mechanics (all have bitten before)
- **Emission order** per §Node creation — single walk, no flushes.
- **generateNodeId inputs**: (filePath, kind, name, startRow+1) — name with
backticks; import nodes named the module text (`helpers.R` keeps its
extension); class nodes named from the STRING CONTENT (no quotes) or
identifier text; duplicate ids pushed verbatim.
- **UTF-16 columns/slices** everywhere (§CRLF).
- **`.R` vs `.r`**: detectLanguage lowercases — both route to r; the parity
sweep script's extension filter must catch BOTH casings (kernel-parity.mjs
— verify before first sweep; `error-sweep.cjs` uses `/\.[rR]$/`).
- **Defer policy**: `has_error()` → `defer:` — no phantom class exists
(§arch-4) but trust the flag regardless; wasm recovery is canonical for
the two genuinely-broken shapes (MISSING-node incompletes, stray-paren
ERRORs).
- **The `undefined`-signature nuance**: r.ts passes `signature: undefined`
explicitly for parameterless generics — object-identical to omission in
JSON/store/wire; emit NONE.
- **No node ever carries**: docstring, visibility, isStatic, isAsync,
isExported (except file:false), returnType, decorators. A walker that
"helpfully" fills any of these breaks byte parity.
## Gates (per plan §5, no exceptions)
- **No grammar-bump gate** (crate pin, no wasm change — first R7b language
with a true no-op grammar prep; kernel-grammar-parity `+= 'r'` is the
entire grammar proof).
- **Torture fixtures** per `## Fixtures to build`, exercised by a new
`__tests__/kernel-r-parity.test.ts` (CRLF variants derived in-memory).
- **Parity sweeps** (`scripts/kernel-parity.mjs <dir>`, order-sensitive
full-object, default `--max-deferral 0.1`): the survey clones live at the
session scratchpad `svy-r/gate-repos/{AnomalyDetection,dplyr,ggplot2,
shiny}` — re-clone fresh public OSS if gone (agent-eval policy). Expect
0-diff on every file and deferrals 0/0/0/1.
- **Full-init dump-diffs byte-identical** (kernel arm vs `CODEGRAPH_KERNEL=0`,
`scripts/dump-graph.mjs`, cmp) on dplyr, ggplot2, shiny ×3 runs.
- **Existing R suite stays green**: `__tests__/extraction.test.ts` R block
(~:9080-9213 — detectLanguage, functions, classes, imports/source,
constants, ggproto/geom-point shapes) runs through the same seam and must
pass under routing.
- **`DEFAULT_ROUTED += 'r'`** (kernel/index.ts:37) only after ALL of the
above; changelog rides the existing kernel entry.
- Post-route perf sanity: raw path everywhere (§arch-2), ~0% deferral —
expect the full kernel speedup on ggplot2/shiny (275-357 files each).
## Fixtures to build
Seed from the survey's probe fixtures + their pinned dumps (torture/classes/
classes2/imports/edge/leftovers/leftovers2/crlf/utf16 — §Probe artifacts).
One consolidated `torture.R` should cover, by branch:
1. **Assignments**: all five ops minting nodes (`<-`, `=`, `<<-` fns +
vars; `->`, `->>` vars); CONSTANT_NAME split (`MAX_RETRIES`, `A.CONST`,
`RIGHT.CONST` vs `lower_var`, `dotted.var`, `x2`); backtick names
(`` `weird name` `` var, `` `%+%` `` operator-function); chained
`a <- b <- 5` (only a); paren'd right-assign fn (`trpl` var only); the
precedence ghost (`function(y) y-1 -> ghost` → nothing); string-lhs,
subset-lhs (nothing), call-lhs (`attr(x,…) <- 8` → calls attr),
`$`/`@`-lhs (nothing incl. the `env$fn <- function` body-leak-to-file),
`:=` (data.table rhs calls), `assign()`/`delayedAssign()` (plain calls).
2. **Functions & scopes**: `\(x)` lambda node; 3-deep nesting
(`nester::inner::innermost` QNs + contains chain); fn-assignment inside
`if` and inside a bare top-level `{ }` (extracted, file-scope) vs the
sibling `hidden_var` (lost); `local({ … })` (calls local + inner fn
extracted, inner var lost); local right-assign (`fetch() -> got` →
calls fetch only); same-line duplicate `f`/`f` (duplicate ids); dots
params `(a, b = 2, ...)` signature; multi-line signature.
3. **Imports**: all four IMPORT_FNS + source() (node+ref shapes, QN
scoping in a function); the FIVE silent-consumption shapes (`library()`,
dynamic source(file.path(…)), named-first requireNamespace, `library("")`,
dynamic library(f())); `base::library` qualified; `library(help = docpkg)`
named-first bug; nested `suppressPackageStartupMessages(library(x))`
(calls-then-import order); sig truncation ≤100 + multi-line sig.
4. **Classes**: S4 setClass (+ representation invisibility + contains
string); setGeneric with/without def; setMethod (+ signature() call
invisibility + identifier-impl no-walk + duplicate `describe` name);
setRefClass (fields ignored, methods list, `<<-` field writes inside
methods, contains); R6Class (public/private/active all emit methods,
`items = NULL` ignored, inherit identifier, inherit AFTER public for ref
ordering, `inherit = pkg::Parent` dropped, empty `public = list()`);
ggproto (name+parent positionals, direct method args, non-fn args
skipped); qualified constructors (`methods::setClass`, `R6::R6Class`);
identifier-named class (`R6Class(GenName, …)`); the ggproto(NULL,…)
fall-through (calls + file-scope body leak, NO var); class in a function
body (`factory::LocalCls::lm`); class-idiom var suppression
(`Account <- setRefClass("Account"…)` → class only).
5. **Calls**: bare; `pkg::fn`/`pkg:::fn`; `obj$meth`/`lst$a$b`/`o@s$m`;
call-of-call (``Negate(`%in%`)`` outer+inner); `match.fun("x")(3)`;
string callee `"strfn"`; subset callee `lst[[1]]`; `(handler)(8)`
conv-regex rewrite; IIFE raw-text callee; **`return(g(x))` → calls
return + g**; `next`/`break` (nothing); pipes `%>%` and `|>` (rhs calls
only, enclosing scope); if/for/while/repeat bodies; call args'
nested-call recursion; `switch` arms.
6. **CRLF variant** derived in-memory (multi-line import sig is the only
byte delta) + a roxygen/comment file (all doc=undefined) + the UTF-16
line (emoji before/inside a call, column pins).
7. **Defer fixture**: genuinely-broken source (`x <-` incomplete — MISSING
node) — kernel defers, wasm output byte-served; and the parse-clean
battery shapes (trailing commas, raw strings, underscore-pipe, BOM) in
the MAIN fixture to prove they DON'T defer.
## Probe artifacts (session scratchpad `svy-r/`)
`cst.cjs` (CST dumps against the production wasm, fields + anon fielded
children), `extract.cjs` (dist TreeSitterExtractor ground truth, rowid
order), `table-dump.cjs` + `table-dump.out` (ABI/state/kind/field tables),
`err-battery.cjs` + `err-battery.out` (48 shapes — no phantom class),
`error-sweep.cjs` + `error-sweep.out` (gate-repo hasError rates + the shiny
template file), fixtures `torture.R`, `classes.R`, `classes2.R`,
`imports.R`, `edge.R`, `leftovers.R`, `leftovers2.R`, `smoke.R`,
`crlf-lf.R`/`crlf-crlf.R`, `utf16.R` with pinned dumps
`extract-{torture,classes,classes2,imports,edge,leftovers2,crlf-lf,
crlf-crlf,utf16}.txt`, gate-repo clones under `gate-repos/`, and the crate
material at `../crates/tree-sitter-r-{1.2.0,1.3.0}/` +
`../r-tag-1.2.0-{parser,scanner}.c` (shas in §Grammar prep; full batch-4
probe record `../batch4-grammar-probe.md`). Scratch is throwaway —
re-derive from this doc if gone.