This commit is contained in:
Mick Cooney 2026-10-12 12:13:19 +01:00 • committed by GitHub
commit fca26eec09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
271 changed files with 17163 additions and 70 deletions

View file

@ -69,6 +69,8 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
"tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"),
"tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"),
"tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"),
# Scoped npm package: the only grammar whose name does not start with "tree-sitter-".
"@eagleoutice/tree-sitter-r": ("r-lib/tree-sitter-r", "main", "src/parser.c"),
"tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"),
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
# Vendored parsers — kept here so the upstream coords for drift
@ -162,13 +164,15 @@ def read_pinned_grammar_versions() -> dict[str, str]:
Looks at both runtime and optional dependencies. Returns the raw range
string (e.g. '0.21.4', '^0.23.0', 'file:./vendor/...') so the report can
expose how flexible each pin is.
expose how flexible each pin is. A grammar is recognised by its package
basename, so a scoped package (``@scope/tree-sitter-x``) is included, keyed
by its full name.
"""
pkg = _load_package_json()
pinned: dict[str, str] = {}
for section in ("dependencies", "optionalDependencies"):
for name, spec in (pkg.get(section) or {}).items():
if name.startswith("tree-sitter-"):
if name.rsplit("/", 1)[-1].startswith("tree-sitter-"):
pinned[name] = spec
return pinned
@ -182,7 +186,7 @@ def read_patched_peers() -> list[tuple[str, str, str]]:
"""
script = GITNEXUS_DIR / "scripts" / "prepare-tree-sitter-bundle.cjs"
return re.findall(
r"\['(tree-sitter[\w-]*)@([\d.]+)',\s*'([^']+)'\]",
r"\['((?:@[\w.-]+/)?tree-sitter[\w-]*)@([\d.]+)',\s*'([^']+)'\]",
script.read_text(encoding="utf-8"),
)

View file

