r-kernel-port-checklist.md 42 KB

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 syntaxlanguages/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). .rr 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.Rlanguage=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.rsr.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 docpkgimport 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 identifierextends 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_definitionANY 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") <- 8calls 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]];
    • returnreturn/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.