From 658e5ce840f46030556e50be00f5dcb4036ab348 Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Mon, 28 Sep 2026 18:03:37 +0100 Subject: [PATCH] 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. --- gitnexus/src/core/ingestion/languages/r.ts | 5 + .../core/ingestion/languages/r/captures.ts | 44 +++ .../core/ingestion/languages/r/interpret.ts | 40 +++ .../src/core/ingestion/languages/r/query.ts | 301 ++++++++++++++++++ .../ingestion/languages/r/scope-resolver.ts | 86 +++++ .../ingestion/languages/r/simple-hooks.ts | 49 +++ .../scope-resolution/pipeline/registry.ts | 2 + .../external-import-conformance.test.ts | 15 + ...import-target-index-reuse.contract.test.ts | 13 + 9 files changed, 555 insertions(+) create mode 100644 gitnexus/src/core/ingestion/languages/r/captures.ts create mode 100644 gitnexus/src/core/ingestion/languages/r/interpret.ts create mode 100644 gitnexus/src/core/ingestion/languages/r/query.ts create mode 100644 gitnexus/src/core/ingestion/languages/r/scope-resolver.ts create mode 100644 gitnexus/src/core/ingestion/languages/r/simple-hooks.ts diff --git a/gitnexus/src/core/ingestion/languages/r.ts b/gitnexus/src/core/ingestion/languages/r.ts index 3fe4811e8..103ee184b 100644 --- a/gitnexus/src/core/ingestion/languages/r.ts +++ b/gitnexus/src/core/ingestion/languages/r.ts @@ -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 = 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, }); diff --git a/gitnexus/src/core/ingestion/languages/r/captures.ts b/gitnexus/src/core/ingestion/languages/r/captures.ts new file mode 100644 index 000000000..787d764f2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/captures.ts @@ -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['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 = {}; + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/r/interpret.ts b/gitnexus/src/core/ingestion/languages/r/interpret.ts new file mode 100644 index 000000000..7632a08aa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/interpret.ts @@ -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' }; +} diff --git a/gitnexus/src/core/ingestion/languages/r/query.ts b/gitnexus/src/core/ingestion/languages/r/query.ts new file mode 100644 index 000000000..0b61488fb --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/query.ts @@ -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 <- ` 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[0]); + } + return _parser; +} + +export function getRScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(R as Parameters[0], R_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts new file mode 100644 index 000000000..524071f79 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts @@ -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(); + 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, +}; diff --git a/gitnexus/src/core/ingestion/languages/r/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/r/simple-hooks.ts new file mode 100644 index 000000000..bc81c30da --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/simple-hooks.ts @@ -0,0 +1,49 @@ +import type { BindingRef, Callsite, SymbolDefinition } from 'gitnexus-shared'; + +const TIER: Record = { + 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(); + 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'; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index ee98e3807..82f7c681c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -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 = n [SupportedLanguages.Vue, vueScopeResolver], [SupportedLanguages.Zig, zigScopeResolver], [SupportedLanguages.ObjectiveC, objectiveCScopeResolver], + [SupportedLanguages.R, rScopeResolver], ]); diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index fd29e7eea..91f887b37 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -412,6 +412,21 @@ const CASES: ReadonlyMap = 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, { diff --git a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts index af18b0a00..af5cd09ed 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts @@ -546,6 +546,19 @@ const FIXTURES: ReadonlyMap = 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, {