@ -38,8 +38,10 @@ _spec.loader.exec_module(readiness) # type: ignore[union-attr]
# The exact row-diff regex the workflow's change-detection bot uses
# (.github/workflows/tree-sitter-upgrade-readiness.yml) — byte-identical so a matrix
# format change that would silently break change-detection fails here. Group 2 is
# ONLY the Status cell ([^|]+? before the final `|$`).
_ROW_DIFF_RE = re.compile(r"\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
# ONLY the Status cell ([^|]+? before the final `|$`). Group 1 is the grammar
# name, optionally scoped (`@eagleoutice/tree-sitter-r`).
_R_GRAMMAR = "@eagleoutice/tree-sitter-r"
_ROW_DIFF_RE = re.compile(r"\| `((?:@[^/`]+/)?tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
# Mirrors the scheduled issue-update summary extraction in
# tree-sitter-upgrade-readiness.yml. If the report prose changes again, the issue
# comment should not silently degrade to "?/? ready. ? blocker(s)".
@ -216,6 +218,93 @@ class AssertCurrent(TestCase):
self.assertIn("outside current runtime range", buf.getvalue())
class AssertCurrentCoversR(TestCase):
"""R is an npm grammar under a scoped name; --assert-current must check its
installed ABI against the current runtime's range like any other grammar."""
def test_out_of_range_r_abi_fails_the_gate_and_names_r(self):
real = readiness.extract_language_version
def fake(parser_c):
return 99 if "tree-sitter-r" in parser_c.parts else real(parser_c)
import urllib.request
buf = io.StringIO()
with mock.patch.object(readiness, "extract_language_version", side_effect=fake), \
mock.patch.object(urllib.request, "urlopen", side_effect=AssertionError("network")), \
contextlib.redirect_stdout(buf):
code = readiness.assert_current()
self.assertEqual(code, 1)
self.assertIn(f"{_R_GRAMMAR}: installed ABI 99 outside current runtime range", buf.getvalue())
class ScopedGrammarName(TestCase):
"""The R grammar's package name is scoped (`@eagleoutice/tree-sitter-r`); every
place that recognises a grammar by a `tree-sitter-` prefix must cope with it."""
def test_pinned_versions_include_the_scoped_grammar(self):
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text())
pinned = readiness.read_pinned_grammar_versions()
self.assertEqual(pinned.get(_R_GRAMMAR), pkg["dependencies"][_R_GRAMMAR])
# Selection is by grammar basename: the runtime and non-grammar scoped
# dependencies are still excluded.
self.assertNotIn("tree-sitter", pinned)
self.assertNotIn("@ladybugdb/core", pinned)
def test_r_row_shows_its_pin_and_is_a_blocker_not_a_bump_candidate(self):
# Production-faithful R metadata: npm latest 1.1.2 (what package.json pins) with peer ^0.21.0 — blocks 0.25 and is NOT behind latest.
def fake_npm(pkg: str):
if pkg == _R_GRAMMAR:
return {"version": "1.1.2", "peerDependencies": {"tree-sitter": "^0.21.0"}}
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
def fake_fetch(url: str, timeout: int = 8):
if "parser.c" in url and "alex-pinkus" not in url:
return "#define LANGUAGE_VERSION 14\n"
if "/commits/" in url:
return json.dumps({"sha": "0123456789abcdef"})
return None
buf = io.StringIO()
with mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm), \
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch), \
contextlib.redirect_stdout(buf):
code = readiness.main()
report = buf.getvalue()
row = next(l for l in report.splitlines() if l.startswith(f"| `{_R_GRAMMAR}` |"))
cells = [c.strip() for c in row.strip().strip("|").split("|")]
self.assertEqual(cells[1], "1.1.2") # Pinned
self.assertEqual(cells[-1], "Peer patch required")
self.assertEqual(code, 1)
self.assertIn(f"`{_R_GRAMMAR}@1.1.2` — peer `^0.21.0`", report) # Blocked bucket
# On latest already: not a bump candidate (pre-fix the lost pin made it
# one). Other grammars are mocked at 9.9.9 and so do appear in that list;
# only R's own line is asserted absent.
self.assertNotIn(f"- `{_R_GRAMMAR}`: ", report)
self.assertNotIn(f"`{_R_GRAMMAR}` — pinned", report)
def test_row_diff_regex_captures_scoped_and_unscoped_names(self):
rows = (
"| `@eagleoutice/tree-sitter-r` | ^1.1.2 | 1.1.2 | ^0.21.0 | **No** | 14 | 14 | Peer patch required |\n"
"| `tree-sitter-go` | ^0.23.0 | 0.25.0 | ^0.25.0 | Yes | 14 | 14 | Ready |\n"
)
self.assertEqual(
dict(_ROW_DIFF_RE.findall(rows)),
{"@eagleoutice/tree-sitter-r": "Peer patch required", "tree-sitter-go": "Ready"},
)
def test_row_diff_regex_mirror_matches_the_workflow(self):
# The workflow's change-detection regex is the one that matters; the
# mirror above is only worth anything while it is the same expression.
workflow = (
_REPO_ROOT / ".github" / "workflows" / "tree-sitter-upgrade-readiness.yml"
).read_text(encoding="utf-8")
match = re.search(r"md\.matchAll\(/(.+)/gm\)", workflow)
self.assertIsNotNone(match, "row-diff regex not found in the workflow")
# JS regex literals escape `/`; Python patterns do not.
self.assertEqual(match.group(1).replace("\\/", "/"), _ROW_DIFF_RE.pattern)
class FetchHelperReadPhaseErrors(TestCase):
"""Read-phase transport failures — raised by resp.read() AFTER urlopen has
returned (ConnectionResetError, ssl.SSLError, socket.timeout,
@ -326,6 +415,17 @@ class ReportRendering(TestCase):
self.assertNotIn("Could not check", self.report)
self.assertNotIn("fetch failed", self.report)
def test_r_grammar_is_listed_npm_queried_and_diffable(self):
# R is an npm dependency, so it takes the npm path (queried, not vendored)
# and its row must be captured by the change-detection regex despite the
# scoped package name.
self.assertEqual(
readiness.GRAMMARS.get(_R_GRAMMAR),
("r-lib/tree-sitter-r", "main", "src/parser.c"),
)
self.assertIn(_R_GRAMMAR, _render_report.last_npm_calls)
self.assertIn(_R_GRAMMAR, self.rows)
def test_held_swift_renders_held_and_keeps_exit_nonzero(self):
# Status is the last matrix cell (the row-diff regex captures the whole
# tail, not just status, so read the cell directly).
@ -361,17 +461,19 @@ class ReportRendering(TestCase):
self.assertIsNotNone(ready)
self.assertIsNotNone(blockers)
# Counts are derived from _render_report()'s mock corpus (all npm peer
# deps mocked permissive): of the 10 npm-installed grammars, 9 render
# deps mocked permissive): of the 11 npm-installed grammars, 10 render
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
# not counted ready. The remaining workarounds are that intentional pin
# and Swift's declaration-recovery hold.
self.assertEqual(ready.groups(), ("9", "10"))
self.assertEqual(ready.groups(), ("10", "11"))
self.assertEqual(blockers.group(1), "2")
def test_lists_every_pack_time_peer_patch_with_its_drop_condition(self):
patched = readiness.read_patched_peers()
# Guards the AUDITED_PEERS regex: the bundle script patches 8 manifests today.
self.assertGreaterEqual(len(patched), 8)
# Guards the AUDITED_PEERS regex: the bundle script patches 9 manifests
# today, including the scoped R grammar.
self.assertGreaterEqual(len(patched), 9)
self.assertIn((_R_GRAMMAR, "1.1.2", "^0.21.0"), patched)
self.assertIn(f"## Pack-time peer patches ({len(patched)})", self.report)
for name, version, _ in patched:
self.assertIn(f"- `{name}@{version}`", self.report)
@ -382,6 +484,8 @@ class ReportRendering(TestCase):
self.assertIn("required while intentionally pinned", line("tree-sitter-cpp@0.23.4"))
self.assertIn("transitive", line("tree-sitter-javascript@0.23.1"))
self.assertIn("transitive", line("tree-sitter-c@0.23.6"))
# The scoped R grammar is a direct pin, so its patch is listed like any other.
self.assertIn("can drop: bump to `9.9.9`", line(f"{_R_GRAMMAR}@1.1.2"))
def test_target_runtime_is_the_bundled_pin(self):
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text())

View file

@ -176,7 +176,8 @@ jobs:
// `|$`), so change-detection fires on status transitions, not on
// unrelated cell drift (e.g. an upstream-ABI bump). Mirror this in
// _ROW_DIFF_RE in test_check_tree_sitter_upgrade_readiness.py.
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$/gm)) {
// The grammar name may be scoped (`@eagleoutice/tree-sitter-r`).
for (const m of md.matchAll(/\| `((?:@[^\/`]+\/)?tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$/gm)) {
map[m[1]] = m[2].trim();
}
return map;

View file

@ -1,7 +1,7 @@
<!-- version: 1.17.0 -->
<!-- Last updated: 2026-09-24 -->
<!-- version: 1.18.0 -->
<!-- Last updated: 2026-10-07 -->
Last reviewed: 2026-09-24
Last reviewed: 2026-10-07
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
@ -40,6 +40,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
- **Objective-C provider work:** read **[docs/languages/objective-c-provider.md](docs/languages/objective-c-provider.md)** before changing Objective-C parsing or resolution.
- **R provider work:** read **[docs/languages/r-provider.md](docs/languages/r-provider.md)** before changing R parsing or resolution.
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP rules in `gitnexus:start` block below.
@ -91,6 +92,7 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
| Date | Version | Change |
|------|---------|--------|
| 2026-10-07 | 1.18.0 | Added the R provider guide as the required reference before changing R parsing or resolution. |
| 2026-09-24 | 1.17.0 | Clones with the same `origin` URL now share a store automatically; `--no-share` records a lasting opt-out (#3352). |
| 2026-09-24 | 1.16.0 | Documented the shared worktree index store (`<GITNEXUS_HOME>/stores/`, `analyze --share-with`, `GITNEXUS_SHARED_STORE=off`) in the storage notes (#3352). |
| 2026-09-07 | 1.15.0 | Added the Objective-C provider guide as the required reference before changing Objective-C parsing or resolution. |

View file

@ -377,7 +377,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
## Language-agnostic graph feeding
18 languages → single unified graph. Four abstraction layers:
19 languages → single unified graph. Four abstraction layers:
```
Unified Graph Schema (44 node types, 21 relationship types)
@ -404,8 +404,10 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
| `mroStrategy` | `first-wins` / `c3` / `none` |
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
| `postParse` | Optional. Whole-graph pass a language runs once after every chunk is merged and before scope resolution (deferred owner resolution, manifest-driven export refinement). Called once per analyze, awaited, only when the language has parsed files; a throw aborts the parse phase. R: deferred `setMethod`/R6 owners + NAMESPACE exports. |
| `resolveMemberOwnerNode` | Optional. For languages whose members are owned by a call/assignment rather than an enclosing container node (R: R6/RefClass): returns the node the method and field extractors read members from, or null. Consulted by the enclosing-owner slot after the container walk and `resolveFileTypeOwner` (same slot; Zig file-owner is the precedent). Not an S4 mechanism: R's `setMethod(...)` returns null (its owner is a string-literal class name, not a node); S4 ownership arrives only through the `ownerNameHint` → `postParse` path. |
18 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
19 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
### Unified capture tags

View file

@ -722,9 +722,12 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
| Objective-C | ✓ | — | ✓ | ✓ | ✓ | — | — | — | — |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
| R (experimental) | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions, S4 classes, R6 classes and RefClass (`setRefClass`) classes with their methods and fields, and resolves `library()` / `require()` / `source()` imports to local packages and files, `obj$method()` and `pkg::fn()` calls, R6 `inherit=` / S4 `contains=` heritage and NAMESPACE-driven export status. Calls through function values, `do.call()`, infix operators and S3 dispatch are not linked, and `.Rmd` files, `box::use()` and external (CRAN) packages are not supported. See [docs/languages/r-provider.md](docs/languages/r-provider.md) for the full coverage and limits.
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.
### Multi-Repo Architecture

View file

@ -0,0 +1,121 @@
# R Language Provider
Status: implemented (experimental)
The provider is covered by unit and integration tests, listed under [Tests](#tests). It is classified as experimental in the language support table. This status describes what is implemented today; it does not promise R's dynamic dispatch.
## Scope
GitNexus indexes `.r` and `.R` files with the `@eagleoutice/tree-sitter-r` grammar and resolves them through the scope-resolution pipeline (see ARCHITECTURE.md, Scope-Resolution Pipeline). R-specific logic lives in `gitnexus/src/core/ingestion/languages/r/`, with the per-language extractor and import-resolver files beside those of the other languages. Shared ingestion code does not name R; the provider plugs in through `LanguageProvider` and `ScopeResolver` hooks.
R has no class syntax: classes are function calls (`R6Class(...)`, `setClass(...)`, `setRefClass(...)`) and package exports live in a NAMESPACE file rather than in the source. That is why the provider uses two optional hooks, `postParse` (attach each member to its owner and refine export status once every file is parsed) and `resolveMemberOwnerNode` (name the owner of a member from a call rather than an enclosing node).
## Supported constructs
Definitions:
- Functions defined by assignment (`<-`, `=`, `<<-`). S3 methods appear as ordinary functions with dotted names such as `print.foo`.
- S4 classes (`setClass`), generics (`setGeneric`), methods (`setMethod`) and slots (`representation`, `slots`).
- R6 classes with their methods, fields and active bindings.
- RefClass (`setRefClass`) classes with their methods and fields.
- The argument that names an S4 or RefClass class, a generic or a method is the argument spelled with the first formal (`Class=`, `name=`, `f=`) wherever it sits, else the first unnamed argument (so a later `Class=` takes the formal from an earlier unnamed argument, as in R). Other strings (`contains=`, `valueClass=`, a `setMethod` signature) create no node.
Types:
- `x <- Type$new()` (R6, RefClass) and `x <- new("Class")` (S4) type `x`. Resolution of `x$method()` is asserted by tests for R6 only; the S4 `new()` binding is unit-tested only.
- roxygen2 `@param` types in a restricted form: the description is the type alone (`#' @param name Type`) or starts with it in braces (`#' @param name {Type} prose`), the type is an R6, S4 or RefClass class defined in the same file, and the name is a formal of the function the block documents. The type is bound inside that function only. Prose that begins with a capitalised word binds nothing. Other roxygen2 tags (`@return`, `@field`, `@inheritParams`, `@rdname`, `@noRd`, `@export`) are not read.
Imports:
- `library()` and `require()` resolve to local packages, and `source()` to local files. The argument is `package=` / `file=` wherever it sits, else the first unnamed argument after any named one; named-first arguments such as `lib.loc=` and `local=` are not imports.
- NAMESPACE `importFrom()` names bind to definitions in local packages. A name that can never bind (not exported, not defined at top level, or dotted) creates no import, so its calls keep the `global-name-fallback` guess.
Calls:
- Free calls, `obj$method()` calls (receiver typed as above), R6 `self$method()` calls, and calls inside native pipe (`|>`) chains.
- Named nested functions (closures) are indexed, and calls inside attribute to the nested function, which can shadow a top-level name. Top-level script calls outside any function are attributed to the File node.
- `pkg::fn()` and `pkg:::fn()`, decided by the qualifier (see [Resolution model](#resolution-model)).
Heritage:
- R6 `inherit=` and S4 `contains=` (a single class or `c(...)`). `contains = "VIRTUAL"` links to one placeholder class named `VIRTUAL`.
Exports:
- Read from each local package's NAMESPACE: `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()`. `S3method(generic, class, method)` exports `method`, the function R registers, else `generic.class`. A `DESCRIPTION` file with a `Package:` line marks a package root.
## Resolution model
The scope query (`languages/r/query.ts`) captures three call shapes only: `f(x)`, `pkg::f(x)` and `obj$f(x)`.
- A bare call binds lexically first. `library()` and `require()` of a local package make every top-level name of that package visible, unfiltered by NAMESPACE. `source()` binds no name; a call into a sourced file, or into another file of the same package, binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`). An ambiguous name yields no edge.
- A bare call never binds to a dotted definition, so `foo(x)` cannot reach `print.foo`. Dotted (S3-style) names are never import-resolved; a call that spells the full dotted name binds by qualified-name lookup. Dotted names are not bound through `importFrom()`.
- `pkg::name()` is decided by its qualifier:
- `pkg` is a package of the repository and defines `name` exactly once: the call binds to that definition at confidence 0.85 (`import-resolved`).
- `pkg` is certainly not a repository package (discovery completed and found no such package, including a repository with no `DESCRIPTION`): no edge.
- `pkg` defines `name` more than once: no edge.
- Discovery may have missed a package (its limits were reached): the call keeps the name-based behaviour.
- `::` and `:::` are not distinguished, so `pkg::name` binds to a definition whether or not the package exports it.
- Package discovery is bounded to depth 3 and 200 directories (when the bound may have hidden a package this is flagged internally and nothing is logged), reads directories in code-unit order, ranks duplicate package names by shallowest then by whole path, and never searches directories named exactly `renv`, `packrat`, `revdep` or `<name>.Rcheck`. The shared file scanner has no such ignore entries, so `.R` files inside those trees are still parsed even though discovery skips them.
- Export status: the language-level export check treats every symbol as public. The NAMESPACE pass then marks the remaining `R/` symbols of a NAMESPACE-bearing package non-exported. Symbols outside `R/` (`tests/`, `vignettes/`, `inst/`) are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them. A package without a NAMESPACE stays fully public, and a nested package without one does not inherit its enclosing package's. roxygen2 `@export` tags are not consulted.
- `exportPattern()` text comes from the analysed repository, so patterns are compiled to a linear-time automaton (`linear-regex.ts`) with a work budget. Constructs with no linear-time algorithm (back-references, look-around) and oversized patterns are dropped with one logged warning per pattern per package. Character classes use ASCII semantics.
## Limits and known gaps
Not edges or nodes:
- A function passed as a value (`lapply(x, f)`, `Map`, `lapply(x, pkg::f)`), dynamic calls (`do.call("f", ...)`, `match.fun()`, `get()`) and dispatch through a registry (`handlers[["a"]]()`) create no CALLS edge, because only the three call shapes above are captured; the cross-language callable-value analysis classifies R as not applicable.
- R6 `super$method()` and `private$method()` calls, S4 slot access (`obj@slot`), and `UseMethod()` / `NextMethod()` create no edge.
- Anonymous functions (`function(x)`, `\(x)`) passed as arguments are not indexed as definitions; calls inside are attributed to the enclosing function, or to the File at top level.
- Infix operators (`a %op% b`) are not linked to their definitions.
- Constants and other top-level non-function assignments, and environments (`new.env()`, `local()`, `assign()` / `get()`), are not nodes; the only value-like nodes are R6, RefClass and S4 fields and slots. `setValidity()`, `setClassUnion()`, `setReplaceMethod()` and `Class$methods(...)` create no definition or edge.
- No framework or entry-point detection (Shiny, plumber, targets, testthat). Plumber `#*` annotations are not read and no route nodes are created, and no route or fetch edges link R to other languages.
Not supported:
- The magrittr pipe `%>%` has no specific support and none is claimed; the native pipe `|>` is supported. tidyverse non-standard evaluation (bare column names, `.data$x`) is not modelled.
- `.Rmd` and `.qmd` files are not indexed (the provider handles `.r` and `.R` only; `.Rprofile` and `.Renviron` are not indexed either), and `box::use()`, `import::from()`, `loadNamespace()`, `attachNamespace()` and `sys.source()` imports are not read.
- S3 dispatch is not resolved: S3 methods are indexed as functions only. S4 and RefClass dispatch is not resolved either; there are definitions and heritage, not dispatch edges.
- NAMESPACE `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read. Imports of external (CRAN) packages produce no edges.
- S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker; basic-type bases such as `"numeric"` add no edge.
- Package discovery is bounded (depth 3, 200 directories), so a package beyond the bound is treated like an unknown one. A package found only under `renv`, `packrat`, `revdep` or `<name>.Rcheck` is not a repository package.
- A `pkg::name` whose package does not itself define `name` (for example a re-export) can still bind to a same-file or `importFrom` candidate, a `::` to a name the package does not export still binds to its definition, and `pkg::name` used as a value is not qualifier-resolved. Call sites dropped for certainly-external packages or duplicate definitions leave no recorded outcome.
- When discovery may have missed a package, a `pkg::name` call keeps the name-based behaviour even if the candidate sits in a different package.
- Files in `R/` subdirectories are treated as package-owned, although R itself does not load them.
- NAMESPACE is read from disk during analysis, but neither it nor `DESCRIPTION` is a parsed source file. After an edit to either alone, an incremental analyse (no `--force`) keeps stale manifest-derived data: the persisted `isExported` of symbols in unchanged `R/` files, a `CALLS` edge bound through an `importFrom()` whose target changed, and a `pkg::fn` edge after a `Package:` rename. A full analyse is correct and `analyze --force` recomputes it. This is a limitation of the shared incremental write path (only changed files are re-processed), not of R resolution; other language manifests that feed resolution can be affected in the same way. It is to be addressed in a follow-up.
- Roxygen `@param` types live in the per-file type environment; no graph edge consumes them.
- Argument matching follows R for named and reordered arguments but does not model partial argument names: `setClass(Cla = "D")`, `new(Cla = "E")`, `setRefClass(Cla = "A")` and `setMethod("show", sig = "Foo")` are not recognised, so no definition or owner is attached.
- Members are read only from the named forms. The positional second argument of `setClass("A", representation(x = "numeric"))`, `R6Class("X", list(...))` and `setRefClass("A", list(...))` creates no field or slot nodes; use `slots=` / `representation=`, `public =` / `private =` / `active =` and `fields =`.
- For `S3method(generic, class, method)` the positional third argument names the exported method. A named third argument such as `S3method(print, foo, method = impl)` is not read: R registers `impl`, but the export is recorded for `print.foo`.
- A leading `]` in a bracket expression of a NAMESPACE `exportPattern()` (for example `[]a]` or `[^]a]`) is read with JavaScript RegExp semantics, not POSIX/TRE, unless the pattern also contains a POSIX class such as `[:digit:]`; R would treat that `]` as a literal member.
### Bounds on NAMESPACE exportPattern matching
- Matching work and NFA states are counted in deterministic work units, never wall-clock time, so the outcome is the same on every machine. A NAMESPACE file is still read in full.
- Caps, in `linear-regex.ts`: `MAX_TOTAL_WORK` (40,000,000 state visits per matcher), `MAX_SHARED_WORK` (100,000,000 matching visits per load), `MAX_PACKAGE_WORK` (30,000,000 per package), `MAX_SHARED_STATES` (500,000 NFA states per load) and `MAX_PACKAGE_STATES` (100,000 per package); a single pattern is also capped by `MAX_PATTERN_LENGTH` (16,384 characters) and `MAX_STATES` (16,384).
- A pattern that does not fit is dropped, exports nothing and is warned about: at most 50 patterns are named per package (`MAX_LISTED_DROPPED_PATTERNS`), followed by a summary line counting the rest.
- The first pattern refused for lack of states closes that package's allowance, so later patterns in the package are refused without being parsed.
- One hostile package cannot exhaust the load budget alone (it is limited to its own share), but several can (three, for matching work); packages processed after that degrade, with warnings. Which package degrades follows graph node order.
- Explicit `export()` names are unaffected by these bounds.
## Type annotations
R has no inline type annotations, so the "Type Annotations" column of the language support table is not ticked for R. The roxygen2 `@param` extraction above is a comment-based fallback, in the same class as the JSDoc, PHPDoc and YARD fallbacks listed in type-resolution-system.md (Ruby's YARD support is likewise not ticked).
## Extending the provider
- Definition captures are in `R_QUERIES` (`tree-sitter-queries.ts`); scope, import, type-binding, heritage and reference captures are in `languages/r/query.ts`. Keep each declaration pattern and its matching `@scope.function` pattern in lockstep, as the comments in `query.ts` explain.
- The captures for `setClass`, `setRefClass`, `setGeneric`, `setMethod` and for `library` / `require` / `source` are deliberately loose. The argument that names the definition or the import is chosen afterwards in `languages/r/naming-argument.ts`, because a query-level prefix that skips leading comments and named arguments is quadratic in the argument count.
- Resolution wiring is in `languages/r/scope-resolver.ts`, registered in `SCOPE_RESOLVERS`. Package discovery and NAMESPACE parsing are in `package-config.ts` and `namespace-imports.ts`; qualified calls in `qualified-call.ts`; owner and export post-processing in `post-parse.ts` and `owner-hooks.ts`.
- Changing what the scope query captures changes what cached parsed files contain, so raise `SCHEMA_BUMP`.
- Shared code under `gitnexus/src/core/ingestion/` must not name languages; add behaviour through the provider hooks.
## Tests
Unit tests are under `gitnexus/test/unit/` (`r-*.test.ts`, `type-env-r.test.ts` and `scope-resolution/r/`) and integration tests under `gitnexus/test/integration/resolvers/` (`r*.test.ts`), with `r-*` fixture projects in `gitnexus/test/fixtures/lang-resolution/`. They cover:
- definitions, owners and the naming-argument rules (`r-definition-anchors`, `r-owner-hooks`, `r-post-parse-owners`, `r-scope-naming-argument`, `r-s4-anchors`, `r-s4-formals`, `r-scope-args`);
- NAMESPACE parsing, exports and `exportPattern()` safety and scale (`r-namespace-parser`, `r-namespace-exports`, `r-export-pattern*`, `r-export-status-*`);
- package discovery and root-package imports (`r-package-discovery-*`, `r-import-root-package`, `r-namespace-imports`);
- qualified calls, dotted S3 names and native pipes (`r-qualified-*`, `r-dotted-s3-collision`, `r-native-pipes` fixture in `r.test.ts`);
- roxygen2 `@param` types (`type-env-r`, `r-roxygen-param`).

View file

@ -53,6 +53,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.R]: ['.r'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
[SupportedLanguages.Zig]: ['.zig'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
@ -127,6 +128,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.R]: 'r',
[SupportedLanguages.Cobol]: 'cobol',
[SupportedLanguages.Zig]: 'zig',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness

View file

@ -21,6 +21,7 @@ export enum SupportedLanguages {
Swift = 'swift',
Dart = 'dart',
Vue = 'vue',
R = 'r',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
Zig = 'zig',

View file

@ -16,6 +16,7 @@
*
* Added after Ring 1: zig enters as `experimental` (new language
* integration; promotion to `production` is a separate governance PR).
* Same for r, added later.
*/
import { SupportedLanguages } from '../languages.js';
@ -47,6 +48,7 @@ export const LanguageClassifications: Readonly<Record<SupportedLanguages, Langua
[SupportedLanguages.Vue]: 'experimental',
[SupportedLanguages.Cobol]: 'experimental',
[SupportedLanguages.Zig]: 'experimental',
[SupportedLanguages.R]: 'experimental',
};
/** Convenience predicate: is this language gating Ring 4 retirement? */

View file

@ -461,7 +461,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Zig
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Zig, R
### Language Feature Matrix
@ -482,9 +482,12 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
| R (experimental) | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions, S4 classes, R6 classes and RefClass (`setRefClass`) classes with their methods and fields, and resolves `library()` / `require()` / `source()` imports to local packages and files, `obj$method()` and `pkg::fn()` calls, R6 `inherit=` / S4 `contains=` heritage and NAMESPACE-driven export status. Calls through function values, `do.call()`, infix operators and S3 dispatch are not linked, and `.Rmd` files, `box::use()` and external (CRAN) packages are not supported. See [docs/languages/r-provider.md](../docs/languages/r-provider.md) for the full coverage and limits.
## Agent Skills
GitNexus ships with skill files that teach AI agents how to use the tools effectively:

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
* — over ONE shared corpus so the arms are directly comparable. One arm per
* registered language, plus a second `csharp` arm carrying csproj configs
* (#2902), so there is one more arm than there are languages. The newest row
* is `zig` (PR #1432), added the day its resolver registered — the inventory
* arm below is what noticed it missing, which is the arm doing its job.
* is `r`, which registered without a row and was reported by the inventory arm
* below — the arm doing its job — after `zig` (PR #1432), which did the same.
*
* NO LANGUAGE IS OMITTED, and that is the point of the list rather than an
* accident of it. Nine of these arms (go, csharp, csharp_csproj, dart, ruby,
@ -127,6 +127,19 @@
* path-walking resolver alone and does not move when that config parsing
* changes.
*
* - r: one resolver behind three import forms, and the arm builds all three
* (see `uniqueTarget`): `library(pkg)` (a wildcard that resolves to every
* file of the package's `R/`), `source("R/file.R")` (a side-effect import
* answered by the suffix index after an exact-join miss) and a NAMESPACE
* `importFrom(pkg, name)` (a named import that reads the parsed workspace,
* spelled `pkg::name` in the corpus). It passes the package map a
* `DESCRIPTION` scan yields and is a `CONTEXT_LANGS` member. Two of its
* legs are O(files) per import rather than keyed (a local `library()` scans
* the file list for the `R/` prefix; every `source()` path runs
* `normalizedFileList.indexOf` before the index), which is why R reads the
* highest scaling of the linear-budget languages; `_r_arm` in
* baselines.json has the per-leg numbers.
*
* Two properties of the corpus are load-bearing and must not be "simplified":
*
* 1. **Most imports are unresolvable.** In real source the majority of imports
@ -495,6 +508,7 @@ import { typescriptScopeResolver } from '../../src/core/ingestion/languages/type
import { cScopeResolver } from '../../src/core/ingestion/languages/c/scope-resolver.ts';
import { cppScopeResolver } from '../../src/core/ingestion/languages/cpp/scope-resolver.ts';
import { objectiveCScopeResolver } from '../../src/core/ingestion/languages/objective-c/scope-resolver.ts';
import { rScopeResolver } from '../../src/core/ingestion/languages/r/scope-resolver.ts';
// `SCOPE_RESOLVERS` is NOT imported here — see the inventory arm at the bottom,
// which loads it dynamically. Statically it costs 6-10 s of module load
// depending on the box (measured both ways there), because reaching the
@ -606,6 +620,9 @@ const HEAP_BUDGETED = [
'objc',
// Lexical module lookup retains a workspace index over parsed Rust files.
'rust',
// R retains the shared workspace file index (57.26 MiB at 32000 files, ratio
// 0.970 against the 1.25 budget), so it takes the full ceiling + floor + ratio.
'r',
];
// javascript, typescript and vue were budgeted here until #2953 and are now
// BOUNDED, which is a demotion in gate strength and a promotion in what the
@ -634,7 +651,7 @@ const HEAP_BUDGETED = [
* parameters cannot observe a context and are not listed, so their timed
* numbers stay on the three-argument shape.
*/
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'rust', 'c', 'cpp'];
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'rust', 'c', 'cpp', 'r'];
/**
* Needs `node --expose-gc` to force collection for a clean delta; without it
@ -767,6 +784,7 @@ const EXTENSION = {
cpp: '.cpp',
objc: '.m',
zig: '.zig',
r: '.R',
};
/** C and C++ resolve `#include` against HEADERS, which reach the resolver
* through `resolutionConfig` rather than through `allFilePaths` — see
@ -899,6 +917,12 @@ function uniqueDir(lang, d, i) {
// share the other unique arms take from a nested directory comes from the
// target instead (see `uniqueTarget`).
if (lang === 'zig') return `src/mod${d}`;
// An R package keeps its sources in `<pkg>/R/` and nothing else in the
// namespace, so one package per index with its files flat under `R/` is the
// layout `library()` (every file of `<pkg>/R/`) and `importFrom()` (the file
// defining one name) both address. Flat on purpose: R has no nested source
// directories inside `R/`, so there is no `d % 7` nested slice to mint.
if (lang === 'r') return `pkg${d}/R`;
throw unwiredLanguage('uniqueDir', lang);
}
@ -994,6 +1018,11 @@ function collideDir(lang, d, i) {
// axis that CAN grow — `collideTarget` spells its imports up through the
// tree and back down, ~4x the components of the unique arm.
if (lang === 'zig') return `src/l0/l1/l2/l3/l4/mod${d}`;
// `svc{d}/R/mod{k}.R` in every package: the conventional flat `R/` directory
// with a repeated basename per package, so the `source("R/mod{k}.R")` spelling
// below is ambiguous across every package and its suffix lookup answers from
// a bucket that grows with the corpus.
if (lang === 'r') return `svc${d}/R`;
throw unwiredLanguage('collideDir', lang);
}
@ -1048,7 +1077,8 @@ function buildFiles(lang, fileCount, pad, shape) {
layout === 'vue' ||
layout === 'python' ||
layout === 'c' ||
layout === 'cpp';
layout === 'cpp' ||
layout === 'r';
const [fileStem, modStem] = PASCAL_CASE_FILES.has(layout) ? ['File', 'Mod'] : ['file', 'mod'];
let stem =
shape === 'collide' && collideStem ? `${modStem}${Math.floor(i / dirs)}` : `${fileStem}${i}`;
@ -1169,6 +1199,58 @@ const kotlinProbeFile = (filePath, packageName, exportName) => {
};
};
/**
* An R file whose Module scope binds each of `names` to a top-level function,
* which is exactly what `rFileTopLevel` reads — and ONLY that, because
* `localDefs` also lists nested functions and R6 members and cannot answer "is
* this defined at top level". The resolver reads the bindings, never the def's
* other fields, so the rest of the shape is the inert `probeFile` one.
*/
const rProbeFile = (filePath, names) => {
const base = probeFile(
filePath,
names.map((name) => ['Function', name]),
);
const moduleScope = `module:${filePath}`;
return {
...base,
moduleScope,
scopes: [
{
id: moduleScope,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 },
filePath,
bindings: new Map(
base.localDefs.map((def) => [def.qualifiedName, [{ def, origin: 'local' }]]),
),
ownedDefs: base.localDefs,
imports: [],
typeBindings: new Map(),
},
],
};
};
/**
* What `loadRPackageConfig` reads from disk (one `DESCRIPTION` per package),
* rebuilt from the corpus paths: every `<dir>/R/` is the `R/` directory of the
* package named after its last component. `namespaceInfoByPackageDir` is empty
* — no NAMESPACE file — so `importFrom()` exports are not filtered, which is
* the leg that reads `parsedFiles` and is the one this arm exists to measure.
*/
const rPackageConfigFor = (files) => {
const packages = new Map();
for (const file of files) {
const at = file.indexOf('/R/');
if (at < 0) continue;
const dir = file.slice(0, at);
packages.set(dir.slice(dir.lastIndexOf('/') + 1), dir);
}
return { packages, namespaceInfoByPackageDir: new Map() };
};
function javaBenchmarkPackage(filePath) {
const uniquePackage = /\/com\/example\/(pkg\d+)(?:\/|$)/.exec(`/${filePath}`)?.[1];
if (uniquePackage !== undefined) return `com.example.${uniquePackage}`;
@ -1217,6 +1299,17 @@ function buildParsedFiles(lang, files) {
parsedFiles.push(kotlinProbeFile(filePath, kotlinBenchmarkPackage(filePath), stem));
continue;
}
if (lang === 'r') {
// One top-level function named for the file, plus an S3 method on it
// (`file7.print`), which is a dotted top-level name: the resolver's
// `dottedTails` guard reads it, and its tail (`print`) never equals a
// name an import asks for, so the guard runs on every lookup and refuses
// none.
const slash = filePath.lastIndexOf('/');
const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.'));
parsedFiles.push(rProbeFile(filePath, [stem, `${stem}.print`]));
continue;
}
const slash = filePath.lastIndexOf('/');
const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.'));
const parent = slash < 0 ? '' : filePath.slice(0, slash);
@ -1446,6 +1539,32 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
return `../vendor${(r >>> 4) % 97}/missing.zig`;
}
if (lang === 'r') {
// Three import forms, one per leg of the resolver, in the shares R code
// actually has them:
// - `library(pkg)` — `pkg{d}` is a LOCAL package and resolves to every
// file of its `R/` directory (a list); an external one (`dplyr`) is
// refused by the wildcard guard before any lookup;
// - `source("R/file{j}.R")` — the project-root spelling. The importer's
// own directory does not hold `R/R/…`, so the exact-join probe misses
// and the suffix index answers; an external one is a vendored path in
// no package, which runs the whole cascade to null;
// - `importFrom(pkg, name)` — spelled `pkg::name` in this corpus because
// the import list is `[from, target]` pairs and `resolveOne` splits it
// back (`::` is not legal in a package name, so the split is exact).
// Resolves through the parsed workspace's top-level definitions; an
// external package is not in the config and answers null.
if (local) {
const leg = (r >>> 3) % 3;
if (leg === 0) return `pkg${d}`;
if (leg === 1) return `R/file${j}.R`;
return `pkg${j % dirs}::file${j}`;
}
const miss = (r >>> 3) % 3;
if (miss === 0) return ['dplyr', 'ggplot2', 'data.table', 'stringr'][(r >>> 4) % 4];
if (miss === 1) return `vendor${(r >>> 4) % 97}/R/missing.R`;
return `ext${(r >>> 4) % 97}::tidy`;
}
throw unwiredLanguage('uniqueTarget', lang);
}
@ -1679,6 +1798,27 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
return `${up}/vendor${(r >>> 4) % 97}/missing.zig`;
}
if (lang === 'r') {
// The same three forms in the same proportions as the unique arm, so the
// resolved count is identical by construction (asserted). The source
// spelling is the one that collides: `R/mod{k}.R` matches the file of that
// name in EVERY package, so the suffix lookup is ambiguous across `dirs`
// packages — the project-root `source("R/utils.R")` shape a multi-package
// repo really has. The external spellings are unchanged and deliberately
// share no stem with a real file, or they would suffix-match and stop
// being misses.
const k = Math.floor(j / dirs);
if (local) {
const leg = (r >>> 3) % 3;
if (leg === 0) return `svc${d}`;
if (leg === 1) return `R/mod${k}.R`;
return `svc${j % dirs}::mod${k}`;
}
const miss = (r >>> 3) % 3;
if (miss === 0) return ['dplyr', 'ggplot2', 'data.table', 'stringr'][(r >>> 4) % 4];
if (miss === 1) return `vendor${(r >>> 4) % 97}/R/missing.R`;
return `ext${(r >>> 4) % 97}::tidy`;
}
throw unwiredLanguage('collideTarget', lang);
}
@ -1780,7 +1920,12 @@ function newPass(lang, files, pad = 0) {
restoreBenchmarkSideChannels(lang, parsedFiles);
return {
allFilePaths: new Set(parsedFiles.map((f) => f.filePath)),
config: lang === 'php' ? phpComposerConfigFor(pad) : undefined,
config:
lang === 'php'
? phpComposerConfigFor(pad)
: lang === 'r'
? rPackageConfigFor(files)
: undefined,
parsedFiles,
};
}
@ -1966,6 +2111,33 @@ function resolveOne(lang, from, target, pass) {
if (lang === 'objc') {
return objectiveCScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
}
// The registered hook, because R's adapter is where its per-pass file index,
// the wildcard guard and the `importFrom` branch all live. `parsedImport` is
// rebuilt from the target's spelling exactly the way `interpretRImport` and
// `populateRNamespaceImports` mint it: `library()` is a `wildcard`, `source()`
// a `side-effect`, and a NAMESPACE `importFrom()` a `named` import whose
// `targetRaw` is the package. Only the named form reads `parsedFiles`.
if (lang === 'r') {
const sep = target.indexOf('::');
const parsedImport =
sep >= 0
? {
kind: 'named',
localName: target.slice(sep + 2),
importedName: target.slice(sep + 2),
targetRaw: target.slice(0, sep),
}
: target.endsWith('.R')
? { kind: 'side-effect', targetRaw: target }
: { kind: 'wildcard', targetRaw: target };
return rScopeResolver.resolveImportTarget(
parsedImport.targetRaw,
from,
allFilePaths,
pass.config,
contextFor(pass, parsedImport),
);
}
if (lang === 'csharp' || lang === 'csharp_csproj') {
return resolveCsharpImportTarget(
{ kind: 'namespace', localName: '_', importedName: '_', targetRaw: target },
@ -2223,6 +2395,9 @@ const HEAP_PROBE_TARGET = {
typescript: 'vendor0/lib/missing',
vue: 'vendor0/lib/Missing.vue',
cpp: 'vendor0/missing.hpp',
// A vendored `source()` path in no package: the exact-join probe misses and
// the suffix cascade runs to the end, forcing the workspace file index.
r: 'vendor0/R/missing.R',
};
/**
@ -2447,6 +2622,24 @@ const CONTEXT_PROBE = {
probeFile('include/util.hpp', []),
],
},
/**
* `importFrom(pkgA, f)` where `pkgA` holds `a.R` (defining `f`) and `b.R`
* (defining `g`). With the parsed workspace the named import resolves to the
* one file that defines `f`; without it the call is the context-free one,
* which cannot tell a named import from `library(pkgA)` and answers every
* file of the package's `R/`. Two distinct non-null answers, so a dropped
* `parsedFiles` cannot look like a miss. `f` is spelled with the `pkg::name`
* convention `resolveOne` splits.
*/
r: {
from: 'app/R/main.R',
target: 'pkgA::f',
parsedFiles: [
rProbeFile('app/R/main.R', ['main']),
rProbeFile('pkgA/R/a.R', ['f']),
rProbeFile('pkgA/R/b.R', ['g']),
],
},
};
/** Resolve the probe twice through `resolveOne` — once with the pass's parsed
@ -2455,7 +2648,12 @@ const CONTEXT_PROBE = {
function measureContext(lang) {
const { from, target, parsedFiles } = CONTEXT_PROBE[lang];
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
const config = lang === 'php' ? phpComposerConfigFor(0) : undefined;
const config =
lang === 'php'
? phpComposerConfigFor(0)
: lang === 'r'
? rPackageConfigFor(parsedFiles.map((f) => f.filePath))
: undefined;
const answer = (files) => {
restoreBenchmarkSideChannels(lang, files ?? []);
const pass = { allFilePaths, config, parsedFiles: files };
@ -2616,6 +2814,7 @@ const LANG_REGISTRY = {
cpp: SupportedLanguages.CPlusPlus,
objc: SupportedLanguages.ObjectiveC,
zig: SupportedLanguages.Zig,
r: SupportedLanguages.R,
};
const LANGS = Object.keys(LANG_REGISTRY);
/**

View file

@ -8,6 +8,7 @@
"name": "gitnexus",
"version": "1.6.12",
"bundleDependencies": [
"@eagleoutice/tree-sitter-r",
"tree-sitter",
"tree-sitter-c-sharp",
"tree-sitter-cpp",
@ -23,6 +24,7 @@
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@eagleoutice/tree-sitter-r": "1.1.2",
"@ladybugdb/core": "0.21.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
@ -231,6 +233,26 @@
"node": ">=18"
}
},
"node_modules/@eagleoutice/tree-sitter-r": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@eagleoutice/tree-sitter-r/-/tree-sitter-r-1.1.2.tgz",
"integrity": "sha512-HR0RyoI5uxcfsdZvTMqSM8GJyGo6DQTkfdgqygQ6N+D0KQObRH4RxYgBZ6ePsGq/36RBAqv8y0NeQvAFL2N4dQ==",
"hasInstallScript": true,
"inBundle": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",

View file

@ -67,6 +67,7 @@
"version": "node scripts/sync-plugin-manifests.mjs"
},
"dependencies": {
"@eagleoutice/tree-sitter-r": "1.1.2",
"@ladybugdb/core": "0.21.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
@ -143,6 +144,7 @@
"node": "^22.18.0 || >=24.11.0"
},
"bundleDependencies": [
"@eagleoutice/tree-sitter-r",
"tree-sitter",
"tree-sitter-c-sharp",
"tree-sitter-cpp",

View file

@ -31,6 +31,7 @@ const SAMPLES = {
dart: 'void main() { print(1); }\n',
kotlin: 'class A {\n fun m() {}\n}\n',
zig: 'pub fn main() void {}\n',
r: 'f <- function(x) x\n',
};
async function check(installedDir) {

View file

@ -13,6 +13,7 @@ const path = require('node:path');
const RUNTIME = '0.25.1';
// Upstream metadata is an input to validate, never the peer range we ship.
const AUDITED_PEERS = new Map([
['@eagleoutice/tree-sitter-r@1.1.2', '^0.21.0'],
['tree-sitter-c@0.23.6', '^0.22.1'],
['tree-sitter-cpp@0.23.4', '^0.21.1'],
['tree-sitter-java@0.23.5', '^0.21.1'],
@ -30,8 +31,10 @@ function prepareTreeSitterBundle(packageRoot = path.resolve(__dirname, '..')) {
if (manifest.dependencies['tree-sitter'] !== RUNTIME) {
throw new Error(`Tree-sitter bundle is only validated for runtime ${RUNTIME}`);
}
// A grammar is recognised by its package basename, so a scoped package such as
// @eagleoutice/tree-sitter-r is held to the same bundling and pinning rules.
const grammars = Object.keys(manifest.dependencies).filter(
(name) => name === 'tree-sitter' || name.startsWith('tree-sitter-'),
(name) => name === 'tree-sitter' || name.split('/').pop().startsWith('tree-sitter-'),
);
for (const name of grammars) {
if (

View file

@ -303,3 +303,10 @@ export function hasZigPubKeyword(declNode: SyntaxNode): boolean {
}
return false;
}
/**
* R: every definition is exported by default. NAMESPACE-driven export
* refinement lives in R's `postParse` hook (ExportChecker has no file-path
* context; NAMESPACE detection needs it).
*/
export const rExportChecker: ExportChecker = (_node, _name) => true;

View file

@ -0,0 +1,259 @@
// gitnexus/src/core/ingestion/field-extractors/r.ts
/**
* R field extractor — hand-written because R expresses classes as function
* calls (R6Class(), setClass(), setRefClass()) rather than dedicated class syntax, so the
* generic config-driven factory cannot navigate the AST.
*
* Handles:
* - R6 fields: non-function entries in public/private list()
* - R5 fields: setRefClass("Foo", fields = list(name = "character"))
* - S4 representation/slots: setClass("Foo", representation(name = "character"))
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { BaseFieldExtractor } from '../field-extractor.js';
import { isRNamingArgument } from '../languages/r/naming-argument.js';
import type {
FieldExtractorContext,
ExtractedFields,
FieldInfo,
FieldVisibility,
} from '../field-types.js';
export class RFieldExtractor extends BaseFieldExtractor {
language = SupportedLanguages.R;
isTypeDeclaration(node: SyntaxNode): boolean {
return isR6Class(node) || isS4Class(node);
}
protected extractVisibility(_node: SyntaxNode): FieldVisibility {
return 'public';
}
extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null {
if (isR6Class(node)) return this.extractR6Fields(node, context);
if (isS4Class(node)) return this.extractS4Fields(node, context);
return null;
}
// --------------------------------------------------------------------------
// R6 field extraction
// --------------------------------------------------------------------------
private extractR6Fields(
node: SyntaxNode,
context: FieldExtractorContext,
): ExtractedFields | null {
const ownerFqn = node.childForFieldName('lhs')?.text;
if (!ownerFqn) return null;
const call = node.childForFieldName('rhs');
if (!call) return null;
const args = call.childForFieldName('arguments');
if (!args) return null;
const fields: FieldInfo[] = [];
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const section = arg.childForFieldName('name')?.text;
if (section !== 'public' && section !== 'private') continue;
const visibility: FieldVisibility = section === 'private' ? 'private' : 'public';
const listCall = arg.childForFieldName('value');
if (!listCall || listCall.type !== 'call') continue;
const listArgs = listCall.childForFieldName('arguments');
if (!listArgs) continue;
for (let j = 0; j < listArgs.namedChildCount; j++) {
const entry = listArgs.namedChild(j);
if (!entry || entry.type !== 'argument') continue;
const nameNode = entry.childForFieldName('name');
const valueNode = entry.childForFieldName('value');
if (!nameNode) continue;
// Skip function entries (those are methods, not fields)
if (valueNode?.type === 'function_definition') continue;
fields.push({
name: nameNode.text,
type: inferR6FieldType(valueNode),
visibility,
isStatic: false,
isReadonly: false,
sourceFile: context.filePath,
line: entry.startPosition.row + 1,
});
}
}
return fields.length > 0 ? { ownerFqn, fields, nestedTypes: [] } : null;
}
// --------------------------------------------------------------------------
// S4 field extraction (representation / slots / fields)
// --------------------------------------------------------------------------
private extractS4Fields(
node: SyntaxNode,
context: FieldExtractorContext,
): ExtractedFields | null {
const args = node.childForFieldName('arguments');
if (!args) return null;
// The class name is the `Class` argument (by name, else the first unnamed one).
const ownerFqn = getRClassNameArgument(args);
if (!ownerFqn) return null;
const fields: FieldInfo[] = [];
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const paramName = arg.childForFieldName('name')?.text;
if (paramName !== 'representation' && paramName !== 'slots' && paramName !== 'fields')
continue;
const slotCall = arg.childForFieldName('value');
if (!slotCall || slotCall.type !== 'call') continue;
const slotArgs = slotCall.childForFieldName('arguments');
if (!slotArgs) continue;
for (let j = 0; j < slotArgs.namedChildCount; j++) {
const slot = slotArgs.namedChild(j);
if (!slot || slot.type !== 'argument') continue;
const slotName = slot.childForFieldName('name');
const slotType = slot.childForFieldName('value');
if (!slotName) continue;
const typeName = extractQuotedTypeName(slotType);
fields.push({
name: slotName.text,
type: typeName,
visibility: 'public',
isStatic: false,
isReadonly: false,
sourceFile: context.filePath,
line: slot.startPosition.row + 1,
});
}
}
return fields.length > 0 ? { ownerFqn, fields, nestedTypes: [] } : null;
}
}
// ---------------------------------------------------------------------------
// AST helpers
// ---------------------------------------------------------------------------
/**
* The class name of a `setClass` / `setRefClass` argument list: the string value of
* the argument that takes the `Class` formal. Uses the same selection as the scope
* query and definition hook (`isRNamingArgument`), so `contains = "Base"` or
* `representation(...)` ahead of `Class = "A"` never names the owner.
*/
export function getRClassNameArgument(args: SyntaxNode): string | null {
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
if (!isRNamingArgument(arg, 'Class')) continue;
const value = arg.childForFieldName('value');
if (value?.type !== 'string') continue;
const name = extractQuotedTypeName(value);
if (name) return name;
}
return null;
}
export function findRFieldOwnerNode(node: SyntaxNode): SyntaxNode | null {
let current: SyntaxNode | null = node;
while (current) {
if (isR6Class(current) || isS4Class(current)) return current;
current = current.parent;
}
return null;
}
export function getRTopLevelPropertyOwnerName(node: SyntaxNode): string | null {
const ownerNode = findRFieldOwnerNode(node);
if (!ownerNode) return null;
if (ownerNode.type === 'binary_operator') {
return ownerNode.childForFieldName('lhs')?.text ?? null;
}
const args = ownerNode.childForFieldName('arguments');
if (!args) return null;
return getRClassNameArgument(args);
}
/** Check if a binary_operator node is an R6 class assignment. */
function isR6Class(node: SyntaxNode): boolean {
if (node.type !== 'binary_operator') return false;
const rhs = node.childForFieldName('rhs');
if (!rhs || rhs.type !== 'call') return false;
const fn = rhs.childForFieldName('function');
if (!fn) return false;
// R6Class(...)
if (fn.type === 'identifier' && fn.text === 'R6Class') return true;
// R6::R6Class(...)
if (fn.type === 'namespace_operator') {
const rhsFn = fn.childForFieldName('rhs');
return rhsFn?.text === 'R6Class';
}
return false;
}
/** Check if a call node is setClass() or setRefClass(). */
function isS4Class(node: SyntaxNode): boolean {
if (node.type !== 'call') return false;
const fn = node.childForFieldName('function');
return fn?.type === 'identifier' && (fn.text === 'setClass' || fn.text === 'setRefClass');
}
/** Infer a basic type from an R6 field default value. */
function inferR6FieldType(valueNode: SyntaxNode | null): string | null {
if (!valueNode) return null;
switch (valueNode.type) {
case 'null':
return null;
case 'true':
case 'false':
return 'logical';
case 'float':
return 'numeric';
case 'integer':
return 'integer';
case 'string':
return 'character';
default:
// NA variants
if (valueNode.type === 'identifier') {
const text = valueNode.text;
if (text === 'NA_character_') return 'character';
if (text === 'NA_integer_') return 'integer';
if (text === 'NA_real_') return 'numeric';
if (text === 'NA_complex_') return 'complex';
}
return null;
}
}
function extractQuotedTypeName(node: SyntaxNode | null): string | null {
if (!node || node.type !== 'string') return null;
const content = node.namedChildren.find((c) => c.type === 'string_content');
return content?.text ?? node.text.replace(/^["']|["']$/g, '');
}

View file

@ -0,0 +1,249 @@
/**
* R import resolution.
* Handles library(), require(), source(), and pkg::func() resolution.
*/
import path from 'path';
import type { SuffixIndex } from './utils.js';
import { suffixResolve } from './utils.js';
import type { ParsedFile } from 'gitnexus-shared';
import { rExportPatternMatches, type RPackageConfig } from '../languages/r/package-config.js';
import {
rFileTopLevel,
rPackageDirForFile,
type RFileTopLevel,
} from '../languages/r/namespace-imports.js';
import type { ImportResult, ResolveCtx } from './types.js';
import type { ImportResolutionContext } from '../scope-resolution/contract/scope-resolver.js';
import { getWorkspaceFileIndex } from './workspace-file-index.js';
/**
* Low-level R import resolver (internal helper).
* Resolves library/require/source paths to matching .R files.
*
* `normToRaw` (normalized path → first raw path with that spelling, built once per pass by
* `getWorkspaceFileIndex`) answers the exact-path lookups of `source()` in O(1). Without it
* they fall back to `indexOf` over `normalizedFileList`, which picks the same file (the first
* in list order) but costs O(files) per `source()` import.
*/
export function resolveRImportInternal(
filePath: string,
rawImportPath: string,
normalizedFileList: readonly string[],
allFileList: readonly string[],
rConfig: RPackageConfig | null,
index?: SuffixIndex,
normToRaw?: ReadonlyMap<string, string>,
): string | string[] | null {
const exactFile = (normalizedPath: string): string | undefined => {
if (normToRaw !== undefined) return normToRaw.get(normalizedPath);
const idx = normalizedFileList.indexOf(normalizedPath);
return idx >= 0 ? allFileList[idx] : undefined;
};
const cleaned = rawImportPath.replace(/^["']|["']$/g, '');
// source() with file path
if (cleaned.endsWith('.R') || cleaned.endsWith('.r')) {
if (path.isAbsolute(cleaned)) {
const normalized = cleaned.replace(/\\/g, '/');
return exactFile(normalized) ?? null;
}
const dir = path.dirname(filePath);
const resolved = path.join(dir, cleaned).replace(/\\/g, '/');
const direct = exactFile(resolved);
if (direct !== undefined) return direct;
const pathParts = cleaned.split('/').filter(Boolean);
return suffixResolve(pathParts, normalizedFileList, allFileList, index);
}
// library("pkg") / require("pkg") — resolve to ALL files in the local package
if (rConfig) {
const pkgDir = rConfig.packages.get(cleaned);
// A root-level package has the directory '' (falsy), so test for absence, not truthiness.
if (pkgDir !== undefined) {
const rDirPrefix = (pkgDir ? pkgDir + '/' : '') + 'R/';
const files: string[] = [];
for (let i = 0; i < normalizedFileList.length; i++) {
if (
normalizedFileList[i].startsWith(rDirPrefix) &&
(normalizedFileList[i].endsWith('.R') || normalizedFileList[i].endsWith('.r'))
) {
files.push(allFileList[i]);
}
}
if (files.length > 0) return files;
}
}
// Fallback: suffix-based resolution
const pathParts = cleaned.split('/').filter(Boolean);
return suffixResolve(pathParts, normalizedFileList, allFileList, index);
}
/** R: library/require/source resolution via R package config. */
export function resolveRImport(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
): ImportResult {
const resolved = resolveRImportInternal(
filePath,
rawImportPath,
ctx.normalizedFileList,
ctx.allFileList,
// The legacy `ResolveCtx` bundle never carried R's config; the live path is
// `resolveRImportTarget` + `loadResolutionConfig`.
null,
ctx.index,
);
if (!resolved) return null;
const files = Array.isArray(resolved) ? resolved : [resolved];
return { kind: 'files', files };
}
/** Per-package index of top-level definitions, built once per `parsedFiles` array. */
interface RPackageDefIndex {
/** Files of the package's `R/` directory, with what each defines at top level. */
readonly files: ReadonlyMap<string, RFileTopLevel>;
/** Top-level name → files (in `parsedFiles` order) that define it. */
readonly definingFiles: ReadonlyMap<string, readonly string[]>;
}
const packageDefIndexes = new WeakMap<
readonly ParsedFile[],
WeakMap<RPackageConfig, ReadonlyMap<string, RPackageDefIndex>>
>();
function packageDefIndex(
parsedFiles: readonly ParsedFile[],
rConfig: RPackageConfig,
pkg: string,
): RPackageDefIndex | undefined {
let byConfig = packageDefIndexes.get(parsedFiles);
if (byConfig === undefined) packageDefIndexes.set(parsedFiles, (byConfig = new WeakMap()));
let byPackage = byConfig.get(rConfig);
if (byPackage === undefined) {
const files = new Map<string, Map<string, RFileTopLevel>>();
const defining = new Map<string, Map<string, string[]>>();
for (const parsed of parsedFiles) {
const location = rPackageDirForFile(parsed.filePath, rConfig);
if (location === undefined) continue;
const topLevel = rFileTopLevel(parsed);
let pkgFiles = files.get(location.name);
let pkgDefining = defining.get(location.name);
if (pkgFiles === undefined || pkgDefining === undefined) {
files.set(location.name, (pkgFiles = new Map()));
defining.set(location.name, (pkgDefining = new Map()));
}
pkgFiles.set(parsed.filePath, topLevel);
for (const name of topLevel.names) {
const list = pkgDefining.get(name);
if (list === undefined) pkgDefining.set(name, [parsed.filePath]);
else list.push(parsed.filePath);
}
}
const built = new Map<string, RPackageDefIndex>();
for (const [name, pkgFiles] of files) {
built.set(name, { files: pkgFiles, definingFiles: defining.get(name) ?? new Map() });
}
byConfig.set(rConfig, (byPackage = built));
}
return byPackage.get(pkg);
}
const stripBackticks = (s: string): string => s.replace(/^`+|`+$/g, '');
/**
* Resolve a NAMESPACE `importFrom(pkg, importedName)` to the `R/` file(s) of the
* local package `pkg` that define `importedName` at top level, or `null`.
*
* `null` (no binding, no IMPORTS edge) when: `pkg` is not a local package
* (external packages never suffix-match anything); `importedName` is dotted
* (finalize keys defs by the text after the last `.`, so it can never bind);
* the provider has a NAMESPACE that does not export the name; no file defines
* it at top level; or a defining file also holds a dotted top-level def whose
* tail equals `importedName` (finalize would index `print.tidy` under `tidy`
* and bind the WRONG def — the guard is order-blind, so it also refuses the
* layout where the bare def comes first). `null` degrades the call site to the
* baseline `global-name-fallback` guess. Never builds a workspace file index.
*/
export function resolveRNamedImport(
rConfig: RPackageConfig | null,
targetRaw: string,
importedName: string,
parsedFiles: readonly ParsedFile[],
): readonly string[] | null {
const pkg = targetRaw.replace(/^["']|["']$/g, '');
const pkgDir = rConfig?.packages.get(pkg);
if (rConfig === null || pkgDir === undefined) return null;
if (importedName.includes('.')) return null;
const info = rConfig.namespaceInfoByPackageDir.get(pkgDir);
if (info !== undefined && info.hasNamespaceFile) {
const exported =
info.namedExports.has(importedName) ||
[...info.namedExports].some((entry) => stripBackticks(entry) === importedName) ||
rExportPatternMatches(info, importedName);
if (!exported) return null;
}
const index = packageDefIndex(parsedFiles, rConfig, pkg);
const defining = index?.definingFiles.get(importedName);
if (index === undefined || defining === undefined || defining.length === 0) return null;
if (defining.some((file) => index.files.get(file)?.dottedTails.has(importedName) === true)) {
return null;
}
return defining;
}
/**
* `ScopeResolver.resolveImportTarget`-shaped adapter over
* `resolveRImportInternal`. Two responsibilities live only here, not in the
* internal resolver:
*
* - Memoization: `getWorkspaceFileIndex` derives `{ normalized, all, index }`
* once per `allFilePaths` Set identity (see its own doc comment) and this
* adapter calls it on every invocation instead of materializing fresh
* arrays per import — the O(imports × files) regression PR #1918/#2911
* exist to catch (`import-target-index-reuse.contract.test.ts`).
* - Cross-language false-positive guard: a `library()`/`require()` package
* name not found in `rConfig.packages` would otherwise fall through to
* `resolveRImportInternal`'s generic suffix fallback — and `EXTENSIONS`
* (`./utils.js`) has no `.R`/`.r` entry, so a bare R package reference can
* suffix-match an unrelated same-named file in another language.
* `context.parsedImport.kind === 'wildcard'` is exactly the
* `library()`/`require()` shape (see `interpretRImport`), so the guard is
* scoped to that kind only — `source()`'s own suffix-based file-path
* fallback is unaffected.
* - NAMESPACE `importFrom()` names (`kind: 'named'`, synthesised by
* `populateRNamespaceImports`): resolved by {@link resolveRNamedImport}
* before `getWorkspaceFileIndex`, so they build no file index.
*/
export function resolveRImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
context?: ImportResolutionContext,
): string | readonly string[] | null {
const rConfig = (resolutionConfig as RPackageConfig | null | undefined) ?? null;
if (context?.parsedImport?.kind === 'wildcard') {
const cleaned = targetRaw.replace(/^["']|["']$/g, '');
if (rConfig === null || !rConfig.packages.has(cleaned)) return null;
}
if (context?.parsedImport?.kind === 'named') {
return resolveRNamedImport(
rConfig,
targetRaw,
context.parsedImport.importedName,
context.parsedFiles,
);
}
const { normalized, all, index, normToRaw } = getWorkspaceFileIndex(allFilePaths);
return resolveRImportInternal(fromFile, targetRaw, normalized, all, rConfig, index, normToRaw);
}

View file

@ -38,6 +38,8 @@ import type { ImportResolverFn } from './import-resolvers/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import type { CfgVisitor } from './cfg/types.js';
import type { GraphNode, NodeLabel, ParameterTypeClass, RelationshipType } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../graph/types.js';
import type { MutableSemanticModel } from './model/semantic-model.js';
import type { ExtractedRoute } from './route-extractors/laravel.js';
import type { SharedSpringType } from './route-extractors/spring-shared.js';
import type {
@ -233,6 +235,16 @@ export function shouldHarvestModuleConstants(
return !provider.moduleConstantHeuristic || provider.moduleConstantHeuristic(content);
}
/**
* Everything a {@link LanguageProviderConfig.postParse} hook may read or mutate: the merged graph, the
* populated semantic model, and the repository root. Hooks mutate `graph`/`model` in place.
*/
export interface PostParseContext {
readonly graph: KnowledgeGraph;
readonly model: MutableSemanticModel;
readonly repoPath: string;
}
interface LanguageProviderConfig {
// ── Identity ──────────────────────────────────────────────────────
readonly id: SupportedLanguages;
@ -376,6 +388,16 @@ interface LanguageProviderConfig {
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null;
/**
* For languages whose member owner is not a syntactic ancestor container
* (R: `R6Class(...)` / `setRefClass(...)` calls): return the node the
* method/field extractors read members from, or `null`.
*
* Consulted by the enclosing-owner slot after the container walk and
* `resolveFileTypeOwner` (same slot as the Zig file-owner).
* Default: undefined. */
readonly resolveMemberOwnerNode?: (definitionNode: SyntaxNode) => SyntaxNode | null;
/**
* The type a CONTAINER node declares, when the language names it from its
* context rather than from a name child of the node — a binding wrapper,
@ -732,6 +754,20 @@ interface LanguageProviderConfig {
*/
readonly prepareRouteConstants?: (repo: RepoConstants) => void;
/**
* Whole-graph work that can only run once every chunk is merged and before scope resolution
* (deferred owner resolution, package-manifest-driven export refinement). "Post-parse" means after
* all chunks are merged but still inside the parse phase — not the later `crossFile` phase.
*
* Called once per analyze, only if this language has parsed files, awaited, on the main thread.
* Mutates `ctx.graph` / `ctx.model` in place. A throw aborts the parse phase (it is not isolated,
* because the hook mutates shared state and a swallowed mid-mutation throw could leave the graph
* half-updated).
*
* Default: undefined (nothing to do after the merge).
*/
readonly postParse?: (ctx: PostParseContext) => void | Promise<void>;
/**
* Spring async messaging facts captured for one file — the listener
* annotations that subscribe to a broker destination and the template calls
@ -1237,6 +1273,22 @@ export function prepareRouteConstantsByProvider(
}
}
/**
* Run each present language's {@link LanguageProviderConfig.postParse} hook, sequentially and in
* language-id order (independent of scan order), awaiting each before the next: the hooks share one
* mutable graph/model. The provider lookup is a callback so this file needs no registry import.
* A rejection propagates and later hooks do not run.
*/
export async function runPostParseHooks(
presentLanguages: ReadonlySet<SupportedLanguages>,
providerFor: (language: SupportedLanguages) => Pick<LanguageProvider, 'postParse'> | undefined,
ctx: PostParseContext,
): Promise<void> {
for (const language of [...presentLanguages].sort()) {
await providerFor(language)?.postParse?.(ctx);
}
}
const DEFAULTS: Pick<LanguageProvider, 'mroStrategy'> = {
mroStrategy: 'first-wins',
};

View file

@ -29,6 +29,7 @@ import { rubyProvider } from './ruby.js';
import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { rProvider } from './r.js';
import { cobolProvider } from './cobol.js';
import { zigProvider } from './zig.js';
@ -49,6 +50,7 @@ export const providers = {
[SupportedLanguages.Swift]: swiftProvider,
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.R]: rProvider,
[SupportedLanguages.Cobol]: cobolProvider,
[SupportedLanguages.Zig]: zigProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;

View file

@ -0,0 +1,195 @@
/**
* R language provider.
*
* R uses wildcard import semantics — library() and source() bring
* everything into scope (no named imports). No special call routing
* is needed (unlike Ruby); R imports are parsed as standard import nodes.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as rTypeConfig } from '../type-extractors/r.js';
import { rExportChecker } from '../export-detection.js';
import { resolveRImport } from '../import-resolvers/r.js';
import { R_QUERIES } from '../tree-sitter-queries.js';
import { RFieldExtractor } from '../field-extractors/r.js';
import { rMethodExtractor } from '../method-extractors/r.js';
import { emitRScopeCaptures } from './r/captures.js';
import { interpretRImport, interpretRTypeBinding } from './r/interpret.js';
import { rPostParse } from './r/post-parse.js';
import { rDefinitionProperties, rResolveMemberOwnerNode } from './r/owner-hooks.js';
import { isRNonNamingArgumentCapture } from './r/naming-argument.js';
const R_BUILT_INS: ReadonlySet<string> = new Set([
// Base R
'c',
'list',
'vector',
'matrix',
'array',
'data.frame',
'length',
'nrow',
'ncol',
'dim',
'names',
'colnames',
'rownames',
'print',
'cat',
'paste',
'paste0',
'sprintf',
'message',
'warning',
'stop',
'tryCatch',
'withCallingHandlers',
'try',
'on.exit',
'sys.call',
'match.arg',
// Type checks / coercion
'is.null',
'is.na',
'is.numeric',
'is.character',
'is.logical',
'is.list',
'as.character',
'as.numeric',
'as.integer',
'as.logical',
'as.data.frame',
// Apply family
'lapply',
'sapply',
'vapply',
'mapply',
'tapply',
'apply',
'Map',
'Reduce',
'Filter',
// Functional
'do.call',
'Recall',
'match.fun',
// I/O
'readRDS',
'saveRDS',
'readLines',
'writeLines',
'read.csv',
'write.csv',
// Environment
'new.env',
'environment',
'parent.env',
'globalenv',
'baseenv',
'exists',
'get',
'assign',
'rm',
'ls',
// String
'nchar',
'substr',
'substring',
'gsub',
'sub',
'grep',
'grepl',
'regmatches',
'trimws',
'toupper',
'tolower',
'startsWith',
'endsWith',
'strsplit',
// Math
'sum',
'mean',
'median',
'min',
'max',
'abs',
'sqrt',
'log',
'exp',
'round',
'ceiling',
'floor',
'seq',
'seq_len',
'seq_along',
'rep',
'which',
'range',
'cumsum',
'diff',
// Logical
'any',
'all',
'xor',
'identical',
// Sort / order
'sort',
'order',
'rev',
'unique',
'duplicated',
'table',
// S4 / R6 internals
'setClass',
'setGeneric',
'setMethod',
'setRefClass',
'new',
'validObject',
'is',
'extends',
'isVirtualClass',
'R6Class',
// Package / namespace
'library',
'require',
'requireNamespace',
'loadNamespace',
'getNamespace',
'source',
'sys.source',
'attachNamespace',
// Control flow (builtins that appear as calls)
'return',
'invisible',
'missing',
'nargs',
'Sys.time',
'Sys.sleep',
'proc.time',
]);
export const rProvider = defineLanguage({
id: SupportedLanguages.R,
extensions: ['.R', '.r'],
entryPointPatterns: [],
astFrameworkPatterns: [],
treeSitterQueries: R_QUERIES,
typeConfig: rTypeConfig,
exportChecker: rExportChecker,
importResolver: resolveRImport,
fieldExtractor: new RFieldExtractor(),
methodExtractor: rMethodExtractor,
builtInNames: R_BUILT_INS,
emitScopeCaptures: emitRScopeCaptures,
interpretImport: interpretRImport,
interpretTypeBinding: interpretRTypeBinding,
postParse: rPostParse,
resolveMemberOwnerNode: rResolveMemberOwnerNode,
definitionPropertiesExtractor: rDefinitionProperties,
// R_QUERIES captures every string argument of setClass/setGeneric/setMethod;
// only the one that names the definition is kept.
shouldSkipDefinitionCapture: isRNonNamingArgumentCapture,
});

View file

@ -0,0 +1,48 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture } from '../../utils/ast-helpers.js';
import { getRParser, getRScopeQuery } from './query.js';
import { isRNonNamingScopeMatch } from './naming-argument.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/**
* Emit R scope captures — a single tree-sitter query run against the R
* grammar (see `query.ts` for the capture shapes and the rationale for each
* one). No programmatic enrichment is needed: unlike C/Zig/Rust, R's
* declarations carry no synthesized arity/parameter metadata, and heritage
* sites resolve directly by name (no marker encoding, unlike Ruby's
* `include`/`extend`/`prepend`).
*/
export function emitRScopeCaptures(
sourceText: string,
filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getRParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getRParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
}
const rawMatches = getRScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
// The scope query captures every candidate argument of setClass/library/require/
// source; keep only the one that names the class or import.
if (isRNonNamingScopeMatch(m.captures)) continue;
const grouped: Record<string, Capture> = {};
let hasRealCapture = false;
for (const c of m.captures) {
const tag = '@' + c.name;
if (tag.startsWith('@_')) continue;
grouped[tag] = nodeToCapture(tag, c.node);
hasRealCapture = true;
}
if (!hasRealCapture) continue;
out.push(grouped);
}
return out;
}

View file

@ -0,0 +1,234 @@
import { compileLinearRegexDetailed, type SharedWorkBudget } from './linear-regex.js';
/**
* `exportPattern("...")` compilation for R NAMESPACE files.
*
* R evaluates `exportPattern()` arguments as POSIX (TRE) extended regular
* expressions, whose bracket expressions support named classes such as
* `[[:alpha:]]`. JavaScript's `RegExp` has no such classes: it reads
* `[[:alpha:]]` as the set `[:alph` followed by a literal `]`. The RStudio
* new-package template writes `exportPattern("^[[:alpha:]]+")`, so without a
* translation step that pattern never matches anything.
*
* {@link compileRExportPattern} rewrites POSIX classes inside bracket
* expressions to ASCII JS equivalents (the C-locale meaning of each class) and
* compiles the result once. Patterns without `[:` are compiled verbatim, so
* their behaviour is unchanged. It never throws.
*
* The compiled form is a linear-time matcher, not a `RegExp`: the pattern text comes
* from the repository being analysed, and a backtracking engine lets a hostile
* pattern such as `^(a+)+$` hang the analyzer (R itself uses the non-backtracking
* TRE). See `linear-regex.ts` for the supported subset; a pattern outside it
* compiles to null and therefore matches nothing. Callers that care why use
* {@link compileRExportPatternDetailed}, so that a dropped pattern is reported
* rather than silently treated as an empty export list.
*
* The argument reaches {@link compileRExportPattern} as an R string *value*:
* {@link unescapeRString} first turns the NAMESPACE source text `"\\."` (the
* R string value `\.`) into the regex `\.`. Callers that read a pattern out
* of NAMESPACE text must unescape before compiling.
*/
const POSIX_CLASSES: Readonly<Record<string, string>> = {
alpha: 'A-Za-z',
alnum: 'A-Za-z0-9',
digit: '0-9',
upper: 'A-Z',
lower: 'a-z',
punct: '!-\\/:-@\\[-`{-~',
space: ' \\t\\n\\r\\f\\v',
blank: ' \\t',
cntrl: '\\x00-\\x1f\\x7f',
print: '\\x20-\\x7e',
graph: '\\x21-\\x7e',
xdigit: '0-9A-Fa-f',
};
/**
* Translate one bracket expression starting at `src[start] === '['`.
* Returns the JS equivalent and the index just past its closing `]`, or null
* when the expression is unterminated or names an unknown class.
*/
function translateBracket(src: string, start: number): { text: string; end: number } | null {
let i = start + 1;
let out = '[';
if (src[i] === '^') {
out += '^';
i++;
}
// A `]` in first position is a literal member, not the terminator.
if (src[i] === ']') {
out += '\\]';
i++;
}
while (i < src.length) {
const ch = src[i];
if (ch === ']') return { text: out + ']', end: i + 1 };
if (ch === '[' && src[i + 1] === ':') {
const close = src.indexOf(':]', i + 2);
if (close === -1) return null;
const expansion = POSIX_CLASSES[src.slice(i + 2, close)];
if (expansion === undefined) return null;
out += expansion;
i = close + 2;
} else if (ch === '\\' && i + 1 < src.length) {
out += ch + src[i + 1];
i += 2;
} else if (ch === '[' || ch === '^') {
out += '\\' + ch;
i++;
} else {
out += ch;
i++;
}
}
return null;
}
function translatePosixClasses(src: string): string | null {
let out = '';
let i = 0;
while (i < src.length) {
const ch = src[i];
if (ch === '\\' && i + 1 < src.length) {
out += ch + src[i + 1];
i += 2;
} else if (ch === '[') {
const bracket = translateBracket(src, i);
if (bracket === null) return null;
out += bracket.text;
i = bracket.end;
} else {
out += ch;
i++;
}
}
return out;
}
/** A compiled `exportPattern()`; `test` answers whether the pattern matches anywhere in `name`. */
export interface RExportMatcher {
/** The pattern text that was compiled (after POSIX-class translation). */
readonly source: string;
test(name: string): boolean;
/**
* True once the matcher stopped answering because it spent its work budget (its own, or
* the one shared by a package-config load); names it had not yet decided were then
* treated as not matching. Absent on matchers that
* cannot run out (a plain `RegExp`).
*/
readonly exhausted?: boolean;
}
/** The outcome of {@link compileRExportPatternDetailed}: a matcher, or why there is none. */
export type RExportPatternResult =
| { readonly matcher: RExportMatcher }
| { readonly matcher: null; readonly reason: string };
/**
* Compile an `exportPattern()` argument, saying why when it cannot be compiled safely.
* Matchers compiled with the same `shared` budget stop together once it is spent (see
* `linear-regex.ts`); a package-config load passes one so that the number of patterns a
* repository carries cannot multiply the per-matcher cost.
*/
export function compileRExportPatternDetailed(
source: string,
shared?: SharedWorkBudget,
): RExportPatternResult {
try {
let translated = source;
if (source.includes('[:')) {
const posix = translatePosixClasses(source);
if (posix === null) {
return {
matcher: null,
reason: 'unterminated bracket expression or unknown POSIX class',
};
}
translated = posix;
}
const result = compileLinearRegexDetailed(translated, shared);
return 'reason' in result
? { matcher: null, reason: result.reason }
: { matcher: result.regex };
} catch {
return { matcher: null, reason: 'pattern could not be compiled' };
}
}
/** Compile an `exportPattern()` argument; null when it cannot be compiled safely. */
export function compileRExportPattern(
source: string,
shared?: SharedWorkBudget,
): RExportMatcher | null {
return compileRExportPatternDetailed(source, shared).matcher;
}
const SIMPLE_R_ESCAPES: Readonly<Record<string, string>> = {
n: '\n',
t: '\t',
r: '\r',
a: '\x07',
b: '\b',
f: '\f',
v: '\v',
'\\': '\\',
'"': '"',
"'": "'",
'`': '`',
' ': ' ',
};
/**
* Turn the body of an R string literal (the text between the quotes) into the
* string value R would hold: `\\` -> `\`, `\"` -> `"`, `\n` -> newline, and
* likewise `\t \r \a \b \f \v \' \``, backslash-space, `\xHH`,
* `\uXXXX` / `\u{X..}` and `\UXXXXXXXX` / `\U{X..}` (code points). R
* rejects any other backslash sequence; this leniently keeps it verbatim, so a
* mistaken `"\."` still reaches the regex compiler as `\.`. Never throws.
*/
export function unescapeRString(body: string): string {
if (!body.includes('\\')) return body;
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i + 1 >= body.length) {
out += ch;
continue;
}
const next = body[i + 1];
const simple = SIMPLE_R_ESCAPES[next];
if (simple !== undefined) {
out += simple;
i++;
continue;
}
const hex = readHexEscape(body, i + 1);
if (hex !== null) {
out += hex.text;
i = hex.end - 1;
continue;
}
out += ch; // Unknown escape: keep the backslash; the next char is emitted normally.
}
return out;
}
/** `x`/`u`/`U` escape whose introducer is at `body[at]`; null when malformed. */
function readHexEscape(body: string, at: number): { text: string; end: number } | null {
const kind = body[at];
const maxDigits = kind === 'x' ? 2 : kind === 'u' ? 4 : kind === 'U' ? 8 : 0;
if (maxDigits === 0) return null;
let i = at + 1;
const braced = kind !== 'x' && body[i] === '{';
if (braced) i++;
const start = i;
while (i < body.length && i - start < maxDigits && /[0-9A-Fa-f]/.test(body[i])) i++;
if (i === start) return null;
if (braced) {
if (body[i] !== '}') return null;
}
const code = parseInt(body.slice(start, i), 16);
if (code > 0x10ffff) return null;
return { text: String.fromCodePoint(code), end: braced ? i + 1 : i };
}

View file

@ -0,0 +1,40 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding } from 'gitnexus-shared';
/**
* `source()` is a side-effect import — it establishes a file dependency but
* binds no name (cross-file free calls into a `source()`d file resolve via
* `rScopeResolver.allowGlobalFreeCallFallback` instead).
*
* `library()`/`require()` are wildcard imports — every top-level name of the
* named local package becomes visible (`rProvider.expandsWildcardTo`).
*
* `resolveRImportTarget` (`import-resolvers/r.ts`) guards on
* `context.parsedImport.kind === 'wildcard'` specifically to refuse a
* `library()`/`require()` package name that isn't a known local package
* (rather than falling through to a cross-language suffix-match false
* positive) — the two kinds set here are what that guard keys on.
*/
export function interpretRImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source']?.text;
if (source === undefined || source.length === 0) return null;
if (captures['@import.wildcard'] !== undefined) {
return { kind: 'wildcard', targetRaw: source };
}
return { kind: 'side-effect', targetRaw: source };
}
/**
* `x <- Type$new(...)` — R6's constructor spelling. `@type-binding.type` is
* the `$` receiver (the class name), captured only when the extract
* operator's rhs is literally `new` (see `query.ts`), so this always
* resolves via the shared `constructor-inferred` sub-kind — the same one
* Zig's `interpretZigTypeBinding` uses for `Counter.init()`.
*/
export function interpretRTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
return { boundName: name, rawTypeName: type, source: 'constructor-inferred' };
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,798 @@
/**
* NAMESPACE `importFrom()` parsing for R packages.
*
* `parseRNamespaceImportFrom` is a pure, package-blind tokenizer: it reads the
* text of one NAMESPACE file and returns every `importFrom(pkg, name, ...)`
* pair in file order. It deliberately returns *all* entries — including a
* package importing from itself and duplicate names from different packages —
* because those policy decisions (own-package drop, last-wins, external-package
* skip) belong to the consumer that knows the workspace, not to the parser.
*
* The tokenizer ({@link scanRNamespaceDirectives}) is generic (directive head +
* balanced argument list across newlines, honouring `"…"`, `'…'` and backtick
* quoting, `#` comments stripped outside quotes). Two consumers sit on it:
* {@link parseRNamespaceImportFrom} (`importFrom` only; `import`,
* `importClassesFrom` and `importMethodsFrom` are deferred) and
* {@link parseRNamespaceExports} (`export`, `exportClasses`, `exportMethods`,
* `S3method`, `exportPattern`). `useDynLib` and anything under an
* `if (…)` / `else` conditional are ignored. Unbalanced or garbled input never
* throws: a malformed directive is discarded and scanning RESYNCHRONISES at the
* next line that begins, at column 0, with `identifier(` (see
* {@link resyncAfterMalformed}); everything parsed before and after is kept.
* Quoted strings never span lines, and a column-0 `identifier(` line ends any
* argument list still open, so a broken directive cannot swallow those after it.
*
* Runtime import direction is `language-config.ts` → this file; any type from
* `language-config.ts` must be taken with `import type` only, so no runtime
* import cycle forms.
*
* The second half of the file binds those entries to workspace definitions:
* {@link populateRNamespaceImports} synthesises `named` imports for
* `importFrom()` names that a *local* package provides, and
* {@link resolveRImportTarget}'s `named` branch (`import-resolvers/r.ts`)
* resolves them using {@link rFileTopLevel} / {@link rPackageDirForFile}.
*
* The file also hosts the pieces of `pkg::name()` qualifier handling that the
* global-name-fallback veto ({@link isRGlobalNameFallbackPlausible}) shares
* with `qualified-call.ts`: {@link parseRQualifier} and the per-package
* definition-count registry. The qualified-call decisions themselves live in
* `qualified-call.ts`.
*/
import type { ParsedFile, ParsedImport } from 'gitnexus-shared';
import type { RPackageConfig } from './package-config.js';
import { unescapeRString } from './export-pattern.js';
import { resolveRNamedImport } from '../../import-resolvers/r.js';
/** One `importFrom(pkg, name)` pair, in NAMESPACE file order. */
export interface RNamespaceImportFromEntry {
readonly pkg: string;
readonly name: string;
}
const IMPORT_FROM_DIRECTIVES: ReadonlySet<string> = new Set(['importFrom']);
/** Directives whose arguments name exported symbols (`S3method` is handled separately). */
const EXPORT_NAME_DIRECTIVES: ReadonlySet<string> = new Set([
'export',
'exportClasses',
'exportMethods',
]);
const EXPORT_DIRECTIVES: ReadonlySet<string> = new Set([
...EXPORT_NAME_DIRECTIVES,
'S3method',
'exportPattern',
]);
/** Directives whose following statement is conditional and therefore ignored. */
const CONDITIONAL_HEADS: ReadonlySet<string> = new Set(['if', 'else']);
const isIdentStart = (ch: string): boolean => /[A-Za-z_.]/.test(ch);
const isIdentPart = (ch: string): boolean => /[A-Za-z0-9_.]/.test(ch);
const isQuote = (ch: string): boolean => ch === '"' || ch === "'" || ch === '`';
/**
* Advance past a quoted region starting at `start` (which holds the opening
* quote). Returns the index just after the closing quote, or `-1` when the
* quote does not close on the same line. Backslash escapes the next character
* (R string rules; harmless inside backticks). Quotes never span lines: a
* NAMESPACE directive argument is a one-line token, and letting a stray quote
* run on would pair with a later one and swallow the directives between them.
*/
function skipQuoted(text: string, start: number): number {
const quote = text[start];
for (let i = start + 1; i < text.length; i++) {
const ch = text[i];
if (ch === '\n') return -1;
if (ch === '\\') {
i++;
if (text[i] === '\n') return -1;
continue;
}
if (ch === quote) return i + 1;
}
return -1;
}
function skipLineComment(text: string, start: number): number {
const nl = text.indexOf('\n', start);
return nl === -1 ? text.length : nl + 1;
}
/** A directive-looking line start: `identifier(` (no leading whitespace, no space before `(`). */
const DIRECTIVE_LINE_START = /[A-Za-z_.][A-Za-z0-9_.]*\(/y;
/** True when `i` is the start of a line (column 0) that begins with `identifier(`. */
function isDirectiveLineStart(text: string, i: number): boolean {
if (i > 0 && text[i - 1] !== '\n') return false;
DIRECTIVE_LINE_START.lastIndex = i;
return DIRECTIVE_LINE_START.test(text);
}
/**
* Resynchronisation point after a malformed directive that began at `from`: the
* start of the first line AFTER the line containing `from` that begins, at
* column 0, with `identifier(`; `text.length` when there is none. The result
* is always greater than `from` (or the end of text), so callers make forward
* progress and each failure costs at most one pass over the text it skips.
*/
function resyncAfterMalformed(text: string, from: number): number {
let nl = text.indexOf('\n', from);
while (nl !== -1) {
const lineStart = nl + 1;
if (isDirectiveLineStart(text, lineStart)) return lineStart;
nl = text.indexOf('\n', lineStart);
}
return text.length;
}
/**
* Parse a balanced `( … )` group whose opening paren is at `openIdx`. Returns
* the comma-separated top-level arguments (raw, comment-stripped, unquoted only
* at the outer layer) and the index just after the closing paren, or `null`
* when the group is unbalanced, holds an unterminated quote, or is still open
* when a line starting `identifier(` at column 0 begins (which bounds the scan,
* so an unbalanced group cannot run to the end of the file).
*/
function parseArgs(text: string, openIdx: number): { args: string[]; end: number } | null {
const args: string[] = [];
let current = '';
let depth = 0;
for (let i = openIdx; i < text.length; ) {
if (i > openIdx && isDirectiveLineStart(text, i)) return null;
const ch = text[i];
if (ch === '#') {
i = skipLineComment(text, i);
continue;
}
if (isQuote(ch)) {
const end = skipQuoted(text, i);
if (end === -1) return null;
current += text.slice(i, end);
i = end;
continue;
}
if (ch === '(') {
depth++;
if (depth > 1) current += ch;
i++;
continue;
}
if (ch === ')') {
depth--;
if (depth === 0) {
args.push(current);
return { args, end: i + 1 };
}
current += ch;
i++;
continue;
}
if (ch === ',' && depth === 1) {
args.push(current);
current = '';
i++;
continue;
}
current += ch;
i++;
}
return null;
}
/**
* Strip whitespace and one layer of surrounding quotes/backticks from a raw
* argument. Returns `''` for empty or non-simple arguments (unterminated quote,
* or anything that is not a single bare/quoted token) so callers can skip them.
*/
function normaliseArg(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
const first = trimmed[0];
if (isQuote(first)) {
if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== first) return '';
const end = skipQuoted(trimmed, 0);
if (end !== trimmed.length) return '';
return trimmed.slice(1, -1).trim();
}
// Bare token: reject anything containing whitespace, quotes, or parens (e.g. `x = y`, nested calls).
if (/[\s"'`()=]/.test(trimmed)) return '';
return trimmed;
}
/**
* Skip one statement following an `if (…)` / `else`: either a `{ … }` block or a
* single directive call. Returns the index to resume scanning from.
*/
function skipConditionalBody(text: string, from: number): number {
let i = from;
while (i < text.length && /\s/.test(text[i])) i++;
if (i >= text.length) return i;
if (text[i] === '{') {
const blockStart = i;
let depth = 0;
for (; i < text.length; ) {
const ch = text[i];
if (ch === '#') {
i = skipLineComment(text, i);
continue;
}
if (isQuote(ch)) {
const end = skipQuoted(text, i);
if (end === -1) return resyncAfterMalformed(text, blockStart);
i = end;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return i + 1;
}
i++;
}
return resyncAfterMalformed(text, blockStart); // unterminated `{ … }`
}
if (isIdentStart(text[i])) {
let j = i + 1;
while (j < text.length && isIdentPart(text[j])) j++;
const head = text.slice(i, j);
if (CONDITIONAL_HEADS.has(head)) return j; // `else if (…)` — let the main loop handle the chain.
while (j < text.length && /\s/.test(text[j])) j++;
if (text[j] === '(') {
const group = parseArgs(text, j);
return group ? group.end : resyncAfterMalformed(text, i);
}
return j;
}
return i;
}
/** One top-level NAMESPACE directive: its head and its raw (comment-stripped) arguments. */
interface RawDirective {
readonly head: string;
readonly args: readonly string[];
}
/**
* Tokenise NAMESPACE text into the top-level directives whose head is in
* `heads`, in file order. Conditional (`if` / `else`) bodies are skipped, and
* directives nested inside another directive's arguments are never emitted.
* Never throws. A malformed directive (unbalanced group, unterminated quote,
* unterminated `{` block) is discarded and scanning resumes at the next line
* starting `identifier(` at column 0 ({@link resyncAfterMalformed}); everything
* before and after is kept. Directives indented after a malformed one are
* dropped with it.
*/
function scanRNamespaceDirectives(text: string, heads: ReadonlySet<string>): RawDirective[] {
const directives: RawDirective[] = [];
let i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === '#') {
i = skipLineComment(text, i);
continue;
}
if (isQuote(ch)) {
const end = skipQuoted(text, i);
if (end === -1) {
i = resyncAfterMalformed(text, i); // stray unterminated quote
continue;
}
i = end;
continue;
}
if (!isIdentStart(ch)) {
i++;
continue;
}
let j = i + 1;
while (j < text.length && isIdentPart(text[j])) j++;
const head = text.slice(i, j);
let k = j;
while (k < text.length && /\s/.test(text[k])) k++;
if (text[k] !== '(') {
// `else` may be followed directly by a directive with no parens of its own.
if (head === 'else') {
i = skipConditionalBody(text, k);
continue;
}
i = j;
continue;
}
const group = parseArgs(text, k);
if (!group) {
i = resyncAfterMalformed(text, i); // discard this directive, resume at the next one
continue;
}
i = group.end;
if (CONDITIONAL_HEADS.has(head)) {
i = skipConditionalBody(text, i);
continue;
}
if (heads.has(head)) directives.push({ head, args: group.args });
}
return directives;
}
/**
* Extract every `importFrom(pkg, name, ...)` pair from NAMESPACE text, in file
* order. Never throws; returns `[]` for empty, garbled or unbalanced input.
*/
export function parseRNamespaceImportFrom(text: string): RNamespaceImportFromEntry[] {
const entries: RNamespaceImportFromEntry[] = [];
for (const { args } of scanRNamespaceDirectives(text, IMPORT_FROM_DIRECTIVES)) {
const values = args.map(normaliseArg);
const pkg = values[0];
if (!pkg) continue;
for (let n = 1; n < values.length; n++) {
if (values[n]) entries.push({ pkg, name: values[n] });
}
}
return entries;
}
/** What a NAMESPACE file exports, as read by {@link parseRNamespaceExports}. */
export interface RNamespaceExports {
/** Names from `export()`, `exportClasses()`, `exportMethods()` and `S3method()` (the third argument, else `generic.class`), in file order. */
readonly namedExports: readonly string[];
/** `exportPattern()` arguments, R-unescaped (an R string `"\\."` yields the regex source `\.`), in file order. */
readonly exportPatterns: readonly string[];
}
/**
* The single-token string argument of `exportPattern()`, R-unescaped, or `''`
* when the argument is empty, not a quoted string, or unterminated. Only `"`
* and `'` quote a string in R (a backticked name is a symbol, not a pattern).
*/
function exportPatternArg(raw: string): string {
const trimmed = raw.trim();
const quote = trimmed[0];
if (quote !== '"' && quote !== "'") return '';
if (skipQuoted(trimmed, 0) !== trimmed.length) return '';
return unescapeRString(trimmed.slice(1, -1));
}
/**
* Extract the export directives from NAMESPACE text: `export`, `exportClasses`
* and `exportMethods` names, `S3method(generic, class[, method])` as `method`
* (else `generic.class`), and `exportPattern("…")` sources. Multi-line, quoted,
* backticked, commented and CRLF input is handled like {@link
* parseRNamespaceImportFrom}. Non-simple arguments (named, nested calls) are
* skipped. Never throws; returns empty lists for empty or garbled input.
*/
export function parseRNamespaceExports(text: string): RNamespaceExports {
const namedExports: string[] = [];
const exportPatterns: string[] = [];
for (const { head, args } of scanRNamespaceDirectives(text, EXPORT_DIRECTIVES)) {
if (head === 'exportPattern') {
for (const raw of args) {
const pattern = exportPatternArg(raw);
if (pattern) exportPatterns.push(pattern);
}
} else if (head === 'S3method') {
const generic = normaliseArg(args[0] ?? '');
const cls = normaliseArg(args[1] ?? '');
if (generic && cls) {
// The optional third argument names the function registered as the method;
// without it the method is `generic.class`.
const method = normaliseArg(args[2] ?? '');
namedExports.push(method || `${generic}.${cls}`);
}
} else {
for (const raw of args) {
const name = normaliseArg(raw);
if (name) namedExports.push(name);
}
}
}
return { namedExports, exportPatterns };
}
// ─── Package lookup ─────────────────────────────────────────────────────────
/** A local R package located by {@link rPackageDirForFile}. */
export interface RPackageLocation {
/** `Package:` name from `DESCRIPTION`. */
readonly name: string;
/** Package directory relative to the repo root; `''` for a root-level package. */
readonly dir: string;
}
/** `R/` directory prefix of a package dir (`'R/'` for a root-level package). */
export function rRDirPrefix(pkgDir: string): string {
return pkgDir === '' ? 'R/' : `${pkgDir}/R/`;
}
/**
* The local package whose `<pkgDir>/R/**` contains `filePath`, or `undefined`.
*
* NAMESPACE effects apply only to a package's `R/` sources (R loads nothing
* else into the namespace), so files under `tests/`, `scripts/`, `inst/`, a
* root-level `plumber.R`, … belong to no package here. When package dirs nest,
* the longest matching dir wins. A root-level package (`dir === ''`) is
* handled without ever building a `'' + '/'` prefix.
*/
export function rPackageDirForFile(
filePath: string,
cfg: RPackageConfig | null | undefined,
): RPackageLocation | undefined {
if (cfg === null || cfg === undefined) return undefined;
const normalized = filePath.replace(/\\/g, '/');
let best: RPackageLocation | undefined;
for (const [name, dir] of cfg.packages) {
if (!normalized.startsWith(rRDirPrefix(dir))) continue;
if (best === undefined || dir.length > best.dir.length) best = { name, dir };
}
return best;
}
/**
* The directory of the package whose `R/` sources contain `filePath`, or `undefined`.
*
* Unlike {@link rPackageDirForFile} this considers every directory that has a NAMESPACE as
* well as every registered package, so the nearest enclosing package owns a file and a nested
* package without a NAMESPACE never inherits the enclosing package's. The longest matching
* directory wins; the root package (`''`) is matched without building a `'' + '/'` prefix.
*/
export function rOwningPackageDir(
filePath: string,
cfg: RPackageConfig | null | undefined,
): string | undefined {
if (cfg === null || cfg === undefined) return undefined;
const normalized = filePath.replace(/\\/g, '/');
let best: string | undefined;
const consider = (dir: string): void => {
if (!normalized.startsWith(rRDirPrefix(dir))) return;
if (best === undefined || dir.length > best.length) best = dir;
};
for (const dir of cfg.packages.values()) consider(dir);
for (const dir of cfg.namespaceInfoByPackageDir.keys()) consider(dir);
return best;
}
/**
* True when `filePath` sits under the `R/` directory of any package discovery read a
* `DESCRIPTION` for: a registered package, a directory with a NAMESPACE, or a same-name copy
* recorded only in {@link RPackageConfig.packageDirs}. Such a file belongs to the package its
* `DESCRIPTION` names, whatever its directory is called.
*/
export function rFileBelongsToDiscoveredPackage(
filePath: string,
cfg: RPackageConfig | null | undefined,
): boolean {
if (cfg === null || cfg === undefined) return false;
if (rOwningPackageDir(filePath, cfg) !== undefined) return true;
if (cfg.packageDirs === undefined) return false;
const normalized = filePath.replace(/\\/g, '/');
for (const dir of cfg.packageDirs) if (normalized.startsWith(rRDirPrefix(dir))) return true;
return false;
}
// ─── Top-level definitions of a parsed file ────────────────────────────────
/** Def kinds an R `importFrom()` (or a `pkg::name()` call) can bind to (functions and R6/R5/S4 classes). */
export const TOP_LEVEL_BINDABLE_TYPES: ReadonlySet<string> = new Set(['Function', 'Class']);
/** What one R file defines at top level, read from its Module scope. */
export interface RFileTopLevel {
/** Names of top-level function/class definitions, exactly as bound (backticks kept). */
readonly names: ReadonlySet<string>;
/** For each dotted name in {@link names}, the text after its last `.`. */
readonly dottedTails: ReadonlySet<string>;
}
const EMPTY_TOP_LEVEL: RFileTopLevel = { names: new Set(), dottedTails: new Set() };
const topLevelCache = new WeakMap<ParsedFile, RFileTopLevel>();
/**
* Top-level definitions of `parsed`, read from the Module scope's hoisted
* bindings.
*
* Since R scope queries anchor a `@scope.function` on every named function, a top-level function def is
* owned by its *own* Function scope and only its binding is hoisted into the
* Module scope, so neither the Module scope's `ownedDefs` nor the flat
* `localDefs` (which also lists nested functions and R6/R5 members) answers
* "is `name` defined at top level?". A `local`-origin Module binding to a
* function/class def does. Bindings are read at extraction time, before
* `finalizeScopeModel` adds import/wildcard bindings.
*/
export function rFileTopLevel(parsed: ParsedFile): RFileTopLevel {
const cached = topLevelCache.get(parsed);
if (cached !== undefined) return cached;
const moduleScope = parsed.scopes.find((s) => s.id === parsed.moduleScope);
let result = EMPTY_TOP_LEVEL;
if (moduleScope !== undefined) {
const names = new Set<string>();
const dottedTails = new Set<string>();
for (const [name, refs] of moduleScope.bindings) {
if (!refs.some((r) => r.origin === 'local' && TOP_LEVEL_BINDABLE_TYPES.has(r.def.type))) {
continue;
}
names.add(name);
const dot = name.lastIndexOf('.');
if (dot >= 0) dottedTails.add(name.slice(dot + 1));
}
result = { names, dottedTails };
}
topLevelCache.set(parsed, result);
return result;
}
// ─── Import synthesis (populateWorkspaceReferences) ─────────────────────────
/**
* Per local package, the set of names it defines at top level in its `R/`
* files, recorded by {@link populateRNamespaceImports} for the packages that
* take part in an `importFrom()` (as caller or as provider). Keyed by the
* `resolutionConfig` object, which reaches both this hook and the
* free-call-fallback veto. A missing entry means "cannot decide".
*/
const packageTopLevelNames = new WeakMap<object, ReadonlyMap<string, ReadonlySet<string>>>();
/** Recorded top-level names per package for `resolutionConfig`, if the hook ran with imports. */
export function rRecordedPackageTopLevelNames(
resolutionConfig: unknown,
): ReadonlyMap<string, ReadonlySet<string>> | undefined {
if (typeof resolutionConfig !== 'object' || resolutionConfig === null) return undefined;
return packageTopLevelNames.get(resolutionConfig);
}
interface CallerPlan {
readonly dir: string;
/** Surviving `name → provider package` after last-wins / self-drop / external-skip. */
readonly bindable: ReadonlyMap<string, string>;
}
/**
* Synthesise `named` imports for NAMESPACE `importFrom(pkg, name)` entries
* whose `pkg` is a local package, into every `R/` file of the caller package.
*
* NOTE: `populateWorkspaceReferences` is documented as reference-site
* enrichment; it is reused here (same call site and ctx as the Zig static
* gating precedent) because it runs before `finalizeScopeModel`, receives the
* mutable `ParsedFile[]`, and also runs over warm-cache ParsedFiles, so the
* synthesised imports are never persisted stale. If this is ever upstreamed,
* propose widening that contract's doc string.
*
* Order of operations per caller package:
* 1. last-wins over ALL entries for a name, in file order, external
* packages included (R replaces the earlier `importFrom` binding);
* 2. drop the surviving entry when its `pkg` is the caller itself;
* 3. skip it when its `pkg` is not a local package (an earlier local entry
* never resurrects);
* 4. skip it when the caller's own package defines `name` at top level in
* any `R/` file (the own namespace masks its imports; `rMergeBindings`
* only shadows within a single scope);
* 5. append a `named` import to every `R/` file of the caller package.
*/
export function populateRNamespaceImports(
parsedFiles: ParsedFile[],
ctx: { readonly resolutionConfig?: unknown },
): void {
const cfg = (ctx.resolutionConfig as RPackageConfig | null | undefined) ?? null;
if (cfg === null || cfg.packages.size === 0) return;
// Steps 1-3: collapse each caller's entries; note which packages we need names for.
const plans = new Map<string, CallerPlan>();
const needNamesFor = new Set<string>();
for (const [callerName, dir] of cfg.packages) {
const info = cfg.namespaceInfoByPackageDir.get(dir);
if (info === undefined || info.importFrom.length === 0) continue;
const last = new Map<string, string>();
for (const entry of info.importFrom) last.set(entry.name, entry.pkg);
const bindable = new Map<string, string>();
for (const [name, pkg] of last) {
if (cfg.packages.has(pkg)) needNamesFor.add(pkg); // the fallback veto reads the provider's names too
if (pkg === callerName || !cfg.packages.has(pkg)) continue;
bindable.set(name, pkg);
}
if (bindable.size === 0) continue;
plans.set(callerName, { dir, bindable });
needNamesFor.add(callerName);
}
if (plans.size === 0) return;
// One pass over the parsed files: group caller files, collect top-level names.
const namesByPackage = new Map<string, Set<string>>();
const callerFiles = new Map<string, number[]>();
for (let i = 0; i < parsedFiles.length; i++) {
const location = rPackageDirForFile(parsedFiles[i].filePath, cfg);
if (location === undefined) continue;
if (needNamesFor.has(location.name)) {
let names = namesByPackage.get(location.name);
if (names === undefined) namesByPackage.set(location.name, (names = new Set()));
for (const name of rFileTopLevel(parsedFiles[i]).names) names.add(name);
}
if (plans.has(location.name)) {
const indices = callerFiles.get(location.name);
if (indices === undefined) callerFiles.set(location.name, [i]);
else indices.push(i);
}
}
for (const pkg of needNamesFor) if (!namesByPackage.has(pkg)) namesByPackage.set(pkg, new Set());
packageTopLevelNames.set(cfg, namesByPackage);
// Steps 4-5.
for (const [callerName, plan] of plans) {
const ownNames = namesByPackage.get(callerName);
const synthesised: Extract<ParsedImport, { kind: 'named' }>[] = [];
for (const [name, pkg] of plan.bindable) {
if (ownNames?.has(name) === true) continue;
// `null` means the name can never bind. A synthesised import for it would stay unresolved
// yet count as an explicit name claim, suppressing the documented `global-name-fallback`.
// (Importing the resolver forms a cycle with import-resolvers/r.ts; every use is deferred
// to call time, so it is safe at runtime.)
if (resolveRNamedImport(cfg, pkg, name, parsedFiles) === null) continue;
synthesised.push({ kind: 'named', localName: name, importedName: name, targetRaw: pkg });
}
if (synthesised.length === 0) continue;
for (const index of callerFiles.get(callerName) ?? []) {
const parsed = parsedFiles[index];
const fresh = synthesised.filter(
(imp) =>
!parsed.parsedImports.some(
(existing) =>
existing.kind === 'named' &&
existing.localName === imp.localName &&
existing.targetRaw === imp.targetRaw,
),
);
if (fresh.length === 0) continue;
parsedFiles[index] = Object.freeze({
...parsed,
parsedImports: Object.freeze([...parsed.parsedImports, ...fresh]),
});
}
}
}
// ─── `pkg::name()` qualifier helpers ────────────────────────────────────────
/**
* The package named by the text of an `@reference.qualified-name` capture (the
* whole `namespace_operator`, e.g. `pkg::f`, `pkg:::f`, `pkg ::: f`,
* `"pkg"::f`, `` `pkg`::f ``): the text before the first `::` / `:::`,
* trimmed, with one pair of surrounding `"`, `'` or backticks removed.
* `undefined` when there is no `::` or the package part is empty. The
* function name is not parsed here: it is the site's own `name`.
*
* Lives here (not in `qualified-call.ts`, which re-exports it) because the
* veto below needs it and `qualified-call.ts` imports this file.
*/
export function parseRQualifier(raw: string): string | undefined {
const match = /^([\s\S]*?)\s*:::?/.exec(raw);
if (match === null) return undefined;
let pkg = match[1].trim();
if (pkg.length >= 2 && /^(["'`])[\s\S]*\1$/.test(pkg)) pkg = pkg.slice(1, -1);
return pkg.length === 0 ? undefined : pkg;
}
/**
* Per local package, how many top-level definitions of each name it has in its
* `R/` files, recorded by `populateRQualifiedCalls` for the packages that some
* `pkg::name()` call in the workspace names. Keyed by the `resolutionConfig`
* object (like {@link packageTopLevelNames}), which reaches both that hook and
* the free-call-fallback veto. A missing entry means "cannot decide".
*/
const qualifiedDefinitionCounts = new WeakMap<
object,
ReadonlyMap<string, ReadonlyMap<string, number>>
>();
/** Record (replacing any earlier record) the per-package definition counts for `resolutionConfig`. */
export function rRecordQualifiedDefinitionCounts(
resolutionConfig: object,
counts: ReadonlyMap<string, ReadonlyMap<string, number>>,
): void {
qualifiedDefinitionCounts.set(resolutionConfig, counts);
}
/**
* Number of top-level definitions of `name` recorded for local package `pkg`,
* or `undefined` when nothing was recorded (hook did not run for that package).
*/
export function rQualifiedDefinitionCount(
resolutionConfig: object,
pkg: string,
name: string,
): number | undefined {
const byName = qualifiedDefinitionCounts.get(resolutionConfig)?.get(pkg);
return byName === undefined ? undefined : (byName.get(name) ?? 0);
}
// ─── Global-name fallback veto (isGlobalNameFallbackPlausible) ──────────────
/**
* Structural subset of the hook context `isRGlobalNameFallbackPlausible`
* reads; the shared contract's full context is assignable to it.
*/
export interface RGlobalNameFallbackContext {
readonly callerParsed: { readonly filePath: string };
readonly candidate: { readonly filePath: string };
/** Opaque `loadResolutionConfig` result; an {@link RPackageConfig} for R. */
readonly resolutionConfig?: unknown;
readonly site: { readonly name: string; readonly rawQualifiedName?: string };
}
/**
* Refuse a `global-name-fallback` guess that the caller package's NAMESPACE
* contradicts. Returns `false` (veto) only when ALL of these hold:
*
* - the caller file lives under `<pkgDir>/R/**` of a package with a NAMESPACE
* that has an `importFrom(P, <site.name>)` entry (the LAST such entry over
* all entries, external packages included — the same last-wins rule the synthesised imports follow);
* - `P` is not the caller's own package (a self-import binds nothing);
* - the candidate lives under some package's `R/**`, and that package is
* neither the caller's own (own namespace masks; a plain 0.5 fallback edge
* until own-package binding exists) nor `P`;
* - if `P` is local, it defines `site.name` at top level; otherwise `P`
* re-exports the name from a third package and the candidate may be the
* real origin (unknown per-package names = cannot decide = allow).
*
* Everything else — no config, caller outside a package `R/` (`tests/`,
* `scripts/`, root `plumber.R`), no entry, candidate outside any package
* `R/` — answers `true`, so `source()`/`library()` script flows are untouched.
*
* A qualified call `pkg::name()` (site carries `rawQualifiedName`) is judged
* by its qualifier first: with `pkg` a local package, a candidate
* inside `pkg` is plausible whatever the NAMESPACE says, and a candidate
* elsewhere is vetoed when `pkg` itself defines `name`. A `pkg` that defines
* nothing of that name (re-export), or that is not a local package, falls
* through to the NAMESPACE rules above.
*
* Known unsound shape, pinned by a characterisation test: `import(pkg)` is not
* tokenised, so a later `import(pkg)` that R would prefer is invisible.
*/
export function isRGlobalNameFallbackPlausible(ctx: RGlobalNameFallbackContext): boolean {
const cfg = ctx.resolutionConfig as RPackageConfig | null | undefined;
if (
typeof cfg !== 'object' ||
cfg === null ||
!(cfg.packages instanceof Map) ||
!(cfg.namespaceInfoByPackageDir instanceof Map)
) {
return true;
}
// Qualified call `pkg::name()`: the source named the package, so the package
// outranks the caller's NAMESPACE. A candidate inside a local `pkg` is right;
// a candidate elsewhere contradicts the qualifier when `pkg` defines `name`
// itself. A `pkg` that defines nothing of that name (a re-export) or that has
// no recorded counts cannot be judged from here: fall through to the rules
// below, i.e. today's behaviour. An unknown or external `pkg` falls through
// too (external sites are dropped upstream, before any fallback tier).
const qualifier =
ctx.site.rawQualifiedName === undefined
? undefined
: parseRQualifier(ctx.site.rawQualifiedName);
if (qualifier !== undefined && cfg.packages.has(qualifier)) {
if (rPackageDirForFile(ctx.candidate.filePath, cfg)?.name === qualifier) return true;
if ((rQualifiedDefinitionCount(cfg, qualifier, ctx.site.name) ?? 0) > 0) return false;
}
const caller = rPackageDirForFile(ctx.callerParsed.filePath, cfg);
if (caller === undefined) return true;
const importFrom = cfg.namespaceInfoByPackageDir.get(caller.dir)?.importFrom;
if (importFrom === undefined) return true;
let lastEntry: RNamespaceImportFromEntry | undefined;
for (const entry of importFrom) if (entry.name === ctx.site.name) lastEntry = entry;
if (lastEntry === undefined) return true;
const importedFrom = lastEntry.pkg;
if (importedFrom === caller.name) return true;
const candidatePkg = rPackageDirForFile(ctx.candidate.filePath, cfg);
if (candidatePkg === undefined) return true;
if (candidatePkg.name === caller.name || candidatePkg.name === importedFrom) return true;
if (cfg.packages.has(importedFrom)) {
const providerNames = rRecordedPackageTopLevelNames(cfg)?.get(importedFrom);
if (providerNames === undefined || !providerNames.has(ctx.site.name)) return true;
}
return false;
}

View file

@ -0,0 +1,187 @@
/**
* Which argument of an R call names what the call defines or imports.
*
* `R_QUERIES` captures every string-valued argument of `setClass`,
* `setRefClass`, `setGeneric` and `setMethod`, and the scope query captures every
* identifier- or string-valued argument of `library`, `require` and `source` and
* every string argument of `setClass`/`setRefClass`. Only one of them is the
* definition's name or the import's source; the rest are `contains = "VIRTUAL"`,
* `valueClass = "numeric"`, the class slot of `setMethod("show", "Foo", ...)`,
* `lib.loc = libO`, and so on. The choice is made here, in code, rather than in the
* query: a tree-sitter query can only skip an unbounded run of leading
* comments and named arguments with a repeated sibling group, and that costs
* time quadratic in the argument count on every call in the file (a single
* `list(a0 = 0, ...)` with 16000 arguments took 18s). This check is linear: it
* reads each argument list once and caches the answer.
*
* R matches arguments by name first, then positionally. The naming argument is
* therefore the argument spelled with the call's first formal (`Class = "A"`),
* or, when no argument anywhere in the list is spelled that way, the first
* unnamed argument. Comments, commas and other named arguments may precede it,
* and a `Class = ...` after an unnamed argument takes the formal from it, so
* `setClass(a = "Q", "B", Class = "A")` names `A` only.
*
* A formal may be spelled bare, in backticks or in quotes (`Class`, `` `Class` ``,
* `"Class"`); all name the same argument. Partial matching of an abbreviated
* name (`Cl = "A"`) is not modelled.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { CaptureMap } from '../../language-provider.js';
/** The first formal of each call whose first argument names what it defines or imports. */
const NAMING_FORMAL: Readonly<Record<string, string>> = {
setClass: 'Class',
setRefClass: 'Class',
setGeneric: 'name',
setMethod: 'f',
library: 'package',
require: 'package',
source: 'file',
};
/** An argument's name with one pair of surrounding backticks or quotes removed. */
function argumentNameText(name: SyntaxNode): string {
const text = name.text;
if (text.length >= 2) {
const first = text[0];
if ((first === '`' || first === '"' || first === "'") && text[text.length - 1] === first) {
return text.slice(1, -1);
}
}
return text;
}
/**
* Per tree, per (argument list, formal): the id of the unnamed argument that takes
* the formal, or null when none does. One pass over the argument list answers every
* later match in that list in constant time. Looking backwards from each match
* instead is quadratic in the number of preceding comments (sibling lookup is
* linear in the sibling's position), so the list is read forwards, once.
*/
const namingArgumentIds = new WeakMap<object, Map<string, number | null>>();
function namingUnnamedArgumentId(argumentList: SyntaxNode, formal: string): number | null {
let perTree = namingArgumentIds.get(argumentList.tree);
if (perTree === undefined) {
perTree = new Map();
namingArgumentIds.set(argumentList.tree, perTree);
}
const key = `${argumentList.id}:${formal}`;
const cached = perTree.get(key);
if (cached !== undefined) return cached;
// The first unnamed argument takes the formal, unless an argument anywhere in the
// list is spelled with it (R matches by name first), which retracts that choice.
let found: number | null = null;
for (const child of argumentList.namedChildren) {
if (child.type !== 'argument') continue; // comment, comma
const name = child.childForFieldName('name');
if (name === null) {
found ??= child.id;
} else if (argumentNameText(name) === formal) {
found = null;
break;
}
}
perTree.set(key, found);
return found;
}
/**
* The argument R would match to `target` in a call to a function whose formals are
* `formals`, in order. R matches named arguments first (a name that spells a formal
* binds it) and then fills the formals that remain from the unnamed arguments, in
* order. Partial matching of abbreviated names (`sig =`) is not modelled. Returns
* null when no argument reaches `target`. Linear in the argument count; meant for
* the one call a definition hook is looking at, not for every call in a file.
*/
export function getRMatchedArgument(
argumentList: SyntaxNode,
formals: readonly string[],
target: string,
): SyntaxNode | null {
const unnamed: SyntaxNode[] = [];
const named = new Set<string>();
for (const child of argumentList.namedChildren) {
if (child.type !== 'argument') continue; // comment, comma
const name = child.childForFieldName('name');
if (name === null) {
unnamed.push(child);
continue;
}
const text = argumentNameText(name);
if (text === target) return child;
named.add(text);
}
const remaining = formals.filter((formal) => !named.has(formal));
const index = remaining.indexOf(target);
return index === -1 ? null : (unnamed[index] ?? null);
}
/**
* True when `argument` (an `argument` node) is the one that names the call's
* definition, given the call's first `formal`.
*/
export function isRNamingArgument(argument: SyntaxNode, formal: string): boolean {
const ownName = argument.childForFieldName('name');
if (ownName !== null) return argumentNameText(ownName) === formal;
const argumentList = argument.parent;
if (argumentList === null) return false;
return namingUnnamedArgumentId(argumentList, formal) === argument.id;
}
/**
* True when a `R_QUERIES` match captured an argument of one of the calls above
* that is NOT the naming argument, so the definition it would mint is spurious.
* Matches of any other shape (R6, functions, slots, calls, imports) are never
* skipped. The legacy `@import` matches are discarded by the parse worker, so
* they are not considered here.
*/
export function isRNonNamingArgumentCapture(captureMap: CaptureMap): boolean {
const fn = captureMap['_fn'];
if (fn === undefined) return false;
const formal = NAMING_FORMAL[fn.text];
if (formal === undefined) return false;
// Definitions capture the string's content. Slot matches capture a named
// argument's identifier and are not ours.
const captured = captureMap['name'];
if (captured === undefined || captured.type !== 'string_content') return false;
const argument = captured.parent?.parent;
if (argument === undefined || argument === null || argument.type !== 'argument') return false;
return !isRNamingArgument(argument, formal);
}
/** The captures of a raw tree-sitter match, as the scope emitter sees them. */
interface RawCapture {
readonly name: string;
readonly node: SyntaxNode;
}
/** Callee captures of the scope query's naming-argument patterns (`r/query.ts`). */
const SCOPE_CALLEE_CAPTURES: ReadonlySet<string> = new Set(['_s4_decl', '_srcfn', '_libfn']);
/**
* True when a scope-query match captured an argument of `setClass` /
* `setRefClass` (`@declaration.name`), `library` / `require` or `source`
* (`@import.source`) that is NOT the naming argument. The scope query captures
* every such argument; the emitter drops these matches.
*/
export function isRNonNamingScopeMatch(captures: readonly RawCapture[]): boolean {
const fn = captures.find((c) => SCOPE_CALLEE_CAPTURES.has(c.name));
if (fn === undefined) return false;
const formal = NAMING_FORMAL[fn.node.text];
if (formal === undefined) return false;
const captured = captures.find(
(c) => c.name === 'declaration.name' || c.name === 'import.source',
);
if (captured === undefined) return false;
const argument =
captured.node.type === 'string_content' ? captured.node.parent?.parent : captured.node.parent;
if (argument === undefined || argument === null || argument.type !== 'argument') return false;
return !isRNamingArgument(argument, formal);
}

View file

@ -0,0 +1,39 @@
/**
* R owner hooks for the shared ingestion pipeline.
*
* R has no class syntax: R6 / RefClass members belong to a call or assignment
* (`Foo <- R6::R6Class(...)`, `setRefClass(...)`), not to an enclosing
* container node. `resolveMemberOwnerNode` hands the member extractors that
* owner node. Members whose owner is a string (`setMethod("area", "Circle",
* ...)`) or a top-level R6/RefClass call are instead tied to their class after
* all chunks merge: `definitionPropertiesExtractor` records the owner name as
* an `ownerNameHint`, resolved by R's `postParse` hook.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { DefinitionPropertiesContext } from '../../language-provider.js';
import { findRFieldOwnerNode, getRTopLevelPropertyOwnerName } from '../../field-extractors/r.js';
import { getRTopLevelMethodOwnerName } from '../../method-extractors/r.js';
/** `LanguageProvider.resolveMemberOwnerNode` for R. */
export const rResolveMemberOwnerNode = (definitionNode: SyntaxNode): SyntaxNode | null =>
findRFieldOwnerNode(definitionNode);
/**
* `LanguageProvider.definitionPropertiesExtractor` for R: the deferred owner
* name (`ownerNameHint`) of a `Method`/`Property` whose owning class is named by
* a string argument or a top-level R6/RefClass call rather than a syntactic
* ancestor.
*/
export const rDefinitionProperties = (
ctx: DefinitionPropertiesContext,
): Readonly<Record<string, unknown>> | undefined => {
const ownerNameHint =
ctx.nodeLabel === 'Method'
? (getRTopLevelMethodOwnerName(ctx.definitionNode) ??
getRTopLevelPropertyOwnerName(ctx.definitionNode))
: ctx.nodeLabel === 'Property'
? getRTopLevelPropertyOwnerName(ctx.definitionNode)
: null;
return ownerNameHint ? { ownerNameHint } : undefined;
};

View file

@ -0,0 +1,407 @@
import type { Dirent } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import {
parseRNamespaceExports,
parseRNamespaceImportFrom,
type RNamespaceImportFromEntry,
} from './namespace-imports.js';
import { compileRExportPatternDetailed, type RExportMatcher } from './export-pattern.js';
import { SharedWorkBudget } from './linear-regex.js';
import { isDev } from '../../utils/env.js';
import { logger } from '../../../logger.js';
/** An `exportPattern()` argument that was not compiled, and why. */
export interface RDroppedExportPattern {
/** The pattern text as R would see it (the string value, after unescaping). */
readonly pattern: string;
readonly reason: string;
}
/** R package config parsed from DESCRIPTION files in a multi-package repo */
export interface RPackageConfig {
/**
* Map of package name to directory path relative to repo root. When several
* directories declare the same `Package:` name the shallowest directory wins
* and, among equal depths, the one whose whole path string sorts first by
* code unit (so `a-b/x` beats `a/x`: `-` sorts before `/`); the others are
* not entries here (their NAMESPACE is still recorded in
* {@link namespaceInfoByPackageDir}, which is keyed by directory). Installed
* libraries and check output (`renv`, `packrat`, `revdep`, `<name>.Rcheck`) are
* not searched, so their packages are not entries here.
*/
packages: Map<string, string>;
/** Package-scoped NAMESPACE config keyed by package dir relative to repo root. */
namespaceInfoByPackageDir: Map<string, RNamespaceInfo>;
/**
* Every directory (relative to the repo root, `''` for the root) in which discovery read a
* `DESCRIPTION` with a `Package:` line: the directories of {@link packages} plus the
* directories of same-name copies that lost to them, with or without a NAMESPACE. The
* qualifier-locality decision reads it, so that a file under such a directory's `R/` is not
* taken as evidence for a package named after its directory. Optional so that hand-built
* configs read as "the directories of {@link packages} and {@link namespaceInfoByPackageDir}".
*/
packageDirs?: ReadonlySet<string>;
/**
* True when discovery cannot rule out an undiscovered package: a directory
* of the main walk could not be read, or a subtree the walk skipped (below
* the depth limit, or still queued when the directory cap stopped it) may
* hide a package. Skipped subtrees are scanned once more for `DESCRIPTION`
* files (see {@link hidesUndiscoveredPackage}); the flag is set only when
* that scan finds a `Package:` name absent from {@link packages}, exceeds
* its directory budget, or hits an unreadable path. A skipped subtree with
* no package inside (`tests/testthat/fixtures/...`, `inst/extdata/...`) or
* only a same-name copy of an already-discovered package does NOT truncate.
* A package missing from {@link packages} is then not proven absent from the
* repo, so `rQualifierLocality` answers `unknown` rather than `external`.
* This holds for an empty {@link packages} map too: a complete scan that found
* no package at all proves every qualifier external.
* Optional so that hand-built configs (tests, callers that never scan) read
* as "complete".
*/
truncated?: boolean;
}
export interface RNamespaceInfo {
/** True when the package has a readable NAMESPACE file. */
hasNamespaceFile: boolean;
/** Explicit named exports from export()/exportClasses()/exportMethods()/S3method(). */
namedExports: Set<string>;
/** Precompiled matchers from exportPattern("..."), compiled once here so
* `refineRExportStatus` doesn't recompile per node it checks. POSIX bracket
* classes such as `[[:alpha:]]` are translated to JS equivalents first
* (see `compileRExportPattern`). The matchers run in linear time, so a hostile
* pattern cannot stall analysis. Patterns that fail to compile, or that the
* linear-time matcher cannot handle, are dropped at this stage (they never match)
* and listed in {@link droppedExportPatterns}. */
exportPatterns: RExportMatcher[];
/**
* Non-empty `exportPattern()` arguments that could not be compiled, with the reason.
* A dropped pattern exports nothing, so every name the pattern was meant to cover
* reads as unexported; `reportDroppedRExportPatterns` turns these into warnings.
* Optional so that hand-built configs read as "nothing dropped".
*/
droppedExportPatterns?: readonly RDroppedExportPattern[];
/**
* How many further non-empty `exportPattern()` arguments were dropped but not listed in
* {@link droppedExportPatterns}, which holds at most {@link MAX_LISTED_DROPPED_PATTERNS}
* per package so that a NAMESPACE of hundreds of thousands of refused patterns cannot
* produce hundreds of thousands of warnings. Reported as one line.
*/
omittedDroppedExportPatterns?: number;
/**
* The work budget this package's matchers draw on: a sub-budget of the load's. Absent on
* hand-built configs. {@link rExportPatternMatches} stops consulting the matchers once it
* is spent.
*/
exportPatternBudget?: SharedWorkBudget;
/** `importFrom(pkg, name)` pairs in NAMESPACE file order (all entries, incl. self-imports and duplicates). */
importFrom: readonly RNamespaceImportFromEntry[];
}
/**
* Most patterns named in warnings per package, for dropped patterns (the rest are counted,
* see `omittedDroppedExportPatterns`) and for patterns cut short by the budget alike, so
* that a NAMESPACE of tens of thousands of patterns cannot produce as many warnings.
*/
export const MAX_LISTED_DROPPED_PATTERNS = 50;
/**
* True when one of the package's `exportPattern()` matchers matches `name`. Once the
* package's work budget (or the load's) is spent it stops asking the matchers at all and
* answers false, so the cost no longer grows as patterns x names; the patterns it did not
* get to run are then reported by {@link reportRExportPatternProblems}.
*/
export function rExportPatternMatches(
info: Pick<RNamespaceInfo, 'exportPatterns' | 'exportPatternBudget'>,
name: string,
): boolean {
const budget = info.exportPatternBudget;
for (const pattern of info.exportPatterns) {
if (budget?.isSpent === true) {
budget.cutShort = true;
return false;
}
if (pattern.test(name)) return true;
}
return false;
}
/** Longest pattern prefix quoted in a warning. */
const QUOTED_PATTERN_LENGTH = 60;
const quotePattern = (pattern: string): string =>
JSON.stringify(
pattern.length > QUOTED_PATTERN_LENGTH
? `${pattern.slice(0, QUOTED_PATTERN_LENGTH)}...`
: pattern,
);
/**
* Warn, once per pattern per package, about every `exportPattern()` that is not in
* effect: one dropped at load ({@link RNamespaceInfo.droppedExportPatterns}) or one
* whose matcher ran out of work budget since (names it had not yet decided were read as
* unexported). Call after the matchers have been used. A dropped pattern exports
* nothing, which would otherwise look exactly like a package that exports few names.
*/
export function reportRExportPatternProblems(config: RPackageConfig): void {
for (const [pkgDir, info] of config.namespaceInfoByPackageDir) {
const where = pkgDir === '' ? '.' : pkgDir;
for (const { pattern, reason } of info.droppedExportPatterns ?? []) {
logger.warn(
`R package ${where}: exportPattern(${quotePattern(pattern)}) was ignored (${reason}); ` +
`names it would export are treated as not exported`,
);
}
if ((info.omittedDroppedExportPatterns ?? 0) > 0) {
logger.warn(
`R package ${where}: ${info.omittedDroppedExportPatterns} further exportPattern() ` +
`arguments were ignored as well and are not listed; names they would export are ` +
`treated as not exported`,
);
}
const cutShort = info.exportPatternBudget?.cutShort === true;
let listed = 0;
let unlisted = 0;
for (const matcher of info.exportPatterns) {
if (matcher.exhausted !== true && !cutShort) continue;
if (listed >= MAX_LISTED_DROPPED_PATTERNS) {
unlisted++;
continue;
}
listed++;
logger.warn(
`R package ${where}: exportPattern(${quotePattern(matcher.source)}) exceeded its work ` +
`budget (its own, its package's or the repository's); ` +
`names it had not yet been tested against are treated as not exported`,
);
}
if (unlisted > 0) {
logger.warn(
`R package ${where}: ${unlisted} further exportPattern() arguments exceeded their work ` +
`budget as well and are not listed; names they had not yet been tested against are ` +
`treated as not exported`,
);
}
}
}
/** Directories never descended into, by the main walk and the hidden-package scan alike. */
const SKIPPED_DIR_NAMES: ReadonlySet<string> = new Set(['node_modules', '.git', '.Rproj.user']);
/**
* Directories R tooling fills with installed packages or check output: the renv project
* library, the packrat private library and the revdepcheck workspace. Each holds one
* `DESCRIPTION` per third-party package, none of which is part of the repository.
*/
const VENDORED_LIBRARY_DIR_NAMES: ReadonlySet<string> = new Set(['renv', 'packrat', 'revdep']);
/** Suffix of the `<pkg>.Rcheck` directories `R CMD check` writes. */
const RCHECK_SUFFIX = '.Rcheck';
/**
* True for a directory that discovery must not enter. The match is on the directory's own
* name, exact and case-sensitive: `renv`, `packrat` or `revdep`, or `<something>.Rcheck`
* (a name that is only the suffix is not one). Look-alikes such as `renv-tools`, `Renv`,
* `revdeps` or `pkg.rcheck` are ordinary directories and may hold a real package.
*/
function isSkippedDirName(name: string): boolean {
return (
SKIPPED_DIR_NAMES.has(name) ||
VENDORED_LIBRARY_DIR_NAMES.has(name) ||
(name.length > RCHECK_SUFFIX.length && name.endsWith(RCHECK_SUFFIX))
);
}
/**
* Directory entries in code-unit name order. `readdir` order is filesystem-dependent
* (and `localeCompare` is locale-dependent), so every walk below reads through this to
* make what it visits, and what it finds first, the same on every machine.
*/
async function readDirSorted(dir: string): Promise<Dirent[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
/**
* Sort key that ranks the directories declaring one package name: fewer path segments
* first, then the whole `/`-separated relative path compared by code unit. Comparing keys
* with `<` therefore needs no help from the walk order. The depth is zero-padded so that
* it compares as a number; `''` (the repo root) has depth 0.
*/
function preferredPackageDir(pkgDir: string): string {
const depth = pkgDir === '' ? 0 : pkgDir.split('/').length;
return `${String(depth).padStart(6, '0')}\0${pkgDir}`;
}
/** Global directory budget for {@link hidesUndiscoveredPackage} across all its roots. */
const HIDDEN_SCAN_BUDGET = 5000;
/**
* Scan the subtrees the main walk skipped for a package it never discovered.
*
* Iterative DFS over `roots` under one global `budget` of directories (skipping
* the directories {@link isSkippedDirName} names, as the main walk does). Returns
* `true` (cannot rule a hidden package out) when the budget is exceeded, when a
* directory or `DESCRIPTION` cannot be read, or when a `DESCRIPTION` declares a
* `Package:` name that is not in `knownPackageNames`. Returns `false` when every
* subtree was scanned and holds no such package. Deterministic and read-only.
* The answer is a property of the set of directories, not of the order they are
* visited in (every `true` condition is order-independent and a `false` means the
* whole forest was read), but the walk still reads sorted so that it is reproducible.
*/
export async function hidesUndiscoveredPackage(
roots: readonly string[],
knownPackageNames: ReadonlySet<string>,
budget: number = HIDDEN_SCAN_BUDGET,
): Promise<boolean> {
const stack = [...roots];
let remaining = budget;
for (let dir = stack.pop(); dir !== undefined; dir = stack.pop()) {
if (--remaining < 0) return true;
let entries;
try {
entries = await readDirSorted(dir);
} catch {
return true;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (!isSkippedDirName(entry.name)) stack.push(path.join(dir, entry.name));
} else if (entry.isFile() && entry.name === 'DESCRIPTION') {
try {
const content = await fs.readFile(path.join(dir, entry.name), 'utf-8');
const pkgMatch = content.match(/^Package:\s*(\S+)/m);
if (pkgMatch && !knownPackageNames.has(pkgMatch[1])) return true;
} catch {
return true;
}
}
}
}
return false;
}
/**
* Discover the R packages of a repo. Always returns a config: a scan that finds
* no package yields an empty {@link RPackageConfig.packages} map, and its
* {@link RPackageConfig.truncated} flag says whether that result is complete
* (no `DESCRIPTION` anywhere, so no `pkg::fn()` qualifier names a local package)
* or merely what the bounded scan managed to see.
*/
export async function loadRPackageConfig(repoRoot: string): Promise<RPackageConfig> {
const packages = new Map<string, string>();
const namespaceInfoByPackageDir = new Map<string, RNamespaceInfo>();
const packageDirs = new Set<string>();
// One allowance (matching work and NFA states) for every exportPattern of every package
// found by this load, split into a sub-budget per package: the number of patterns a
// repository carries must not multiply the per-matcher cost.
const loadBudget = new SharedWorkBudget();
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
const maxDepth = 3;
const maxDirs = 200;
let dirsScanned = 0;
let truncated = false;
// Directories the walk never visits (depth limit, cap leftovers): scanned at the end.
const unvisited: string[] = [];
while (scanQueue.length > 0 && dirsScanned < maxDirs) {
const { dir, depth } = scanQueue.shift()!;
dirsScanned++;
try {
const entries = await readDirSorted(dir);
for (const entry of entries) {
if (entry.isDirectory()) {
if (isSkippedDirName(entry.name)) continue;
const child = path.join(dir, entry.name);
if (depth < maxDepth) {
scanQueue.push({ dir: child, depth: depth + 1 });
} else {
// Below the depth limit: not visited by the walk; scanned below.
unvisited.push(child);
}
}
if (entry.isFile() && entry.name === 'DESCRIPTION') {
try {
const descPath = path.join(dir, entry.name);
const content = await fs.readFile(descPath, 'utf-8');
const pkgMatch = content.match(/^Package:\s*(\S+)/m);
if (pkgMatch) {
const pkgName = pkgMatch[1];
const pkgDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
packageDirs.add(pkgDir);
// Same name in several directories: keep the preferred one, whatever the
// order the walk met them in (see preferredPackageDir).
const current = packages.get(pkgName);
if (
current === undefined ||
preferredPackageDir(pkgDir) < preferredPackageDir(current)
) {
packages.set(pkgName, pkgDir);
if (isDev) {
logger.info(`📦 Found R package: ${pkgName} at ${pkgDir}`);
}
}
const nsPath = path.join(dir, 'NAMESPACE');
try {
const nsContent = await fs.readFile(nsPath, 'utf-8');
const parsedExports = parseRNamespaceExports(nsContent);
const namedExports = new Set<string>(parsedExports.namedExports);
// POSIX classes are translated; an uncompilable pattern is skipped (never
// matches) and recorded so the caller can say so.
const exportPatterns: RExportMatcher[] = [];
const droppedExportPatterns: RDroppedExportPattern[] = [];
let omittedDropped = 0;
// This package's share of the load's budget: one hostile package cannot
// take all of it and leave the packages after it without their patterns.
const packageBudget = loadBudget.child();
for (const pattern of new Set(parsedExports.exportPatterns)) {
const compiled = compileRExportPatternDetailed(pattern, packageBudget);
if ('reason' in compiled) {
if (droppedExportPatterns.length < MAX_LISTED_DROPPED_PATTERNS) {
droppedExportPatterns.push({ pattern, reason: compiled.reason });
} else {
omittedDropped++;
}
} else {
exportPatterns.push(compiled.matcher);
}
}
namespaceInfoByPackageDir.set(pkgDir, {
hasNamespaceFile: true,
namedExports,
exportPatterns,
droppedExportPatterns,
omittedDroppedExportPatterns: omittedDropped,
exportPatternBudget: packageBudget,
importFrom: parseRNamespaceImportFrom(nsContent),
});
} catch {
// No NAMESPACE file or can't read it
}
}
} catch {
// Can't read DESCRIPTION: the package it declares was not discovered.
truncated = true;
}
}
}
} catch {
// Can't read directory: whatever it holds was not discovered.
truncated = true;
}
}
// Directories still queued when the cap stopped the walk were never visited either.
for (const { dir } of scanQueue) unvisited.push(dir);
if (
!truncated &&
unvisited.length > 0 &&
(await hidesUndiscoveredPackage(unvisited, new Set(packages.keys())))
) {
truncated = true;
}
return { packages, namespaceInfoByPackageDir, packageDirs, truncated };
}

View file

@ -0,0 +1,161 @@
/**
* R post-parse hooks.
*
* R classes are expressed as function calls (`R6::R6Class("Name", ...)`,
* `setClass("Name", ...)`, `setMethod("fn", "ClassName", ...)`) rather than
* tree-sitter class syntax, so the worker cannot always find the enclosing
* class node via a parent walk. When that happens, the worker stores the
* owner name as an `ownerNameHint` string on the symbol/graph node.
*
* After parsing is complete and the TypeRegistry is fully populated, we
* resolve every `ownerNameHint` to a real `ownerId` and:
* 1. Patch `node.properties.ownerId`
* 2. Mutate the SymbolDefinition's `ownerId` (shared ref across registries)
* 3. Register the symbol into MutableMethodRegistry / MutableFieldRegistry
* 4. Emit the HAS_METHOD / HAS_PROPERTY graph edge
*
* NAMESPACE-aware export refinement also runs here — ExportChecker doesn't have
* a file-path context, so it defaults all R symbols to public. Once we know
* which package's `R/` directory each file lives in, we flip `isExported` to false
* for symbols that aren't in the NAMESPACE exports.
*/
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { MutableSemanticModel } from '../../model/semantic-model.js';
import type { PostParseContext } from '../../language-provider.js';
import {
loadRPackageConfig,
reportRExportPatternProblems,
rExportPatternMatches,
type RNamespaceInfo,
type RPackageConfig,
} from './package-config.js';
import { rOwningPackageDir } from './namespace-imports.js';
import type { GraphNode } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { generateId } from '../../../../lib/utils.js';
type EdgeLabel = 'HAS_METHOD' | 'HAS_PROPERTY';
/**
* Resolve R deferred owner hints into ownerIds. Runs post-parse, after all
* Class symbols have been registered into the TypeRegistry.
*/
export const attachDeferredROwners = (
graph: KnowledgeGraph,
model: MutableSemanticModel,
nodeLabel: 'Method' | 'Property',
edgeType: EdgeLabel,
): void => {
graph.forEachNode((node: GraphNode) => {
// Skip immediately for non-R nodes — avoids doing any further work per node
// in this full-graph traversal for repos with no R files at all.
if (node.properties.language !== SupportedLanguages.R) return;
if (node.label !== nodeLabel) return;
// If we already have a resolved ownerId, the hint is redundant — drop it.
if (typeof node.properties.ownerId === 'string') {
delete node.properties.ownerNameHint;
return;
}
const ownerNameHint =
typeof node.properties.ownerNameHint === 'string' ? node.properties.ownerNameHint : null;
if (!ownerNameHint) return;
const classDefs = model.types.lookupClassByName(ownerNameHint);
if (classDefs.length !== 1) return; // ambiguous or not found — leave hint for debugging
const ownerId = classDefs[0].nodeId;
// 1. Patch the graph node.
node.properties.ownerId = ownerId;
delete node.properties.ownerNameHint;
// 2. Mutate the SymbolDefinition in place (shared ref across indexes).
const filePath = typeof node.properties.filePath === 'string' ? node.properties.filePath : '';
const name = typeof node.properties.name === 'string' ? node.properties.name : '';
const defs = model.symbols.lookupExactAll(filePath, name);
const def = defs.find((d) => d.nodeId === node.id);
if (def) {
def.ownerId = ownerId;
// 3. Explicitly register into the owner-scoped registry so resolveMemberCall
// can find the symbol via MethodRegistry / FieldRegistry.
if (nodeLabel === 'Method') {
model.methods.register(ownerId, name, def);
} else {
model.fields.register(ownerId, name, def);
}
}
// 4. Emit the HAS_METHOD / HAS_PROPERTY edge.
graph.addRelationship({
id: generateId(edgeType, `${ownerId}->${node.id}`),
sourceId: ownerId,
targetId: node.id,
type: edgeType,
confidence: 1.0,
reason: '',
});
});
};
/**
* Refine R export status based on NAMESPACE files. A file belongs to the package whose
* `R/` directory contains it (the nearest enclosing discovered package; R loads nothing else
* into a package namespace), so only `R/` symbols are tested against that package's NAMESPACE:
* a symbol that package does not export is flipped to `isExported: false`. Files outside every
* package's `R/` (`tests/`, `vignettes/`, `inst/`, scripts, ...) and packages without a
* NAMESPACE file keep the default public behaviour; a nested package without a NAMESPACE does
* not inherit its enclosing package's.
*/
export const refineRExportStatus = (
graph: KnowledgeGraph,
rPackageConfig: RPackageConfig | null,
): void => {
if (!rPackageConfig || rPackageConfig.namespaceInfoByPackageDir.size === 0) return;
// Nodes of one file share an owner; resolve it once per file.
const ownerByFile = new Map<string, RNamespaceInfo | undefined>();
const namespaceInfoFor = (filePath: string): RNamespaceInfo | undefined => {
if (ownerByFile.has(filePath)) return ownerByFile.get(filePath);
const pkgDir = rOwningPackageDir(filePath, rPackageConfig);
const info =
pkgDir === undefined ? undefined : rPackageConfig.namespaceInfoByPackageDir.get(pkgDir);
ownerByFile.set(filePath, info);
return info;
};
graph.forEachNode((node: GraphNode) => {
if (node.properties.language !== SupportedLanguages.R) return;
const filePath = typeof node.properties.filePath === 'string' ? node.properties.filePath : '';
if (!filePath) return;
const nsInfo = namespaceInfoFor(filePath);
if (!nsInfo || !nsInfo.hasNamespaceFile) return;
const name = typeof node.properties.name === 'string' ? node.properties.name : '';
if (!name) return;
if (nsInfo.namedExports.has(name)) return; // explicit export — keep public
// exportPatterns are precompiled linear-time matchers (see loadRPackageConfig) — no per-node recompile.
const matched = rExportPatternMatches(nsInfo, name);
if (matched) return;
// Not in NAMESPACE → not exported.
node.properties.isExported = false;
});
};
/**
* R's `LanguageProvider.postParse` hook: deferred owner attach for methods and
* properties, then NAMESPACE-driven export refinement. Runs once per analyze,
* after every chunk is merged and before scope resolution.
*/
export const rPostParse = async (ctx: PostParseContext): Promise<void> => {
attachDeferredROwners(ctx.graph, ctx.model, 'Method', 'HAS_METHOD');
attachDeferredROwners(ctx.graph, ctx.model, 'Property', 'HAS_PROPERTY');
const config = await loadRPackageConfig(ctx.repoPath);
refineRExportStatus(ctx.graph, config);
reportRExportPatternProblems(config);
};

View file

@ -0,0 +1,243 @@
/**
* R `pkg::name()` / `pkg:::name()` qualified calls.
*
* The scope query captures the whole `namespace_operator` as
* `@reference.qualified-name`, so a call site carries `rawQualifiedName`
* (`pkg::f`). Without it every tier discards the qualifier and guesses by
* name: `dplyr::filter()` binds to an unrelated local `filter`, and
* `mypkg::tidy()` inside a wrapper named `tidy` binds to the wrapper itself.
*
* {@link populateRQualifiedCalls} runs at `populateWorkspaceReferences` time
* (before `finalizeScopeModel`, on cold and warm-cache runs alike, with the
* workspace's `RPackageConfig`) and decides each qualified site by the
* {@link rQualifierLocality} of its package:
*
* - `external` (not local, and package discovery completed): the site is
* dropped from `referenceSites`, so no tier can guess an edge for it;
* - `local` with exactly one top-level definition of the name in the
* package's `R/` files: that definition is recorded per call site and
* returned by {@link resolveRQualifiedFreeCall}, which the free-call
* fallback consults before every lexical tier (a precise 0.85 edge);
* - `local` with two or more such definitions (same file or across files):
* the site is dropped (every valid target is inside the named package, so
* any edge would be provably wrong);
* - `local` with no definition (a re-export, or a name defined some other
* way) and `unknown` locality: the site is left untouched, i.e. today's
* behaviour.
*
* The per-package definition counts are also recorded for the
* global-name-fallback veto (`isRGlobalNameFallbackPlausible`).
*
* Import direction: this file imports `namespace-imports.ts`, never the other
* way round, so `parseRQualifier` and the definition-count registry live there
* and are re-exported here.
*/
import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared';
import type { RPackageConfig } from './package-config.js';
import {
parseRQualifier,
rFileBelongsToDiscoveredPackage,
rPackageDirForFile,
rRecordQualifiedDefinitionCounts,
TOP_LEVEL_BINDABLE_TYPES,
} from './namespace-imports.js';
export { parseRQualifier } from './namespace-imports.js';
/** Whether a qualifier's package is a package of the analysed repo. */
export type RQualifierLocality = 'local' | 'external' | 'unknown';
/**
* True when `filePath` sits under a `<pkg>/R/` directory: a `/`-delimited path
* segment equal to `pkg` immediately followed by `R/` (`pkg/R/a.R`,
* `deep/x/pkg/R/a.R`; not `mypkg/R/a.R`, not `pkg/scripts/a.R`).
*
* The directory name stands in for the package name only for a file that no discovered
* package owns (the fallback for a package whose `DESCRIPTION` discovery missed or that has
* none). A file under a discovered package's `R/` belongs to the package its `DESCRIPTION`
* names, whatever its directory is called, so it is never evidence for another name.
*/
function isUnderPackageRDir(
pkg: string,
filePath: string,
cfg: RPackageConfig | null | undefined,
): boolean {
const normalized = filePath.replace(/\\/g, '/');
if (!normalized.startsWith(`${pkg}/R/`) && !normalized.includes(`/${pkg}/R/`)) return false;
return !rFileBelongsToDiscoveredPackage(normalized, cfg);
}
/**
* Locality of the package named by a qualifier.
*
* - `local`: `pkg` is in `cfg.packages`, or a parsed file sits under a
* `<pkg>/R/` directory (a package whose `DESCRIPTION` discovery missed or
* that has none; root-level packages are found through `cfg` only);
* - `external`: not local, and `cfg` exists and its discovery was complete
* (`!cfg.truncated`), so `pkg` provably is not part of the repo. This
* includes a complete scan that found no package at all (no `DESCRIPTION`
* anywhere): the config is then empty, not absent;
* - `unknown`: not found, but discovery was truncated or there is no config
* (hand-built callers), so `pkg` may still be a local package that was not seen.
*
* One predicate, so the populate step and the veto cannot disagree on "local".
*/
export function rQualifierLocality(
pkg: string,
cfg: RPackageConfig | null | undefined,
parsedFilePaths: ReadonlySet<string>,
): RQualifierLocality {
if (cfg?.packages.has(pkg) === true) return 'local';
for (const filePath of parsedFilePaths) {
if (isUnderPackageRDir(pkg, filePath, cfg)) return 'local';
}
if (cfg === null || cfg === undefined) return 'unknown';
return cfg.truncated === true ? 'unknown' : 'external';
}
/**
* Call site → the single definition its `pkg::name()` qualifier names, for the
* current run. Module state, replaced wholesale by every
* {@link populateRQualifiedCalls} call. Keyed by file path and start position
* (the key `free-call-fallback.ts` uses for its handled sites), not by object
* identity, so it survives the frozen `ParsedFile` copies made after populate.
*/
let preciseBySite: Map<string, SymbolDefinition> = new Map();
const siteKey = (filePath: string, atRange: { startLine: number; startCol: number }): string =>
`${filePath}:${atRange.startLine}:${atRange.startCol}`;
function isQualifiedCall(site: ReferenceSite): site is ReferenceSite & {
readonly rawQualifiedName: string;
} {
return site.kind === 'call' && site.rawQualifiedName !== undefined;
}
/**
* Per name, every top-level function/class definition (one entry per binding
* ref, so a same-file redefinition counts twice) of the given package files.
* Read from Module-scope `local` bindings at extraction time, like
* `rFileTopLevel`, but keeping the definitions rather than the names.
*/
function collectTopLevelDefinitions(files: readonly ParsedFile[]): Map<string, SymbolDefinition[]> {
const byName = new Map<string, SymbolDefinition[]>();
for (const parsed of files) {
const moduleScope = parsed.scopes.find((s) => s.id === parsed.moduleScope);
if (moduleScope === undefined) continue;
for (const [name, refs] of moduleScope.bindings) {
for (const ref of refs) {
if (ref.origin !== 'local' || !TOP_LEVEL_BINDABLE_TYPES.has(ref.def.type)) continue;
const list = byName.get(name);
if (list === undefined) byName.set(name, [ref.def]);
else list.push(ref.def);
}
}
}
return byName;
}
/**
* Decide every qualified call site of `parsedFiles` as described in the file
* header: drop external and duplicate-definition sites (in place, through
* frozen `ParsedFile` copies), record the precise target of single-definition
* sites, leave every other site untouched. Resets all module state first, so a
* run never sees the previous run's answers.
*/
export function populateRQualifiedCalls(
parsedFiles: ParsedFile[],
ctx: { readonly resolutionConfig?: unknown },
): void {
preciseBySite = new Map();
const cfg = (ctx.resolutionConfig as RPackageConfig | null | undefined) ?? null;
if (cfg !== null) rRecordQualifiedDefinitionCounts(cfg, new Map());
// Packages named by some qualified call, and how local each one is.
let filePaths: Set<string> | undefined;
const localityByPackage = new Map<string, RQualifierLocality>();
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
if (!isQualifiedCall(site)) continue;
const pkg = parseRQualifier(site.rawQualifiedName);
if (pkg === undefined || localityByPackage.has(pkg)) continue;
filePaths ??= new Set(parsedFiles.map((p) => p.filePath));
localityByPackage.set(pkg, rQualifierLocality(pkg, cfg, filePaths));
}
}
if (localityByPackage.size === 0) return;
// Top-level definitions of each named local package, by name.
const localPackages = [...localityByPackage].filter(([, l]) => l === 'local').map(([p]) => p);
const definitions = new Map<string, Map<string, SymbolDefinition[]>>();
if (localPackages.length > 0) {
const filesByPackage = localPackages.map((pkg) => ({ pkg, files: [] as ParsedFile[] }));
for (const parsed of parsedFiles) {
const owner = rPackageDirForFile(parsed.filePath, cfg)?.name;
for (const { pkg, files } of filesByPackage) {
// In the config a package owns its `R/` files (longest-dir wins); otherwise
// it is local only through the `<pkg>/R/` path, as `rQualifierLocality` decided.
const inPackage =
cfg?.packages.has(pkg) === true
? owner === pkg
: isUnderPackageRDir(pkg, parsed.filePath, cfg);
if (inPackage) files.push(parsed);
}
}
for (const { pkg, files } of filesByPackage) {
definitions.set(pkg, collectTopLevelDefinitions(files));
}
if (cfg !== null) {
rRecordQualifiedDefinitionCounts(
cfg,
new Map(
[...definitions].map(([pkg, byName]) => [
pkg,
new Map([...byName].map(([name, defs]) => [name, defs.length])),
]),
),
);
}
}
// Decide each site.
for (let i = 0; i < parsedFiles.length; i++) {
const parsed = parsedFiles[i];
const kept: ReferenceSite[] = [];
for (const site of parsed.referenceSites) {
if (!isQualifiedCall(site)) {
kept.push(site);
continue;
}
const pkg = parseRQualifier(site.rawQualifiedName);
const locality = pkg === undefined ? 'unknown' : localityByPackage.get(pkg);
if (locality === 'external') continue;
if (locality === 'local' && pkg !== undefined) {
const found = definitions.get(pkg)?.get(site.name) ?? [];
if (found.length >= 2) continue;
if (found.length === 1) preciseBySite.set(siteKey(parsed.filePath, site.atRange), found[0]);
}
kept.push(site);
}
if (kept.length !== parsed.referenceSites.length) {
parsedFiles[i] = Object.freeze({ ...parsed, referenceSites: Object.freeze(kept) });
}
}
}
/**
* `ScopeResolver.resolveQualifiedFreeCall` for R: the definition
* {@link populateRQualifiedCalls} recorded for this call site, if any.
*
* The contract types `site` without its position; the runtime object is the
* `ReferenceSite`, which has `atRange`. That is read through a narrow cast
* here instead of widening the shared contract.
*/
export function resolveRQualifiedFreeCall(
site: object,
callerParsed: ParsedFile,
): SymbolDefinition | undefined {
const atRange = (site as { readonly atRange?: { startLine: number; startCol: number } }).atRange;
return atRange === undefined
? undefined
: preciseBySite.get(siteKey(callerParsed.filePath, atRange));
}

View file

@ -0,0 +1,348 @@
/**
* Tree-sitter query for R scope captures (RFC #909 Ring 3).
*
* R has no dedicated class syntax — R6 classes are `R6Class(...)` calls, S4/R5
* classes are `setClass(...)`/`setRefClass(...)` calls — so this query mirrors
* the shapes the legacy `R_QUERIES` (`tree-sitter-queries.ts`) already proved
* queryable for structural extraction (the heritage shapes below have no
* counterpart there), renamed onto the scope-resolution capture vocabulary
* (`@scope.*`, `@declaration.*`, `@reference.*`, `@import.*`,
* `@type-binding.*`).
*
* R specifics encoded here:
*
* - A Class scope spans the ENTIRE `R6Class(...)`/`setClass(...)` call
* (the whole `binary_operator` for the `name <- R6Class(...)` shape),
* not just `public = list(...)` — the `inherit=`/`contains=` argument
* must fall inside the scope's range so the `@reference.inherits` sites
* resolve against the deriving class's own scope.
*
* - `@declaration.function`/`@declaration.class` anchor on the WHOLE
* `name <- <rhs>` assignment (matching the legacy `@definition.*`
* anchors exactly), so `resolveDefGraphId`'s position-based (Function)
* and qualifiedName-keyed (Class) reconciliation lands on the SAME
* graph node the legacy extractor already created.
*
* - `@declaration.method` anchors on the `name = function(...)` ARGUMENT
* inside `public =`/`private =`/`active =`/`methods = list(...)`.
*
* - A def is owned by the innermost scope whose range EQUALS its anchor
* range (`scope-extractor.ts` Pass 2), and `resolveCallerGraphId` walks
* from a call site up to the first scope that owns a callable def. The
* blanket `(function_definition) @scope.function` is strictly narrower
* than the assignment/argument anchors, so on its own it never owns a
* def: every named function/method would be owned by the module or
* Class scope and every call in the file would be credited to the
* FIRST callable in it. So each named-callable anchor also gets a
* `@scope.function` on the SAME node, placed directly beside its
* `@declaration.*` pattern below (the two must stay in lockstep — a
* declaration anchor without a matching scope silently reverts that
* shape to first-callable attribution). The def's own binding still
* hoists to the PARENT scope (anchor range == scope range), so name
* lookup is unchanged. The blanket rule is kept: it still covers
* anonymous functions (`lapply(x, function(z) ...)`), which own nothing.
* Calls in a Class body outside any method (an R6 field default, a
* `setClass(validity = function...)` body) are credited to the Class.
*
* - No `@declaration.qualified_name` is emitted for `@declaration.class`
* — R's legacy extractor has no `classExtractor`, so a Class node's
* `qualifiedName` today is the bare class-name text. Omitting the
* capture lets `buildDefFromDeclarationMatch` default `qualifiedName`
* to the bare name, matching the legacy node so `resolveDefGraphId`'s
* name-keyed fallback finds it.
*
* - S4/setGeneric/setMethod declarations are deliberately NOT captured
* here — they are already produced by the legacy extractor via
* R_QUERIES, independent of scope-resolution, and none of the target
* scenarios needs a NEW declaration for them. Their inner
* `function_definition` bodies still get a `@scope.function` from the
* blanket rule below (an unnamed scope, harmless).
*
* - `library()`/`require()` are wildcard imports (`@import.wildcard`) —
* every top-level name of the named local package becomes visible,
* unfiltered by NAMESPACE (see `rProvider.expandsWildcardTo`).
* `source()` is a side-effect import (`@import.side-effect`) — it
* establishes a file dependency but binds no name; cross-file free
* calls into a `source()`d file resolve via `allowGlobalFreeCallFallback`
* instead (`rScopeResolver`).
*
* - `obj$method()` (the `extract_operator`, R's `$`) is a member call with
* an explicit receiver — the same shape as Ruby's `obj.method`.
* `Type$new(...)` is R6's constructor spelling: captured separately as
* `@type-binding.constructor` so `x <- Type$new(...)` types `x`.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
* tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
// eslint-disable-next-line @typescript-eslint/no-var-requires
import R from '@eagleoutice/tree-sitter-r';
/**
* Exported for `value-ref-dispatchability.test.ts`-style whole-query readers.
* Not part of the provider surface — nothing else should import it.
*/
export const R_SCOPE_QUERY = `
;; ── Scopes ───────────────────────────────────────────────────────────────
(program) @scope.module
(function_definition) @scope.function
;; R6 class via namespace call (ClassName <- R6::R6Class(...)) — the scope
;; spans the WHOLE assignment, including inherit=, so heritage sites resolve
;; inside the deriving class's own scope.
(binary_operator
lhs: (identifier)
rhs: (call
function: (namespace_operator
rhs: (identifier) @_r6ns_scope
(#match? @_r6ns_scope "^R6Class$")))) @scope.class
;; R6 class via bare call (ClassName <- R6Class(...))
(binary_operator
lhs: (identifier)
rhs: (call
function: (identifier) @_r6bare_scope
(#match? @_r6bare_scope "^R6Class$"))) @scope.class
;; S4/R5 class (setClass(...) / setRefClass(...)) — scope spans the whole call.
(call
function: (identifier) @_s4_scope
(#match? @_s4_scope "^(setClass|setRefClass)$")) @scope.class
;; ── Declarations — functions ──────────────────────────────────────────────
(binary_operator
lhs: (identifier) @declaration.name
rhs: (function_definition)) @declaration.function
;; Function scope on the SAME assignment node as the declaration above, so the
;; def is owned by its own scope (and calls inside the body attribute to it).
;; This pattern and the \`@declaration.function\` pattern above must stay in
;; lockstep — identical structure, identical anchor node (see file header).
(binary_operator
lhs: (identifier)
rhs: (function_definition)) @scope.function
;; ── Declarations — classes (name only; no qualified_name, see file header) ─
;;
;; The setClass/setRefClass declaration and the source/library/require imports
;; below capture EVERY string (imports: identifier or string) argument of the call.
;; \`emitRScopeCaptures\` keeps only the one that names the class or import
;; (\`isRNonNamingScopeMatch\`): the argument spelled with the first formal, else the
;; first unnamed one, so a leading comment or a named \`lib.loc =\` / \`local =\` does
;; not change the answer. A query-level anchor cannot skip an unbounded run of
;; leading comments and named arguments without a repeated sibling group, which
;; is quadratic in the argument count on every call.
(binary_operator
lhs: (identifier) @declaration.name
rhs: (call
function: (namespace_operator
rhs: (identifier) @_r6ns_decl
(#match? @_r6ns_decl "^R6Class$")))) @declaration.class
(binary_operator
lhs: (identifier) @declaration.name
rhs: (call
function: (identifier) @_r6bare_decl
(#match? @_r6bare_decl "^R6Class$"))) @declaration.class
(call
function: (identifier) @_s4_decl
(#match? @_s4_decl "^(setClass|setRefClass)$")
arguments: (arguments
(argument
value: (string
content: (string_content) @declaration.name)))) @declaration.class
;; ── Declarations — R6/R5 methods ──────────────────────────────────────────
;; R6 methods via namespace call (inside public/private/active = list(...))
(binary_operator
lhs: (identifier) @_class1
rhs: (call
function: (namespace_operator
rhs: (identifier) @_r6ns_m
(#match? @_r6ns_m "^R6Class$"))
arguments: (arguments
(argument
name: (identifier) @_section1
(#match? @_section1 "^(public|private|active)$")
value: (call
function: (identifier) @_listfn1
(#match? @_listfn1 "^list$")
arguments: (arguments
(argument
name: (identifier) @declaration.name
value: (function_definition)) @declaration.method))))))
;; R6 methods via bare call (inside public/private/active = list(...))
(binary_operator
lhs: (identifier) @_class2
rhs: (call
function: (identifier) @_r6bare_m
(#match? @_r6bare_m "^R6Class$")
arguments: (arguments
(argument
name: (identifier) @_section2
(#match? @_section2 "^(public|private|active)$")
value: (call
function: (identifier) @_listfn2
(#match? @_listfn2 "^list$")
arguments: (arguments
(argument
name: (identifier) @declaration.name
value: (function_definition)) @declaration.method))))))
;; R5 methods (inside setRefClass(... methods = list(...)))
(binary_operator
lhs: (identifier) @_class3
rhs: (call
function: (identifier) @_refFn_m
(#match? @_refFn_m "^setRefClass$")
arguments: (arguments
(argument
name: (identifier) @_methods3
(#match? @_methods3 "^methods$")
value: (call
function: (identifier) @_listfn3
(#match? @_listfn3 "^list$")
arguments: (arguments
(argument
name: (identifier) @declaration.name
value: (function_definition)) @declaration.method))))))
;; Function scope on the SAME \`name = function(...)\` argument node that the
;; three \`@declaration.method\` patterns above anchor on, so each R6/R5 method
;; def is owned by its own Function scope (a child of the Class scope) rather
;; than by the Class scope. Deliberately generic — any named function-valued
;; argument — so it cannot drift from the three long nesting patterns above; it
;; is inert for non-method arguments (\`FUN = function...\`, \`error = function(e)\`
;; own no defs, and calls inside them walk up to the enclosing callable). The
;; declaration patterns above and this pattern must stay in lockstep.
(argument
name: (identifier)
value: (function_definition)) @scope.function
;; ── Imports ────────────────────────────────────────────────────────────────
;; source("path.R") — side-effect: establishes a file dependency, binds no name.
(call
function: (identifier) @_srcfn
(#match? @_srcfn "^source$")
arguments: (arguments
(argument
value: [(identifier) (string)] @import.source))) @import.side-effect
;; library("pkg") / require("pkg") — wildcard: every top-level name of the
;; named local package becomes visible (rProvider.expandsWildcardTo).
(call
function: (identifier) @_libfn
(#match? @_libfn "^(library|require)$")
arguments: (arguments
(argument
value: [(identifier) (string)] @import.source))) @import.wildcard
;; ── Type bindings — R6 constructor (x <- Type$new(...)) ───────────────────
(binary_operator
lhs: (identifier) @type-binding.name
rhs: (call
function: (extract_operator
lhs: (identifier) @type-binding.type
rhs: (identifier) @_newfn
(#match? @_newfn "^new$")))) @type-binding.constructor
;; ── References — heritage: R6 inherit= ────────────────────────────────────
(binary_operator
lhs: (identifier)
rhs: (call
function: (namespace_operator
rhs: (identifier) @_r6ns_h
(#match? @_r6ns_h "^R6Class$"))
arguments: (arguments
(argument
name: (identifier) @_inh1
(#match? @_inh1 "^inherit$")
value: (identifier) @reference.inherits))))
(binary_operator
lhs: (identifier)
rhs: (call
function: (identifier) @_r6bare_h
(#match? @_r6bare_h "^R6Class$")
arguments: (arguments
(argument
name: (identifier) @_inh2
(#match? @_inh2 "^inherit$")
value: (identifier) @reference.inherits))))
;; ── References — heritage: S4 contains= (single parent, incl. "VIRTUAL") ──
(call
function: (identifier) @_s4h1
(#match? @_s4h1 "^(setClass|setRefClass)$")
arguments: (arguments
(argument
name: (identifier) @_contains1
(#match? @_contains1 "^(contains|CONTAINS)$")
value: (string
content: (string_content) @reference.inherits))))
;; ── References — heritage: S4 contains=c(...) (multiple parents) ──────────
(call
function: (identifier) @_s4h2
(#match? @_s4h2 "^(setClass|setRefClass)$")
arguments: (arguments
(argument
name: (identifier) @_contains2
(#match? @_contains2 "^(contains|CONTAINS)$")
value: (call
function: (identifier) @_cfn2
(#match? @_cfn2 "^c$")
arguments: (arguments
(argument
value: (string
content: (string_content) @reference.inherits)))))))
;; ── References — calls ────────────────────────────────────────────────────
;; Free calls: foo(x)
(call
function: (identifier) @reference.name) @reference.call.free
;; Namespaced calls: pkg::func() and pkg:::func(). The whole namespace_operator
;; is captured as the qualified name so the qualifier survives the parse cache.
(call
function: (namespace_operator
rhs: (identifier) @reference.name) @reference.qualified-name) @reference.call.free
;; Member calls via $: obj$method()
(call
function: (extract_operator
lhs: (identifier) @reference.receiver
rhs: (identifier) @reference.name)) @reference.call.member
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getRParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(R as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getRScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(R as Parameters<Parser['setLanguage']>[0], R_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,235 @@
/**
* R `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by the
* generic `runScopeResolution` orchestrator.
*
* Thin wiring: import resolution reuses `resolveRImportTarget`
* (`import-resolvers/r.ts`), an adapter over the existing
* `resolveRImportInternal`, with the repo's local R packages
* threaded through `loadRPackageConfig`. Heritage (R6 `inherit=`, S4
* `contains=`) mostly rides the generic `@reference.inherits` mechanism —
* R6/S4 base names are ordinary scope-bound class names, unlike Ruby's
* `include`/`extend`, which are method calls. The one exception is S4's
* `contains = "VIRTUAL"` sentinel (`setClass(x, contains = "VIRTUAL", ...)`,
* R's spelling for an abstract base): no fixture ever declares
* `setClass("VIRTUAL", ...)`, so the generic scope-based lookup
* (`resolveInheritanceBaseInScope` / `findClassBindingInScope`) can never
* find a binding for it — that lookup is scope-model-only and never
* consults graph nodes directly. `emitRVirtualHeritageEdges` below mints
* the placeholder `VIRTUAL` graph node and its EXTENDS edge directly,
* mirroring Ruby's `emitRubyMixinEdges` shape for the same reason: a
* heritage target that R code names but never declares.
*/
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import {
findEnclosingClassDef,
populateClassOwnedMembers,
} from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import { loadRPackageConfig } from './package-config.js';
import { resolveRImportTarget } from '../../import-resolvers/r.js';
import {
isRGlobalNameFallbackPlausible,
populateRNamespaceImports,
rFileTopLevel,
} from './namespace-imports.js';
import { rProvider } from '../r.js';
import { populateRQualifiedCalls, resolveRQualifiedFreeCall } from './qualified-call.js';
import { rArityCompatibility, rMergeBindings } from './simple-hooks.js';
/** Literal string `"VIRTUAL"` graph id — one node shared by every S4 class
* that names it as a base, minted lazily on first use. */
const VIRTUAL_CLASS_NAME = 'VIRTUAL';
/**
* Mint the `VIRTUAL` placeholder Class node (once) and an EXTENDS edge from
* every S4 class whose `contains=` names it. Scope is deliberately limited to
* the literal string `"VIRTUAL"`: other S4 base-type sentinels (`"numeric"`,
* `"list"`, …) are not treated as placeholder classes, which would be a
* separate design decision.
*
* Idempotent: re-seeds its dedup set from any `EXTENDS` edge already
* targeting a node named `VIRTUAL` before emitting, and re-uses the graph's
* existing `VIRTUAL` node instead of minting a second one when the
* orchestrator re-runs resolution.
*/
function emitRVirtualHeritageEdges(
graph: KnowledgeGraph,
_parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
scopes?: ScopeResolutionIndexes,
): void {
if (scopes === undefined) return;
const virtualGraphId = generateId('Class', VIRTUAL_CLASS_NAME);
const emitted = new Set<string>();
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
if (rel.targetId === virtualGraphId) emitted.add(rel.sourceId);
}
let virtualNodeMinted = graph.getNode(virtualGraphId) !== undefined;
for (const site of scopes.referenceSites) {
if (site.kind !== 'inherits' || site.name !== VIRTUAL_CLASS_NAME) continue;
const callerClass = findEnclosingClassDef(site.inScope, scopes);
if (callerClass === undefined) continue;
const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup);
if (callerGraphId === undefined || emitted.has(callerGraphId)) continue;
if (!virtualNodeMinted) {
graph.addNode({
id: virtualGraphId,
label: 'Class',
properties: { name: VIRTUAL_CLASS_NAME, filePath: callerClass.filePath },
});
virtualNodeMinted = true;
}
emitted.add(callerGraphId);
graph.addRelationship({
id: generateId('EXTENDS', `${callerGraphId}->${virtualGraphId}`),
sourceId: callerGraphId,
targetId: virtualGraphId,
type: 'EXTENDS',
confidence: 0.85,
reason: 'scope-resolution: inherits (virtual)',
});
}
}
/**
* Enumerate all top-level names exported from a target module scope's file.
* `library()`/`require()` are R's wildcard imports — every top-level name of
* the named local package becomes visible, UNFILTERED by NAMESPACE (a
* `library()`/`require()` call brings the whole package's namespace into
* scope regardless of what NAMESPACE declares as exported — confirmed by a
* fixture where `pkgA/NAMESPACE` does not export `ResultSet`, yet
* `require("pkgA")` must still resolve it).
*
* A bare name is dropped when the same file also defines a dotted top-level
* name with that tail (`foo` next to `print.foo`). The shared finalize indexes
* every def by the text after its last `.`, so `foo` would be bound to
* whichever of the two comes first in the file, and `print.foo` (an S3 method,
* never callable as `foo()`) can win. The dropped name resolves through the
* free-call fallback instead, where {@link rIsCallableVisibleFromCaller}
* removes the dotted def from the candidates. Order-blind, like the matching
* guard in `import-resolvers/r.ts`.
*/
export function expandRWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
const { dottedTails } = rFileTopLevel(target);
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0 || qn.includes('.')) continue; // top-level only
if (dottedTails.has(qn)) continue; // shares its tail with an S3 method: see above
if (seen.has(qn)) continue;
seen.add(qn);
names.push(qn);
}
return names;
}
/**
* A bare R call `foo(x)` can never invoke a def whose name contains a `.`
* (`print.foo` is only reachable as `print.foo(...)` or through S3 dispatch),
* yet the free-call fallback's simple-name index keys it under `foo`. Refuse
* such candidates so `foo(x)` cannot collide with `print.foo`.
*
* Applies to every candidate the hook sees, including R6/R5 members whose
* qualified name is `Class.member`: a bare `member(x)` never reaches those
* either.
*/
export function rIsCallableVisibleFromCaller(ctx: {
readonly candidate: SymbolDefinition;
}): boolean {
return !(ctx.candidate.qualifiedName ?? '').includes('.');
}
export const rScopeResolver: ScopeResolver = {
language: SupportedLanguages.R,
languageProvider: rProvider,
importEdgeReason: 'r-scope: import',
loadResolutionConfig: (repoPath: string) => loadRPackageConfig(repoPath),
resolveImportTarget: resolveRImportTarget,
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandRWildcardNames(targetModuleScope, parsedFiles),
mergeBindings: rMergeBindings,
arityCompatibility: rArityCompatibility,
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) =>
emitRVirtualHeritageEdges(graph, parsedFiles, nodeLookup, scopes),
// R has no `super`.
isSuperReceiver: () => false,
// R's dynamic-dispatch `self$field$method()` chains and R6/S4 field access
// benefit from the same heuristic Ruby/Python rely on.
fieldFallbackOnMethodLookup: true,
// `source()` is a side-effect import (binds no name) — a cross-file free
// call into a `source()`d file (e.g. `HelperFunc(x)`) has no lexical or
// import binding to resolve through, so it resolves via a unique
// workspace-wide name match instead. `library()`/`require()`'s own
// wildcard-bound names still resolve through `expandsWildcardTo` above;
// this only covers what that path cannot reach.
allowGlobalFreeCallFallback: true,
// A named import binds only to a MODULE-LEVEL definition of the target
// file. Without this, `findExportByName` indexes every `localDefs` entry by
// simple name, so an R6/R5 method named like an imported symbol could win
// the binding over the top-level function.
namedImportsBindTopLevelOnly: true,
// NAMESPACE `importFrom()` names provided by a LOCAL package become
// synthesised `named` imports (`resolveRImportTarget`'s named branch binds
// them). Runs before finalize and over warm-cache ParsedFiles too.
//
// Followed by `populateRQualifiedCalls`: it decides each
// `pkg::name()` site by the qualifier — drops external / ambiguous ones and
// records the single definition a local one names for
// `resolveQualifiedFreeCall` below. Import synthesis is unchanged and first.
populateWorkspaceReferences: (parsedFiles, ctx) => {
populateRNamespaceImports(parsedFiles, ctx);
populateRQualifiedCalls(parsedFiles, ctx);
},
resolveQualifiedFreeCall: resolveRQualifiedFreeCall,
// `populateRNamespaceImports` and `populateRQualifiedCalls` read only ParsedFile scopes, never source
// text: without this, merely declaring the hook makes the pipeline read the
// text of every R file on the main thread.
postExtractSourceTextPolicy: 'uncached-files',
// A bare call whose name the caller package's NAMESPACE imports from a
// package other than the one the unique-name guess landed in is impossible
// in R (see `isRGlobalNameFallbackPlausible` for the exact rule, the
// qualifier-first handling of `pkg::name()` calls and the documented
// `import()` limitation).
isGlobalNameFallbackPlausible: isRGlobalNameFallbackPlausible,
// The free-call fallback keys defs by the text after the last `.` of their
// qualified name, so an S3 method `print.foo` sits next to a bare `foo` and
// makes the bare call ambiguous (no edge). A bare call can never invoke a
// dotted def, so drop those candidates.
isCallableVisibleFromCaller: rIsCallableVisibleFromCaller,
};

View file

@ -0,0 +1,49 @@
import type { BindingRef, Callsite, SymbolDefinition } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
local: 0,
namespace: 1,
import: 2,
reexport: 3,
wildcard: 4,
};
/** Local declarations shadow imports; deterministic order within a tier.
* Same shape as Zig's `zigMergeBindings` — R has no per-scope binding
* precedence rule beyond "the tightest-origin binding wins". */
export function rMergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
_scopeId: string,
): BindingRef[] {
const all = [...existing, ...incoming];
if (all.length === 0) return [];
let bestTier = 99;
for (const b of all) {
const t = TIER[b.origin] ?? 99;
if (t < bestTier) bestTier = t;
}
const byNode = new Map<string, BindingRef>();
for (const b of all) {
if ((TIER[b.origin] ?? 99) !== bestTier) continue;
if (!byNode.has(b.def.nodeId)) byNode.set(b.def.nodeId, b);
}
return [...byNode.values()].sort((a, b) => a.def.nodeId.localeCompare(b.def.nodeId));
}
/** R declarations carry no synthesized arity metadata (no `...`/default-arg
* capture exists yet), so the comparison is always 'unknown' — a real bounds
* check that turns on if arity captures are added later. Same rationale as
* Zig's `zigArityCompatibility`. */
export function rArityCompatibility(
callsite: Callsite,
def: SymbolDefinition,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
if (!Number.isFinite(callsite.arity) || callsite.arity! < 0) return 'unknown';
if (min !== undefined && callsite.arity! < min) return 'incompatible';
if (max !== undefined && callsite.arity! > max) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,250 @@
// gitnexus/src/core/ingestion/method-extractors/r.ts
/**
* R method extractor — hand-written because R expresses classes and methods
* as function calls rather than dedicated syntax.
*
* Handles:
* - R6 methods: function entries in public/private/active list()
* - S4 setMethod(): owner hints for method implementations dispatched on signature
* - R5/setRefClass methods: methods = list(name = function(...) {...})
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { getRMatchedArgument } from '../languages/r/naming-argument.js';
import { getRClassNameArgument } from '../field-extractors/r.js';
import type {
MethodExtractor,
MethodExtractorContext,
ExtractedMethods,
MethodInfo,
MethodVisibility,
ParameterInfo,
} from '../method-types.js';
/** `setMethod`'s formals, in order (methods package). */
const SET_METHOD_FORMALS: readonly string[] = [
'f',
'signature',
'definition',
'where',
'valueClass',
'sealed',
];
export const rMethodExtractor: MethodExtractor = {
language: SupportedLanguages.R,
isTypeDeclaration(node: SyntaxNode): boolean {
return isR6Class(node) || isRefClass(node);
},
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null {
if (isR6Class(node)) return extractR6Methods(node, context);
if (isRefClass(node)) return extractRefClassMethods(node, context);
return null;
},
};
/** Resolve the owning class name for a top-level R setMethod("foo", "Bar", ...) definition. */
export function getRTopLevelMethodOwnerName(node: SyntaxNode): string | null {
if (!isS4SetMethod(node)) return null;
const args = node.childForFieldName('arguments');
if (!args) return null;
// The owner is the `signature` argument, as R matches it (named first, then by position).
const signature = getRMatchedArgument(args, SET_METHOD_FORMALS, 'signature');
return extractQuotedString(signature?.childForFieldName('value') ?? null);
}
// ---------------------------------------------------------------------------
// R6 method extraction
// ---------------------------------------------------------------------------
function extractR6Methods(
node: SyntaxNode,
context: MethodExtractorContext,
): ExtractedMethods | null {
const ownerName = node.childForFieldName('lhs')?.text;
if (!ownerName) return null;
const call = node.childForFieldName('rhs');
if (!call) return null;
const args = call.childForFieldName('arguments');
if (!args) return null;
const methods: MethodInfo[] = [];
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const section = arg.childForFieldName('name')?.text;
if (section !== 'public' && section !== 'private' && section !== 'active') continue;
const visibility: MethodVisibility = section === 'private' ? 'private' : 'public';
const listCall = arg.childForFieldName('value');
if (!listCall || listCall.type !== 'call') continue;
const listArgs = listCall.childForFieldName('arguments');
if (!listArgs) continue;
for (let j = 0; j < listArgs.namedChildCount; j++) {
const entry = listArgs.namedChild(j);
if (!entry || entry.type !== 'argument') continue;
const nameNode = entry.childForFieldName('name');
const valueNode = entry.childForFieldName('value');
if (!nameNode || valueNode?.type !== 'function_definition') continue;
methods.push({
name: nameNode.text,
receiverType: null,
returnType: null,
parameters: extractRParameters(valueNode),
visibility,
isStatic: false,
isAbstract: false,
isFinal: false,
annotations: section === 'active' ? ['@active'] : [],
sourceFile: context.filePath,
line: entry.startPosition.row + 1,
column: entry.startPosition.column,
});
}
}
return methods.length > 0 ? { ownerName, methods } : null;
}
// ---------------------------------------------------------------------------
// S4 / R5 (setRefClass) method extraction
// ---------------------------------------------------------------------------
function extractRefClassMethods(
node: SyntaxNode,
context: MethodExtractorContext,
): ExtractedMethods | null {
const args = node.childForFieldName('arguments');
if (!args) return null;
// The class name is the argument that takes the `Class` formal (by name, else the
// first unnamed one), not the first string: `contains = "Base"` may precede it.
const ownerName = getRClassNameArgument(args);
if (!ownerName) return null;
const methods: MethodInfo[] = [];
// setRefClass(... methods = list(name = function(...) { ... }))
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const paramName = arg.childForFieldName('name')?.text;
if (paramName !== 'methods') continue;
const listCall = arg.childForFieldName('value');
if (!listCall || listCall.type !== 'call') continue;
const listArgs = listCall.childForFieldName('arguments');
if (!listArgs) continue;
for (let j = 0; j < listArgs.namedChildCount; j++) {
const entry = listArgs.namedChild(j);
if (!entry || entry.type !== 'argument') continue;
const nameNode = entry.childForFieldName('name');
const valueNode = entry.childForFieldName('value');
if (!nameNode || valueNode?.type !== 'function_definition') continue;
methods.push({
name: nameNode.text,
receiverType: null,
returnType: null,
parameters: extractRParameters(valueNode),
visibility: 'public',
isStatic: false,
isAbstract: false,
isFinal: false,
annotations: [],
sourceFile: context.filePath,
line: entry.startPosition.row + 1,
column: entry.startPosition.column,
});
}
}
return methods.length > 0 ? { ownerName, methods } : null;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isR6Class(node: SyntaxNode): boolean {
if (node.type !== 'binary_operator') return false;
const rhs = node.childForFieldName('rhs');
if (!rhs || rhs.type !== 'call') return false;
const fn = rhs.childForFieldName('function');
if (!fn) return false;
if (fn.type === 'identifier' && fn.text === 'R6Class') return true;
if (fn.type === 'namespace_operator') {
return fn.childForFieldName('rhs')?.text === 'R6Class';
}
return false;
}
function isRefClass(node: SyntaxNode): boolean {
if (node.type !== 'call') return false;
const fn = node.childForFieldName('function');
return fn?.type === 'identifier' && fn.text === 'setRefClass';
}
function isS4SetMethod(node: SyntaxNode): boolean {
if (node.type !== 'call') return false;
const fn = node.childForFieldName('function');
return fn?.type === 'identifier' && fn.text === 'setMethod';
}
function extractQuotedString(node: SyntaxNode | null): string | null {
if (!node || node.type !== 'string') return null;
const content = node.namedChildren.find((c) => c.type === 'string_content');
return content?.text ?? node.text.replace(/^["']|["']$/g, '');
}
/** Extract parameter names from an R function_definition node. */
function extractRParameters(fnNode: SyntaxNode): ParameterInfo[] {
const params: ParameterInfo[] = [];
const paramList = fnNode.childForFieldName('parameters');
if (!paramList) return params;
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
if (param.type === 'identifier') {
params.push({
name: param.text,
type: null,
isOptional: false,
isVariadic: param.text === '...',
});
} else if (param.type === 'argument') {
// Named argument with default value -> optional parameter
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
isOptional: true,
isVariadic: false,
});
}
}
}
return params;
}

View file

@ -105,7 +105,7 @@ import {
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
import { resolveOperands } from '../route-extractors/python-const-resolver.js';
import type { ModuleConstants } from '../route-extractors/constant-resolver.js';
import { prepareRouteConstantsByProvider } from '../language-provider.js';
import { prepareRouteConstantsByProvider, runPostParseHooks } from '../language-provider.js';
import {
resolveInheritedSpringRoutes,
type SharedSpringType,
@ -1548,6 +1548,15 @@ export async function runChunkedParseAndResolve(
}
}
// Language post-parse hooks: whole-graph work a language can only do once every chunk is merged
// and before scope resolution (e.g. deferred owner resolution). Only languages with parsed files pay.
const presentLanguages = new Set<SupportedLanguages>();
for (const file of parseableScanned) {
const language = languageForScannedFile(file);
if (language !== null) presentLanguages.add(language);
}
await runPostParseHooks(presentLanguages, getProvider, { graph, model, repoPath });
// Worker-path enrichment: if exportedTypeMap is empty (e.g. the worker pool
// built TypeEnv inside workers without access to SymbolTable), reconstruct
// the map from graph nodes + SymbolTable here in the main thread before

View file

@ -29,6 +29,7 @@ import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
import { zigScopeResolver } from '../../languages/zig/scope-resolver.js';
import { objectiveCScopeResolver } from '../../languages/objective-c/scope-resolver.js';
import { rScopeResolver } from '../../languages/r/scope-resolver.js';
/** Map of `SupportedLanguages` → `ScopeResolver`. The scope-resolution phase
* iterates this map directly — every registered resolver runs. This is the
@ -55,4 +56,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Vue, vueScopeResolver],
[SupportedLanguages.Zig, zigScopeResolver],
[SupportedLanguages.ObjectiveC, objectiveCScopeResolver],
[SupportedLanguages.R, rScopeResolver],
]);

View file

@ -3051,6 +3051,189 @@ export const ZIG_QUERIES = `
function: (field_expression
member: (identifier) @call.name)) @call
`;
export const R_QUERIES = `
; ── Functions (name <- function(...) or name = function(...)) ────────────────
(binary_operator
lhs: (identifier) @name
rhs: (function_definition)) @definition.function
; ── S4 Classes (setClass("ClassName", ...)) ──────────────────────────────────
; The S4 patterns below capture EVERY string argument of the call on purpose.
; Which argument names the definition is decided by the provider's
; \`shouldSkipDefinitionCapture\` (languages/r/naming-argument.ts): a query cannot skip an
; unbounded run of leading comments and named arguments without a repeated
; sibling group, which is quadratic in the argument count.
(call
function: (identifier) @_fn
(#match? @_fn "^(setClass|setRefClass)$")
arguments: (arguments
(argument
value: (string
content: (string_content) @name)))) @definition.class
; ── R6 Classes via namespace (ClassName <- R6::R6Class(...)) ─────────────────
(binary_operator
lhs: (identifier) @name
rhs: (call
function: (namespace_operator
rhs: (identifier) @_nsfn
(#match? @_nsfn "^R6Class$")))) @definition.class
; ── R6 Classes via bare call (ClassName <- R6Class(...)) ─────────────────────
(binary_operator
lhs: (identifier) @name
rhs: (call
function: (identifier) @_r6fn
(#match? @_r6fn "^R6Class$"))) @definition.class
; ── R6 Methods via namespace (inside public/private/active = list(...)) ─────
(binary_operator
lhs: (identifier) @_class
rhs: (call
function: (namespace_operator
rhs: (identifier) @_nsfn2
(#match? @_nsfn2 "^R6Class$"))
arguments: (arguments
(argument
name: (identifier) @_section
(#match? @_section "^(public|private|active)$")
value: (call
function: (identifier) @_listfn
(#match? @_listfn "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: (function_definition)) @definition.method))))))
; ── R6 Methods via bare call (inside public/private/active = list(...)) ─────
(binary_operator
lhs: (identifier) @_class2
rhs: (call
function: (identifier) @_r6fn2
(#match? @_r6fn2 "^R6Class$")
arguments: (arguments
(argument
name: (identifier) @_section2
(#match? @_section2 "^(public|private|active)$")
value: (call
function: (identifier) @_listfn2
(#match? @_listfn2 "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: (function_definition)) @definition.method))))))
; ── R5 Methods (inside setRefClass(... methods = list(...))) ─────────────────
(binary_operator
lhs: (identifier) @_class3
rhs: (call
function: (identifier) @_refFn
(#match? @_refFn "^setRefClass$")
arguments: (arguments
(argument
name: (identifier) @_methods
(#match? @_methods "^methods$")
value: (call
function: (identifier) @_listfn3
(#match? @_listfn3 "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: (function_definition)) @definition.method))))))
; ── S4 setGeneric() (generic function declaration) ───────────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^setGeneric$")
arguments: (arguments
(argument
value: (string
content: (string_content) @name)))) @definition.function
; ── S4 setMethod() (method implementation for a class) ──────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^setMethod$")
arguments: (arguments
(argument
value: (string
content: (string_content) @name)))) @definition.method
; ── All function calls ───────────────────────────────────────────────────────
(call
function: (identifier) @call.name) @call
; ── Namespaced calls (pkg::func()) ───────────────────────────────────────────
(call
function: (namespace_operator
lhs: (identifier) @call.namespace
rhs: (identifier) @call.name)) @call
; ── Member calls via $ (obj$method()) ───────────────────────────────────────
(call
function: (extract_operator
rhs: (identifier) @call.name)) @call
; ── Imports (library/require/source) ─────────────────────────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^(library|require|source)$")
arguments: (arguments
(argument
value: [(identifier) (string)] @import.source))) @import
; ── R6 Fields via namespace (non-function entries in public/private list()) ──
(binary_operator
lhs: (identifier) @_class
rhs: (call
function: (namespace_operator
rhs: (identifier) @_nsfn
(#match? @_nsfn "^R6Class$"))
arguments: (arguments
(argument
name: (identifier) @_section
(#match? @_section "^(public|private)$")
value: (call
function: (identifier) @_listfn
(#match? @_listfn "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: [(string) (integer) (float) (true) (false) (null) (identifier) (call) (unary_operator) (binary_operator)] @_val) @definition.property))))))
; ── R6 Fields via bare call ──────────────────────────────────────────────────
(binary_operator
lhs: (identifier) @_class2
rhs: (call
function: (identifier) @_r6fn
(#match? @_r6fn "^R6Class$")
arguments: (arguments
(argument
name: (identifier) @_section2
(#match? @_section2 "^(public|private)$")
value: (call
function: (identifier) @_listfn2
(#match? @_listfn2 "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: [(string) (integer) (float) (true) (false) (null) (identifier) (call) (unary_operator) (binary_operator)] @_val2) @definition.property))))))
; ── S4 slots / representation ────────────────────────────────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^(setClass|setRefClass)$")
arguments: (arguments
(argument
name: (identifier) @_slotArg
(#match? @_slotArg "^(representation|slots|fields)$")
value: (call
function: (identifier) @_listfn
(#match? @_listfn "^(list|representation)$")
arguments: (arguments
(argument
name: (identifier) @name) @definition.property)))))
`;
import { SupportedLanguages } from 'gitnexus-shared';
@ -3073,6 +3256,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Swift]: SWIFT_QUERIES,
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.R]: R_QUERIES,
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
[SupportedLanguages.Zig]: ZIG_QUERIES,
};

View file

@ -0,0 +1,270 @@
import type {
LanguageTypeConfig,
ParameterExtractor,
TypeBindingExtractor,
InitializerExtractor,
ConstructorBindingScanner,
} from './types.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { getRClassNameArgument } from '../field-extractors/r.js';
/**
* R type extractor — roxygen2 annotation parsing.
*
* R has no static type system and roxygen2 has no type syntax: a `@param`
* description is free prose. A parameter is therefore typed from a roxygen
* comment only when the type is stated in an anchored form AND names a class
* the file defines:
*
* #' @param repo UserRepo (the description is the type alone)
* #' @param backup {UserRepo} a spare (the type in braces, prose after it)
* save <- function(repo, backup) { ... }
*
* Prose that merely starts with a capitalised word (`@param df Data frame of
* observations`) binds nothing, even when a class called `Data` exists, and a
* description may run over several lines: it is read up to the next tag.
* The type is bound for a formal of the documented function, inside that
* function only.
*
* Resolution tiers:
* - Tier 0: roxygen2 @param annotations (extractParameter binds each documented formal)
* - Tier 1: Constructor inference via `obj <- ClassName$new()` (R6) or `obj <- new("ClassName")` (S4)
*/
/** A `@param` tag: the parameter name and the description text that follows it. */
interface RoxygenParam {
readonly name: string;
readonly description: string;
}
/** The whole description is a single type word: `UserRepo`. */
const BARE_TYPE_RE = /^([A-Z][\w.]*)$/;
/** The description starts with a braced type: `{UserRepo} the repository`. */
const BRACED_TYPE_RE = /^\{([A-Z][\w.]*)\}(?:\s|$)/;
/**
* Walk backwards through preceding sibling nodes collecting consecutive
* roxygen2 comment lines (`#'`). Returns the joined comment block text.
*/
const collectRoxygenBlock = (node: SyntaxNode): string => {
const commentTexts: string[] = [];
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment' && sibling.text.startsWith("#'")) {
commentTexts.unshift(sibling.text);
} else if (sibling.type === 'comment') {
// Regular comment (not roxygen2) — skip, don't break
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
return commentTexts.join('\n');
};
/**
* Split a roxygen block into its `@param` tags. A tag's description is the
* rest of its line plus every following line up to the next `@tag`, joined by
* single spaces. A tag whose name is not one plain identifier (`x,y`, `...`,
* `na.rm`) is skipped, and its continuation lines belong to no tag.
*/
const parseRoxygenParams = (block: string): RoxygenParam[] => {
const params: RoxygenParam[] = [];
let name: string | undefined;
let parts: string[] = [];
const flush = (): void => {
if (name !== undefined) params.push({ name, description: parts.join(' ').trim() });
name = undefined;
parts = [];
};
for (const rawLine of block.split('\n')) {
const line = rawLine.replace(/^#'[ \t]*/, '').trim();
const tag = /^@(\w+)(?:[ \t]+(.*))?$/.exec(line);
if (tag) {
flush();
if (tag[1] !== 'param') continue;
const head = /^(\w+)(?:[ \t]+(.*))?$/.exec(tag[2] ?? '');
if (!head) continue;
name = head[1];
if (head[2]) parts.push(head[2]);
} else if (name !== undefined && line.length > 0) {
parts.push(line);
}
}
flush();
return params;
};
/** The type a `@param` description states in an anchored form, if any. */
const anchoredType = (description: string): string | undefined =>
(BARE_TYPE_RE.exec(description) ?? BRACED_TYPE_RE.exec(description))?.[1];
/**
* The class a node declares, in the shapes `query.ts` captures as
* `@declaration.class`: `X <- R6Class(...)` / `X <- R6::R6Class(...)`, and
* `setClass("X", ...)` / `setRefClass("X", ...)`.
*/
const declaredClassName = (node: SyntaxNode): string | undefined => {
if (node.type === 'binary_operator') {
const lhs = node.childForFieldName('lhs');
const rhs = node.childForFieldName('rhs');
if (lhs?.type !== 'identifier' || rhs?.type !== 'call') return undefined;
const fn = rhs.childForFieldName('function');
const calleeName =
fn?.type === 'namespace_operator' ? fn.childForFieldName('rhs')?.text : fn?.text;
return calleeName === 'R6Class' ? lhs.text : undefined;
}
if (node.type !== 'call') return undefined;
const fn = node.childForFieldName('function');
if (fn?.type !== 'identifier' || (fn.text !== 'setClass' && fn.text !== 'setRefClass')) {
return undefined;
}
// The class name is the argument that takes the `Class` formal, by name or else
// the first unnamed one; `contains = "Base"` ahead of `Class = "A"` is not it.
const args = node.childForFieldName('arguments');
return (args && getRClassNameArgument(args)) ?? undefined;
};
/** Class names defined anywhere in the file, built on first use and kept per tree. */
const localClassNamesByTree = new WeakMap<object, ReadonlySet<string>>();
const localClassNames = (node: SyntaxNode): ReadonlySet<string> => {
const cached = localClassNamesByTree.get(node.tree);
if (cached) return cached;
const names = new Set<string>();
for (const candidate of node.tree.rootNode.descendantsOfType(['binary_operator', 'call'])) {
const name = declaredClassName(candidate);
if (name !== undefined) names.add(name);
}
localClassNamesByTree.set(node.tree, names);
return names;
};
/**
* The roxygen2 `@param` types documented for an assigned function: the
* comment block directly above `name <- function(...)`, keyed by parameter
* name. Returns a map of paramName → typeName.
*/
const collectRoxygenParams = (assignment: SyntaxNode): Map<string, string> => {
const params = new Map<string, string>();
for (const { name, description } of parseRoxygenParams(collectRoxygenBlock(assignment))) {
const typeName = anchoredType(description);
if (typeName !== undefined && localClassNames(assignment).has(typeName)) {
params.set(name, typeName);
}
}
return params;
};
/**
* R node types that may carry type bindings.
* - `binary_operator`: function definitions use `name <- function(...)` which
* tree-sitter-r parses as binary_operator nodes. Also used for constructor
* assignments like `obj <- ClassName$new()`.
*/
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set(['binary_operator']);
/**
* R declaration extraction. Roxygen types are bound per formal by
* `extractParameter`, so a function definition binds nothing here: this
* hook runs in the scope that CONTAINS the assignment (the file, for a
* top-level function), and a type bound there would be visible in every
* function of the file.
*/
const extractDeclaration: TypeBindingExtractor = (
_node: SyntaxNode,
_env: Map<string, string>,
): void => {
// Constructor bindings are made by extractInitializer; roxygen types by extractParameter.
};
/**
* R parameter extraction: the roxygen2 `@param` type of a formal.
*
* Runs once per formal with the env of the function that owns it, so the
* binding is visible inside that function only. The roxygen block is the one
* above the assignment `name <- function(...)` the formal belongs to; a name
* the block documents but the function does not have is never visited, and a
* function that is not the right-hand side of an assignment (a callback)
* is documented by nothing. The `...` formal is not an identifier and is
* skipped.
*/
const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
if (node.type !== 'parameter') return;
const name = node.childForFieldName('name');
if (name?.type !== 'identifier') return;
const fn = node.parent?.parent;
if (fn?.type !== 'function_definition') return;
const assignment = fn.parent;
if (assignment?.type !== 'binary_operator') return;
const rhs = assignment.childForFieldName('rhs');
if (rhs?.startIndex !== fn.startIndex || rhs.endIndex !== fn.endIndex) return;
const typeName = collectRoxygenParams(assignment).get(name.text);
if (typeName !== undefined) env.set(name.text, typeName);
};
/**
* R constructor inference:
* - R6: `obj <- ClassName$new(...)` → type is ClassName
* - S4: `obj <- new("ClassName", ...)` → type is ClassName
*
* Resolves against locally-known class names.
*/
const extractInitializer: InitializerExtractor = (node, env, classNames): void => {
const result = scanConstructorBinding(node);
if (!result) return;
if (env.has(result.varName)) return;
if (classNames.has(result.calleeName)) {
env.set(result.varName, result.calleeName);
}
};
/**
* R constructor binding scanner: captures both R6 `obj <- ClassName$new()`
* and S4 `obj <- new("ClassName", ...)` patterns.
*/
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'binary_operator') return undefined;
const lhs = node.childForFieldName('lhs');
const rhs = node.childForFieldName('rhs');
if (!lhs || !rhs) return undefined;
if (lhs.type !== 'identifier') return undefined;
if (rhs.type !== 'call') return undefined;
const fn = rhs.childForFieldName('function');
if (!fn) return undefined;
// R6 pattern: obj <- ClassName$new(...)
// tree-sitter-r parses `ClassName$new` as an `extract_operator` node
if (fn.type === 'extract_operator') {
const children = fn.namedChildren;
if (children.length >= 2) {
const className = children[0];
const method = children[1];
if (className?.type === 'identifier' && method?.text === 'new') {
return { varName: lhs.text, calleeName: className.text };
}
}
}
// S4 pattern: obj <- new("ClassName", ...)
if (fn.type === 'identifier' && fn.text === 'new') {
// `new(Class, ...)`: the class is the argument that takes the `Class` formal.
const args = rhs.childForFieldName('arguments');
const className = args && getRClassNameArgument(args);
if (className) return { varName: lhs.text, calleeName: className };
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
};

View file

@ -14,6 +14,7 @@ import CPP from 'tree-sitter-cpp';
import { getLanguageGrammar } from '../../tree-sitter/parser-loader.js';
import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import R from '@eagleoutice/tree-sitter-r';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import { requireVendoredGrammar } from '../../tree-sitter/vendored-grammars.js';
@ -607,6 +608,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
[SupportedLanguages.R]: R,
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
@ -687,7 +689,9 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
* a type (`resolveFileTypeOwner`, e.g. a Zig file-struct), the tree root is
* the owner node the method/field extractors should read members from. Same
* root, same name as `findEnclosingClassInfo`'s root branch, so member ids and
* owner ids agree.
* owner ids agree. Failing both, the provider's `resolveMemberOwnerNode` gets
* the last word (languages whose members belong to a call/assignment rather
* than an enclosing container node, e.g. R's `R6Class(...)`).
*/
function findEnclosingClassNodeOrFileOwner(
node: SyntaxNode,
@ -696,10 +700,12 @@ function findEnclosingClassNodeOrFileOwner(
): SyntaxNode | null {
const container = findEnclosingClassNode(node);
if (container !== null) return container;
if (provider.resolveFileTypeOwner === undefined) return null;
let root: SyntaxNode = node;
while (root.parent) root = root.parent;
return provider.resolveFileTypeOwner(root, filePath) !== null ? root : null;
if (provider.resolveFileTypeOwner !== undefined) {
let root: SyntaxNode = node;
while (root.parent) root = root.parent;
if (provider.resolveFileTypeOwner(root, filePath) !== null) return root;
}
return provider.resolveMemberOwnerNode?.(node) ?? null;
}
/**

View file

@ -131,6 +131,11 @@ const SOURCES: Record<string, GrammarSource> = {
unavailableNote:
'Ruby parsing requires `tree-sitter-ruby`. Check the install and native binding.',
},
[SupportedLanguages.R]: {
load: () => _require('@eagleoutice/tree-sitter-r'),
unavailableNote:
'R parsing requires `@eagleoutice/tree-sitter-r`. Check the install and native binding.',
},
[SupportedLanguages.Vue]: {
load: () => _require('tree-sitter-typescript').typescript,
unavailableNote:

View file

@ -858,7 +858,25 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// and original graph positions. Both stores must re-extract v135 captures.
// v137: Python module-accessor syntax proofs and exact assigned-call provenance
// join the existing subtype capture payload. Re-extract v136 in both stores.
const SCHEMA_BUMP = 137;
// v138: R provider fixes, collapsed into one bump because none of them has
// shipped. R scope queries anchor a `@scope.function` on every named-function
// assignment and named function-valued argument (the same nodes the
// `@declaration.function`/`@declaration.method` anchors use), so each def is
// owned by its own Function scope instead of the module/Class scope, and
// capture the `pkg::`/`pkg:::` qualifier of namespaced calls as
// `@reference.qualified-name` so it reaches `site.rawQualifiedName`. The R
// provider also keeps only the argument that names an S4 definition
// (shouldSkipDefinitionCapture), so `setClass("A", contains = "VIRTUAL")` no
// longer defines a Class `VIRTUAL`, `setGeneric(..., valueClass = "numeric")` a
// Function `numeric`, nor `setMethod("show", "Foo", ...)` a Method `Foo`; the R
// scope emitter likewise keeps only the argument that names a `setClass` class
// or a `library`/`require`/`source` import, so `library(lib.loc = libO, pkgP)`
// no longer imports `libO` and a comment before the first argument no longer
// hides the class or import. Definitions and scope captures are parse-time
// facts replayed verbatim from the warm cache, so stale ones (the pre-fix scope
// tree, the unqualified call-site shape, the mis-selected arguments) would
// persist on unchanged files; both stores must re-extract.
const SCHEMA_BUMP = 138;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,38 @@
# Synthetic leaf callees for the R caller-attribution fixture (no calls inside).
leaf_a <- function() NULL
leaf_b <- function() NULL
leaf_c <- function() NULL
leaf_d <- function() NULL
leaf_e <- function() NULL
leaf_f <- function() NULL
leaf_g <- function() NULL
leaf_h <- function() NULL
leaf_i <- function() NULL
leaf_j <- function() NULL
leaf_k <- function() NULL
leaf_l <- function() NULL
leaf_m <- function() NULL
leaf_n <- function() NULL
leaf_o <- function() NULL
leaf_p <- function() NULL
leaf_q <- function() NULL
leaf_r <- function() NULL
leaf_s <- function() NULL
leaf_t <- function() NULL
leaf_u <- function() NULL
leaf_v <- function() NULL
leaf_w <- function() NULL
leaf_x <- function() NULL
leaf_y <- function() NULL
leaf_z <- function() NULL
leaf_aa <- function() NULL
leaf_ab <- function() NULL
leaf_ac <- function() NULL
leaf_ad <- function() NULL
leaf_ae <- function() NULL
leaf_af <- function() NULL
leaf_ag <- function() NULL
leaf_ah <- function() NULL
leaf_ai <- function() NULL
leaf_aj <- function() NULL
leaf_ak <- function() NULL

View file

@ -0,0 +1,23 @@
# Every file starts with a decoy first function: a call misattributed to the
# file's first callable shows up as an edge from `decoy_named`.
decoy_named <- function() leaf_a()
arrow_fn <- function(x) leaf_b(x)
equals_fn = function(x) leaf_c(x)
super_fn <<- function(x) leaf_d(x)
lambda_fn <- \(x) leaf_e(x)
dotted.name.fn <- function(x) leaf_f(x)
`backtick name fn` <- function(x) leaf_g(x)
pair_one <- function() leaf_h(1); pair_two <- function() leaf_i(2)
if (TRUE) cond_fn <- function() leaf_j(4)
default_arg_fn <- function(a = leaf_k(1)) {
a
}
registry <- list()
registry$member_fn <- function(x) leaf_l(x)
res <- leaf_x(1)

View file

@ -0,0 +1,19 @@
decoy_nesting <- function() leaf_m()
outer <- function(x) {
inner <- function(y) leaf_n(y)
inner(x)
lapply(x, function(z) leaf_o(z))
sapply(x, FUN = function(z) leaf_p(z))
tryCatch(leaf_q(x), error = function(e) leaf_r(e))
}
level1 <- function() {
level2 <- function() {
level3 <- function() leaf_s()
level3()
leaf_t()
}
level2()
leaf_u()
}

View file

@ -0,0 +1,26 @@
decoy_r6 <- function() leaf_v()
Widget <- R6::R6Class("Widget",
public = list(
cache = leaf_w(),
first = function() leaf_y(),
run = function() {
helper <- function() leaf_z()
helper()
lapply(1:3, function(i) leaf_aa(i))
},
relay = function() self$peer(),
peer = function() leaf_ab()
),
private = list(
hidden = function() leaf_ac()
),
active = list(
shown = function() leaf_ad()
)
)
Counter <- setRefClass("Counter", methods = list(
bump = function() leaf_ae(),
reset = function() leaf_af()
))

View file

@ -0,0 +1,6 @@
decoy_s4 <- function() leaf_ag()
setClass("Sq", representation(side = "numeric"))
setGeneric("area", function(shape) standardGeneric("area"))
setMethod("area", "Sq", function(shape) leaf_ah(shape))
setClass("P", representation(x = "numeric"), validity = function(object) leaf_ai(object))

View file

@ -0,0 +1,2 @@
result <- leaf_aj(1)
print(leaf_ak(result))

View file

@ -0,0 +1,3 @@
Package: other
Title: Other
Version: 0.1.0

View file

@ -0,0 +1 @@
export(other_user)

View file

@ -0,0 +1,3 @@
other_helper <- function(v) helper.util(v)
other_print <- function(v) print.widget(v)
other_plain <- function(v) plain_fn(v)

View file

@ -0,0 +1,3 @@
Package: prov
Title: Provider
Version: 0.1.0

View file

@ -0,0 +1,3 @@
export(helper.util)
export(plain_fn)
S3method(print, widget)

View file

@ -0,0 +1,3 @@
helper.util <- function(x) x
print.widget <- function(x, ...) invisible(x)
plain_fn <- function(x) x

View file

@ -0,0 +1,3 @@
same_pkg_helper <- function(v) helper.util(v)
same_pkg_print <- function(v) print.widget(v)
same_pkg_plain <- function(v) plain_fn(v)

View file

@ -0,0 +1 @@
bare_helper <- function(v) helper.util(v)

View file

@ -0,0 +1,3 @@
library(prov)
script_helper <- function(v) helper.util(v)
script_print <- function(v) print.widget(v)

View file

@ -0,0 +1,3 @@
Package: cons
Title: Consumer
Version: 0.1.0

View file

@ -0,0 +1 @@
export(use_foo)

View file

@ -0,0 +1,7 @@
library(prov)
use_foo <- function(v) foo(v)
use_bar <- function(v) bar(v)
use_baz <- function(v) baz(v)
use_dotted <- function(x) print.foo(x)
use_qux <- function(v) qux(v)
use_odd <- function(v) `odd name`(v)

View file

@ -0,0 +1,3 @@
Package: prov
Title: Provider
Version: 0.1.0

View file

@ -0,0 +1,7 @@
export(foo)
export(bar)
export(baz)
S3method(print, foo)
S3method(print, bar)
S3method(summary, baz)
export(qux)

View file

@ -0,0 +1,6 @@
same_pkg_foo <- function(v) foo(v)
same_pkg_bar <- function(v) bar(v)
same_pkg_baz <- function(v) baz(v)
dotted_call <- function(x) print.foo(x)
same_pkg_qux <- function(v) qux(v)
same_pkg_odd <- function(v) `odd name`(v)

View file

@ -0,0 +1,12 @@
print.foo <- function(x, ...) x
foo <- function(v) structure(list(v = v), class = "foo")
bar <- function(v) structure(list(v = v), class = "bar")
print.bar <- function(x, ...) x
baz <- function(v) v
summary.baz <- function(object, ...) object
qux <- function(v) v
`odd name` <- function(v) v

View file

@ -0,0 +1,4 @@
library(prov)
script_foo <- function(v) foo(v)
script_bar <- function(v) bar(v)
script_qux <- function(v) qux(v)

View file

@ -0,0 +1,3 @@
Package: analytics
Title: NAMESPACE Import Fixture Caller
Version: 0.1.0

View file

@ -0,0 +1,37 @@
export(run_all)
# Single-line and multi-line importFrom() directives from local packages.
importFrom(scorelib, tidy_scores)
importFrom(
scorelib,
normalise_scores,
rank_scores
)
importFrom("scorelib", "quoted_fn")
# Exported by scorelib, but also defined in this package: the own package masks it.
importFrom(scorelib, own_dup)
# Duplicate names: the later directive wins (legacyscore).
importFrom(scorelib, dup_fn)
importFrom(legacyscore, dup_fn)
# Not exported by scorelib; never exported name with no definition; whole-package import.
importFrom(scorelib, hidden_helper)
importFrom(scorelib, no_such_fn)
import(reportlib)
# Dotted names cannot bind precisely.
importFrom(scorelib, normalise.scores)
importFrom(scorelib, tidy)
importFrom(scorelib, tally)
importFrom(scorelib, plain_fn)
# R6 members versus top-level definitions.
importFrom(scorelib, score_it)
importFrom(scorelib, describe_run)
# Re-export through a local provider (scorelib imports it from corelib).
importFrom(scorelib, reexp_fn)
# Nested function sharing a name with a later top-level function.
importFrom(scorelib, nest_fn)
# External packages never bind.
importFrom(dplyr, mutate)
# Cross-package decoy guesses the NAMESPACE contradicts (name-guess veto).
importFrom(dplyr, filter)
importFrom(scorelib, dup_ext)
importFrom(dplyr, dup_ext)
importFrom(dplyr, scripts_only_fn)

View file

@ -0,0 +1,3 @@
# First function of the package in load order: a decoy that owns none of the
# edges emitted from the functions written in later files.
first_fn <- function(d) d

View file

@ -0,0 +1,15 @@
dotted_name_user <- function(d) {
normalise.scores(d)
}
print_before_bare_user <- function(d) {
tidy(d)
}
bare_before_print_user <- function(d) {
tally(d)
}
underscore_user <- function(d) {
plain_fn(d)
}

View file

@ -0,0 +1,3 @@
nested_user <- function(d) {
nest_fn(d)
}

View file

@ -0,0 +1,3 @@
own_dup <- function(d) d
# Same name as an externally imported function: the own definition masks the import.
filter <- function(d) d

View file

@ -0,0 +1,10 @@
# Explicitly qualified call to a package the NAMESPACE does not import it from.
qualified_user <- function(d) {
legacyscore::tidy_scores(d)
}
# Correctly qualified call to the package that really defines `mutate`, while the
# NAMESPACE imports `mutate` from dplyr.
qualified_mutate_user <- function(d) {
legacyscore::mutate(d)
}

View file

@ -0,0 +1,7 @@
r6_flag_user <- function(d) {
score_it(d)
}
r6_method_only_user <- function(d) {
describe_run(d)
}

View file

@ -0,0 +1,3 @@
reexport_user <- function(d) {
reexp_fn(d)
}

View file

@ -0,0 +1,4 @@
# A second R file: every R file of the package sees the NAMESPACE imports.
second_user <- function(d) {
tidy_scores(d)
}

View file

@ -0,0 +1,11 @@
run_all <- function(d) {
tidy_scores(d)
normalise_scores(d)
rank_scores(d)
quoted_fn(d)
own_dup(d)
dup_fn(d)
hidden_helper(d)
no_such_fn(d)
d
}

View file

@ -0,0 +1,17 @@
# Callers for the name-guess veto scenarios. Each lives in its own function so the exact
# call sets of the other fixture functions stay untouched.
mutate_user <- function(d) {
mutate(d)
}
filter_user <- function(d) {
filter(d)
}
dup_ext_user <- function(d) {
dup_ext(d)
}
scripts_only_user <- function(d) {
scripts_only_fn(d)
}

View file

@ -0,0 +1,2 @@
# Script outside any package R/ directory: NAMESPACE imports do not apply here.
scripts_only_fn <- function(d) d

View file

@ -0,0 +1,4 @@
# Outside the package R/ directory: NAMESPACE imports do not apply, so the name guess stands.
test_mutate_user <- function(d) {
mutate(d)
}

View file

@ -0,0 +1,3 @@
test_that_user <- function(d) {
tidy_scores(d)
}

View file

@ -0,0 +1,3 @@
Package: corelib
Title: NAMESPACE Import Fixture Origin
Version: 0.1.0

View file

@ -0,0 +1 @@
export(reexp_fn)

View file

@ -0,0 +1 @@
reexp_fn <- function(d) d

View file

@ -0,0 +1,3 @@
Package: legacyscore
Title: NAMESPACE Import Fixture Decoy
Version: 0.1.0

View file

@ -0,0 +1,3 @@
export(tidy_scores)
export(dup_fn)
export(mutate)

View file

@ -0,0 +1,3 @@
tidy_scores <- function(d) d
dup_fn <- function(d) d
mutate <- function(d) d

View file

@ -0,0 +1,3 @@
Package: scorelib
Title: NAMESPACE Import Fixture Provider
Version: 0.1.0

View file

@ -0,0 +1,17 @@
export(tidy_scores)
export(normalise_scores)
export(rank_scores)
export(quoted_fn)
export(own_dup)
export(dup_fn)
export(no_such_fn)
export(normalise.scores)
export(tidy)
export(tally)
export(plain_fn)
export(score_it)
export(describe_run)
export(reexp_fn)
export(nest_fn)
importFrom(corelib, reexp_fn)
export(dup_ext)

View file

@ -0,0 +1,7 @@
# S3-style dotted definitions: a dotted def is indexed under its last segment.
print.tidy <- function(x, ...) x
tidy <- function(d) d
normalise.scores <- function(d) d
tally <- function(d) d
print.tally <- function(x, ...) x

View file

@ -0,0 +1,5 @@
# Also defined (and exported) by legacyscore, which the importer names last.
dup_fn <- function(d) d
# Exported here, but the importer names an external package last: no binding, and the
# unique-name guess to this definition is contradicted by the NAMESPACE.
dup_ext <- function(d) d

View file

@ -0,0 +1,2 @@
# Not listed in NAMESPACE: defined but not exported.
hidden_helper <- function(d) d

View file

@ -0,0 +1,6 @@
nest_host <- function(d) {
nest_fn <- function(y) y
nest_fn(d)
}
nest_fn <- function(d) d

View file

@ -0,0 +1,2 @@
# Also defined in the importing package, whose own definition masks this one.
own_dup <- function(d) d

View file

@ -0,0 +1 @@
plain_fn <- function(d) d

View file

@ -0,0 +1,15 @@
Scorer <- R6::R6Class("Scorer",
public = list(
# Method defined BEFORE the top-level function of the same name.
score_it = function(d) d
)
)
score_it <- function(d) d
Reporter <- R6::R6Class("Reporter",
public = list(
# Only a method: no top-level function carries this name.
describe_run = function(d) d
)
)

View file

@ -0,0 +1,4 @@
tidy_scores <- function(d) d
normalise_scores <- function(d) d
rank_scores <- function(d) d
quoted_fn <- function(d) d

View file

@ -0,0 +1,4 @@
# Same-named file in another language: an external R package must never
# suffix-match it.
def mutate(d):
return d

View file

@ -0,0 +1,3 @@
Package: pkgmain
Title: Native Pipe Fixture Main
Version: 0.1.0

Some files were not shown because too many files have changed in this diff Show more