From e45fa2e080870b00a6c6c66653308fe1c08bd958 Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Fri, 16 Oct 2026 15:52:26 +0100 Subject: [PATCH] fix(r): read slots and fields given as c(...) vectors The slot pattern matched only list(...) and representation(...), although the field extractor accepts any call, so setClass("A", slots = c(a = "numeric")) and setRefClass("R", fields = c(...)) created no Property nodes. Accept c(...) for both. An unnamed string in slots = c("a", "b") or fields = c("a", "b") names an untyped member, as in R; an unnamed string in representation(...) or list(...) is still not a member. The new pattern captures its callee as @_vecOwner, so the class-naming capture filter does not mistake these strings for the class name. --- docs/languages/r-provider.md | 1 + .../src/core/ingestion/field-extractors/r.ts | 21 +++++++- .../src/core/ingestion/tree-sitter-queries.ts | 23 ++++++++- .../r-vector-members/R/vectors.R | 5 ++ .../resolvers/r-vector-members.test.ts | 45 ++++++++++++++++ .../test/unit/r-definition-anchors.test.ts | 51 +++++++++++++++++++ .../r-field-extractor-class-owner.test.ts | 37 ++++++++++++++ 7 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/r-vector-members/R/vectors.R create mode 100644 gitnexus/test/integration/resolvers/r-vector-members.test.ts diff --git a/docs/languages/r-provider.md b/docs/languages/r-provider.md index 01eea34b3..e1ccb8d46 100644 --- a/docs/languages/r-provider.md +++ b/docs/languages/r-provider.md @@ -18,6 +18,7 @@ Definitions: - 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. +- Slots and fields given as `list(...)`, `representation(...)` or `c(...)`. A named entry (`c(a = "numeric")`) records its type; an unnamed string in `slots = c("a", "b")` or `fields = c("a", "b")` names an untyped member, as in R. An unnamed string in `representation(...)` or `list(...)` is not a member. - 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: diff --git a/gitnexus/src/core/ingestion/field-extractors/r.ts b/gitnexus/src/core/ingestion/field-extractors/r.ts index d2cc8a141..1bce4f3c3 100644 --- a/gitnexus/src/core/ingestion/field-extractors/r.ts +++ b/gitnexus/src/core/ingestion/field-extractors/r.ts @@ -129,13 +129,32 @@ export class RFieldExtractor extends BaseFieldExtractor { const slotArgs = slotCall.childForFieldName('arguments'); if (!slotArgs) continue; + // Only an unnamed character vector (`slots = c("a", "b")`) names its slots by + // their strings; elsewhere an unnamed string is not a slot (`representation("Base")`). + const unnamedNamesSlots = + paramName !== 'representation' && slotCall.childForFieldName('function')?.text === 'c'; + 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; + if (!slotName) { + const unnamed = unnamedNamesSlots ? extractQuotedTypeName(slotType) : null; + if (unnamed) { + fields.push({ + name: unnamed, + type: null, + visibility: 'public', + isStatic: false, + isReadonly: false, + sourceFile: context.filePath, + line: slot.startPosition.row + 1, + }); + } + continue; + } const typeName = extractQuotedTypeName(slotType); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 4dff706f6..29fe7e7c2 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -3242,10 +3242,31 @@ export const R_QUERIES = ` (#match? @_slotArg "^(representation|slots|fields)$") value: (call function: (identifier) @_listfn - (#match? @_listfn "^(list|representation)$") + (#match? @_listfn "^(list|representation|c)$") arguments: (arguments (argument name: (identifier) @name) @definition.property))))) + +; ── S4 / RefClass unnamed names: slots = c("a", "b"), fields = c("a", "b") ── +; An unnamed character vector names the slots or fields (their class is ANY). Only +; c(...) reads this way: an unnamed string in representation(...) is a superclass. +; The callee is captured as @_vecOwner, not @_fn, so these string captures are not +; mistaken for the class-naming argument by isRNonNamingArgumentCapture. +(call + function: (identifier) @_vecOwner + (#match? @_vecOwner "^(setClass|setRefClass)$") + arguments: (arguments + (argument + name: (identifier) @_vecArg + (#match? @_vecArg "^(slots|fields)$") + value: (call + function: (identifier) @_vecfn + (#match? @_vecfn "^c$") + arguments: (arguments + (argument + !name + value: (string + content: (string_content) @name)) @definition.property))))) `; import { SupportedLanguages } from 'gitnexus-shared'; diff --git a/gitnexus/test/fixtures/lang-resolution/r-vector-members/R/vectors.R b/gitnexus/test/fixtures/lang-resolution/r-vector-members/R/vectors.R new file mode 100644 index 000000000..54dbd50f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-vector-members/R/vectors.R @@ -0,0 +1,5 @@ +# slots / fields given as c(...) rather than list(...). +setClass("Person", slots = c(name = "character", age = "numeric")) +setClass("Tagged", slots = c("tag", "weight")) +Account <- setRefClass("Account", fields = c(balance = "numeric")) +Ledger <- setRefClass("Ledger", fields = c("entries")) diff --git a/gitnexus/test/integration/resolvers/r-vector-members.test.ts b/gitnexus/test/integration/resolvers/r-vector-members.test.ts new file mode 100644 index 000000000..746ec7001 --- /dev/null +++ b/gitnexus/test/integration/resolvers/r-vector-members.test.ts @@ -0,0 +1,45 @@ +/** + * R: `slots = c(...)` and `fields = c(...)` are the forms `methods::setClass` and + * `setRefClass` document. They used to create no Property nodes, although the + * `slots = list(...)` and `representation(...)` forms did. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo } from './helpers.js'; +import type { PipelineResult } from './helpers.js'; + +// Cold worker-pool startups otherwise flake against the 5s default ready budget +// on a loaded runner, failing for reasons unrelated to R parsing or resolution. +// Safe under `pool: 'forks'`: the env stub is process-global, but each file gets its own process. +beforeAll(() => vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000')); +afterAll(() => vi.unstubAllEnvs()); + +describe('R slots / fields given as a c(...) vector', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'r-vector-members'), () => {}); + }, 60000); + + it('creates a Property for every named and unnamed entry, owned by its class', () => { + const owned = getRelationships(result, 'HAS_PROPERTY') + .map((e) => `${e.source}->${e.target}`) + .sort(); + expect(owned).toEqual([ + 'Account->balance', + 'Ledger->entries', + 'Person->age', + 'Person->name', + 'Tagged->tag', + 'Tagged->weight', + ]); + }); + + it('records the declared type of a named entry and none for an unnamed one', () => { + const declared = (name: string) => + result.graph.getNode(`Property:R/vectors.R:${name}`)?.properties.declaredType; + expect(declared('Person.name')).toBe('character'); + expect(declared('Person.age')).toBe('numeric'); + expect(declared('Tagged.tag')).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/r-definition-anchors.test.ts b/gitnexus/test/unit/r-definition-anchors.test.ts index 181245751..77843c80e 100644 --- a/gitnexus/test/unit/r-definition-anchors.test.ts +++ b/gitnexus/test/unit/r-definition-anchors.test.ts @@ -117,6 +117,57 @@ describe('R_QUERIES: setClass / setRefClass name only their first argument', () }); }); +describe('R_QUERIES: slots = c(...) and fields = c(...) define properties', () => { + it('reads a named vector: c(a = "numeric")', () => { + expect(definitions('setClass("A", slots = c(a = "numeric", b = "character"))')).toEqual([ + 'definition.class:A', + 'definition.property:a', + 'definition.property:b', + ]); + expect(definitions('setRefClass("R", fields = c(n = "numeric"))')).toEqual([ + 'definition.class:R', + 'definition.property:n', + ]); + }); + + it('reads an unnamed character vector as names: c("a", "b")', () => { + expect(definitions('setClass("A", slots = c("a", "b"))')).toEqual([ + 'definition.class:A', + 'definition.property:a', + 'definition.property:b', + ]); + expect(definitions('setRefClass("R", fields = c("n", "m"))')).toEqual([ + 'definition.class:R', + 'definition.property:n', + 'definition.property:m', + ]); + }); + + it('reads a vector that mixes named and unnamed entries', () => { + expect(definitions('setClass("A", slots = c("a", b = "numeric"))')).toEqual([ + 'definition.class:A', + 'definition.property:a', + 'definition.property:b', + ]); + }); + + it('does not read a string inside a vector under another argument name', () => { + expect(definitions('setClass("A", contains = c("B", "C"))')).toEqual(['definition.class:A']); + expect(definitions('setClass("A", prototype = c(x = "1"))')).toEqual(['definition.class:A']); + }); + + it('does not read unnamed strings of representation() or list() as slots', () => { + expect(definitions('setClass("A", representation("VIRTUAL"))')).toEqual(['definition.class:A']); + expect(definitions('setClass("A", slots = list("a"))')).toEqual(['definition.class:A']); + }); + + it('does not read other vector builders', () => { + expect(definitions('setClass("A", slots = character(a = "numeric"))')).toEqual([ + 'definition.class:A', + ]); + }); +}); + describe('R_QUERIES: setGeneric names only its first argument', () => { it('names the generic from the first positional string (positive control)', () => { expect(definitions('setGeneric("f", function(x) standardGeneric("f"))')).toEqual([ diff --git a/gitnexus/test/unit/r-field-extractor-class-owner.test.ts b/gitnexus/test/unit/r-field-extractor-class-owner.test.ts index f703ae2ed..1e0c3f094 100644 --- a/gitnexus/test/unit/r-field-extractor-class-owner.test.ts +++ b/gitnexus/test/unit/r-field-extractor-class-owner.test.ts @@ -120,3 +120,40 @@ describe('R field extractor: slots attach to the Class argument', () => { expect(propertyOwner(source)).toBeNull(); }); }); + +describe('RFieldExtractor: slots = c(...) / fields = c(...)', () => { + const details = (source: string) => + extractor + .extract(classCall(source), context) + ?.fields.map((f) => [f.name, f.type] as [string, string | null]); + + it('reads the names and types of a named vector', () => { + expect(details('setClass("A", slots = c(a = "numeric", b = "character"))')).toEqual([ + ['a', 'numeric'], + ['b', 'character'], + ]); + expect(details('setRefClass("R", fields = c(n = "numeric"))')).toEqual([['n', 'numeric']]); + }); + + it('reads the strings of an unnamed vector as untyped names', () => { + expect(details('setClass("A", slots = c("a", "b"))')).toEqual([ + ['a', null], + ['b', null], + ]); + expect(details('setRefClass("R", fields = c("n"))')).toEqual([['n', null]]); + }); + + it('keeps named and unnamed entries of one vector together', () => { + expect(details('setClass("A", slots = c("a", b = "numeric"))')).toEqual([ + ['a', null], + ['b', 'numeric'], + ]); + }); + + it('does not read an unnamed string of representation() or list()', () => { + expect( + details('setClass("A", representation = representation("VIRTUAL", x = "numeric"))'), + ).toEqual([['x', 'numeric']]); + expect(details('setClass("A", slots = list("a"))')).toBeUndefined(); + }); +});