feat(r): wire R onto the scope-resolution pipeline

Add emitRScopeCaptures/interpretRImport/interpretRTypeBinding and a new
rScopeResolver, modeled on Zig's/Ruby's shape, and register it in
SCOPE_RESOLVERS. Captures cover scopes (function/R6-class/S4-class, the
class scope spanning the whole R6Class()/setClass() call so inherit=/
contains= sites resolve inside it), declarations (function, class without
a qualified_name so the bare-name fallback matches the legacy node,
R6/R5 methods), imports (source() as side-effect, library()/require() as
wildcard per the unfiltered-by-NAMESPACE policy), the R6 constructor
type-binding (Type$new(...)), and heritage references (R6 inherit=, S4
contains=/contains=c()), which resolve via the generic
preEmitInheritanceEdges mechanism directly.

38/39 r.test.ts scenarios pass; only the VIRTUAL sentinel (no
setClass("VIRTUAL", ...) exists anywhere) remains, requiring a custom
emitHeritageEdges hook.

Adds R's entries to the two registry-census unit tests
(import-target-index-reuse.contract.test.ts,
external-import-conformance.test.ts) that assert every SCOPE_RESOLVERS
key has a fixture/case.
This commit is contained in:
Mick Cooney 2026-09-28 18:03:37 +01:00
parent b9a4ed619f
commit 658e5ce840
9 changed files with 555 additions and 0 deletions

View file

@ -14,6 +14,8 @@ 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';
const R_BUILT_INS: ReadonlySet<string> = new Set([
// Base R
@ -178,4 +180,7 @@ export const rProvider = defineLanguage({
fieldExtractor: new RFieldExtractor(),
methodExtractor: rMethodExtractor,
builtInNames: R_BUILT_INS,
emitScopeCaptures: emitRScopeCaptures,
interpretImport: interpretRImport,
interpretTypeBinding: interpretRTypeBinding,
});

View file

@ -0,0 +1,44 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture } from '../../utils/ast-helpers.js';
import { getRParser, getRScopeQuery } from './query.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) {
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,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' };
}

View file

@ -0,0 +1,301 @@
/**
* Tree-sitter query for R scope captures (RFC #909 Ring 3, kaybenleroll/GitNexus#1).
*
* 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, 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 (§6/stress-test).
*
* - `@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
* narrower range than the enclosing Class scope, so Pass 2 attaches the
* method as an owned def of the class scope directly (no hoist).
*
* - 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
;; ── Declarations — classes (name only; no qualified_name, see file header) ─
(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))))))
;; ── 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()
(call
function: (namespace_operator
rhs: (identifier) @reference.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,86 @@
/**
* R `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by the
* generic `runScopeResolution` orchestrator.
*
* Thin wiring: import resolution reuses `resolveRImportTarget`
* (`import-resolvers/r.ts`), the adapter step 2 of this plan added over the
* existing `resolveRImportInternal`, with the repo's local R packages
* threaded through `loadRPackageConfig`. Heritage (R6 `inherit=`, S4
* `contains=`) 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 and need a custom
* `emitHeritageEdges` hook.
*/
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { loadRPackageConfig } from '../../language-config.js';
import { resolveRImportTarget } from '../../import-resolvers/r.js';
import { rProvider } from '../r.js';
import { rArityCompatibility, rMergeBindings } from './simple-hooks.js';
/**
* 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 — verified during
* the plan's stress-test round: `pkgA/NAMESPACE` does not export `ResultSet`,
* yet `require("pkgA")` must still resolve it).
*/
function expandRWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
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 (seen.has(qn)) continue;
seen.add(qn);
names.push(qn);
}
return names;
}
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),
// 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,
};

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

@ -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

@ -412,6 +412,21 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
reachesDecoy: 'std.zig',
},
],
[
SupportedLanguages.R,
{
files: ['scripts/main.R', 'vendor/ggplot2.py', 'pkgA/R/utils.R'],
fromFile: 'scripts/main.R',
resolutionConfig: undefined,
external: 'ggplot2',
decoy: 'vendor/ggplot2.py',
reachesDecoy: 'ggplot2.py',
parsedImport: (targetRaw) =>
targetRaw === 'ggplot2'
? { kind: 'wildcard', targetRaw }
: { kind: 'side-effect', targetRaw },
},
],
[
SupportedLanguages.ObjectiveC,
{

View file

@ -546,6 +546,19 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.R,
{
files: ['scripts/main.R', 'scripts/utils.R', 'pkgA/R/other.R'],
fromFile: 'scripts/main.R',
resolutionConfig: undefined,
missTarget: (i) => `ghost${i}.R`,
hitTarget: 'utils.R',
parsedImport: (targetRaw) => ({ kind: 'side-effect', targetRaw }),
minimumScans: 1,
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.ObjectiveC,
{