Skip to content

feat(r): extract R with tree-sitter and resolve calls corpus-wide - #2393

Open
fernando-duarte wants to merge 1 commit into
Graphify-Labs:v8from
fernando-duarte:feat/r-language-support
Open

feat(r): extract R with tree-sitter and resolve calls corpus-wide#2393
fernando-duarte wants to merge 1 commit into
Graphify-Labs:v8from
fernando-duarte:feat/r-language-support

Conversation

@fernando-duarte

Copy link
Copy Markdown

What

Adds R as an extracted language. .r/.R were already in CODE_EXTENSIONS, so R files were counted as code and then contributed nothing — R is the language the #1689 no-AST-extractor warning uses as its example, and that comment is updated here.

Why it is bespoke rather than a LanguageConfig

R has no named-function syntax. Every definition is an assignment whose right-hand side is an anonymous function_definition:

compute_moments <- function(x) { ... }
rescale = function(x) { ... }
report <- \(x) { ... }
(function(x) x^2) -> square_it

function_definition's only name field is the function keyword itself, so the config-driven walker labels all of them function. Right-assign needs care: function(x) x^2 -> nm binds nm inside the body (R deparses it as function(x) nm <- x^2), so only the parenthesised form is a function binding. The extractor and its fixture both follow R here.

Why calls resolve corpus-wide

Within a package or an analysis directory, R has one shared namespace and no import statement binding a name to a file. paste0(x) and a sibling file's compute_moments(x) are the same syntax, and nothing in the file distinguishes them — so a per-file extractor that emits both produces either a base-R god node or a flood of dangling edges.

extractors/r.py therefore resolves only same-file callees (those are certain) and emits everything else as raw_calls. r_resolution.resolve_r_calls, registered through resolver_registry, links a callee only when the corpus defines it exactly once — the same god-node guard the Java and Objective-C resolvers use. Anything defined nowhere is base R or a third-party package and is dropped.

That keeps base R out of the graph with no hardcoded list of ~1,300 base names, and emits no dangling edges.

Sourced files resolve the same way. A bare source("b.R") becomes imports; an R path literal joined from string literals by a project helper — source_once(paper_path("sub", "b.R")), the dominant shape in real analysis pipelines — becomes references. Both link only when the joined path matches exactly one corpus file, so a wrong separator guess cannot invent an edge.

Also covered

library/require/requireNamespace/loadNamespace and pkg::fn imports, obj$method() calls, nested definitions scoped to their enclosing function, top-level statements attributed to the file node (most of an analysis script), and Rscript shebangs.

Grammar dependency

The r-lib grammar has no standalone PyPI wheel, so it comes from tree-sitter-language-pack (~2 MB platform wheel) under a new optional [r] extra, hard-failing like tree-sitter-sql rather than falling back. get_language() returns a ready Language, unlike the per-grammar modules whose language() returns a raw pointer — noted in the code since it is an easy mis-port.

Measured

On a 541-file R corpus (an R package plus a 400-file analysis pipeline): 2,171 nodes, 7,434 edges, 0 dangling, 2.2 s. Relations: 4,312 calls, 1,393 defines, 927 references, 802 imports. Highest-degree targets are all real project functions — no base-R hub. Of 30,804 cross-file call sites, 6,466 resolve, 24,127 are dropped as undefined (base R, test_that, package calls), and 211 (0.7%) are dropped as ambiguous across 25 names.

Tests

tests/test_r.py — 14 tests covering every binding form, nested definitions, same-file calls, the absence of base-R nodes, imports, the no-dangling-edges invariant, and the four resolver paths (cross-file call, ambiguity guard, sourced file, wrapper-joined path, out-of-corpus path).

Two tests in tests/test_extract.py used .r as their example of a code extension with no extractor. They move to .ets, which is now one of the two that remain (.ejs, .ets). The subjects of those tests (#1689's warning and #1693's progress denominator) are unchanged.

Full suite: same 15 failures as the base commit 00efd6e (11 test_skillgen, 4 test_ollama_retry_cap — all pre-existing and unrelated), 3,906 passed.

.Rmd and .Rd are deliberately out of scope — they are in no extension set today, and each is a different format.

R was in CODE_EXTENSIONS but had no entry in _DISPATCH, so every .R file was
counted as code and then contributed nothing — it is the language Graphify-Labs#1689's
no-AST-extractor warning names as its example.

R has no named-function syntax: `f <- function(x)`, `f = \(x)` and
`(function(x) x) -> f` are all assignments binding an anonymous
function_definition, whose only `name` field is the `function` keyword itself.
The config-driven walker reads a name off the function node, so this is a
bespoke extractor.

Calls are resolved corpus-wide rather than per file. Within a package or an
analysis directory R has one shared namespace and no import statement binding a
name to a file, so `paste0(x)` and a sibling file's `compute_moments(x)` are the
same syntax and the file cannot tell them apart. The extractor emits unresolved
calls as raw_calls and graphify.r_resolution links a callee only when the corpus
defines it exactly once — the god-node guard the Java and ObjC resolvers use.
Everything else is dropped, so base R stays out of the graph without hardcoding
~1,300 base names and no dangling edge reaches build_from_json.

Sourced files resolve the same way: any R path literal, including one joined
from string literals by a helper (`source_once(paper_path("sub", "b.R"))`),
links only when it matches exactly one corpus file.

The r-lib grammar has no standalone PyPI wheel, so it comes from
tree-sitter-language-pack under a new optional [r] extra, hard-failing like
tree-sitter-sql rather than falling back.

Two tests in test_extract.py used .r as their example of a code extension with
no extractor; they move to .ets, which is now one of the two that remain.

Measured on a 541-file R corpus: 2,171 nodes and 7,434 edges in 2.2s, zero
dangling edges, and no base-R hub in the top-degree nodes.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

This PR adds R language extraction support to graphify. It introduces a new bespoke extractor (graphify/extractors/r.py) that handles R's assignment-based function definitions (<-/=/<<-/right-assign), lambdas, imports (library/require/pkg::fn), and source() calls, plus a companion r_resolution module that resolves calls corpus-wide against a shared namespace rather than per-file. It wires the extractor into the dispatch tables (.r/.R extensions and the Rscript shebang), registers the R resolver, adds an [r] optional extra, and updates the existing no-AST-extractor warning comments since R is no longer the example of an unsupported-but-counted language. The changeset also touches the changelog and a broad set of test files covering extraction, R resolution, and related behavior. I'm only describing intent and surface area; I haven't assessed whether the extraction/resolution logic is correct.

No blocking issues surfaced. 3 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1632 functions depend on the 694 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 365 callers, 29 callees
  • worse: _get_extractor() — 26 callers, 6 callees
  • new: extract_r() — 10 callers, 3 callees
  • new: walk_calls() — 0 callers, 8 callees
  • new: collect_definitions() — 0 callers, 6 callees

Verification — 1632 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1504 function(s) in the blast radius were not formally verified this run

· 3 grounded finding(s) anchored inline below; 2 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/extractors/r.py
return joined if joined.lower().endswith((".r",)) else None


def extract_r(path: Path) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_r()

10 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/extractors/r.py
return rhs, lhs
return None

def collect_definitions(node, scope_nid: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressioncollect_definitions()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/extractors/r.py
add_node(imp_nid, name, line)
add_edge(scope_nid, imp_nid, "imports", line, context="import")

def walk_calls(node, caller_nid: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionwalk_calls()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant