From c2fcdd1bc52e4e3a73f7fa3c976d60a7c6124b3b Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Wed, 30 Sep 2026 01:51:14 +0100 Subject: [PATCH] refactor(ingestion): add LanguageProvider.resolveMemberOwnerNode and move R's owner-node lookup behind it Add an optional resolveMemberOwnerNode hook consulted by the worker's enclosing-owner helper after the container walk and resolveFileTypeOwner. R implements it via rResolveMemberOwnerNode, replacing the two language === R branches in parse-worker. --- .../src/core/ingestion/language-provider.ts | 10 ++ gitnexus/src/core/ingestion/languages/r.ts | 2 + .../core/ingestion/languages/r/owner-hooks.ts | 15 +++ .../core/ingestion/workers/parse-worker.ts | 26 +++-- gitnexus/test/unit/r-owner-hooks.test.ts | 96 +++++++++++++++++++ 5 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/r/owner-hooks.ts create mode 100644 gitnexus/test/unit/r-owner-hooks.test.ts diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 9e901bb9a..152957ff0 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -385,6 +385,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, diff --git a/gitnexus/src/core/ingestion/languages/r.ts b/gitnexus/src/core/ingestion/languages/r.ts index 4f5c954a0..4741642f2 100644 --- a/gitnexus/src/core/ingestion/languages/r.ts +++ b/gitnexus/src/core/ingestion/languages/r.ts @@ -17,6 +17,7 @@ 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 { rResolveMemberOwnerNode } from './r/owner-hooks.js'; const R_BUILT_INS: ReadonlySet = new Set([ // Base R @@ -185,4 +186,5 @@ export const rProvider = defineLanguage({ interpretImport: interpretRImport, interpretTypeBinding: interpretRTypeBinding, postParse: rPostParse, + resolveMemberOwnerNode: rResolveMemberOwnerNode, }); diff --git a/gitnexus/src/core/ingestion/languages/r/owner-hooks.ts b/gitnexus/src/core/ingestion/languages/r/owner-hooks.ts new file mode 100644 index 000000000..a8184b167 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/owner-hooks.ts @@ -0,0 +1,15 @@ +/** + * 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. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { findRFieldOwnerNode } from '../../field-extractors/r.js'; + +/** `LanguageProvider.resolveMemberOwnerNode` for R. */ +export const rResolveMemberOwnerNode = (definitionNode: SyntaxNode): SyntaxNode | null => + findRFieldOwnerNode(definitionNode); diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 01dd62b3c..0d84d87da 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -179,7 +179,7 @@ import { DEFAULT_PDG_MAX_FUNCTION_LINES, type CfgSkipCounts, } from '../cfg/collect.js'; -import { findRFieldOwnerNode, getRTopLevelPropertyOwnerName } from '../field-extractors/r.js'; +import { getRTopLevelPropertyOwnerName } from '../field-extractors/r.js'; import { getRTopLevelMethodOwnerName } from '../method-extractors/r.js'; import { logger } from '../../logger.js'; @@ -685,7 +685,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, @@ -694,10 +696,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; } /** @@ -2816,13 +2820,9 @@ const processFileGroup = ( // returnType, isAbstract/isFinal/annotations, visibility, and more. let enrichedByMethodExtractor = false; if (provider.methodExtractor && definitionNode) { - // R6/R5 classes are defined via function calls (R6::R6Class(), setRefClass()), - // not tree-sitter class syntax, so findEnclosingClassNode can't find them. - // findRFieldOwnerNode walks up looking for those function-call patterns. const classNode = findEnclosingClassNodeOrFileOwner(definitionNode, provider, file.path) ?? - findClassNodeByQualifiedName(definitionNode) ?? - (language === SupportedLanguages.R ? findRFieldOwnerNode(definitionNode) : null); + findClassNodeByQualifiedName(definitionNode); if (classNode) { const methodMap = getMethodInfo(classNode, provider, { filePath: file.path, @@ -3021,9 +3021,7 @@ const processFileGroup = ( if (nodeLabel === 'Property' && definitionNode) { // FieldExtractor is the single source of truth when available if (provider.fieldExtractor && typeEnv) { - const classNode = - findEnclosingClassNodeOrFileOwner(definitionNode, provider, file.path) ?? - (language === SupportedLanguages.R ? findRFieldOwnerNode(definitionNode) : null); + const classNode = findEnclosingClassNodeOrFileOwner(definitionNode, provider, file.path); if (classNode) { const fieldMap = getFieldInfo(classNode, provider, { typeEnv, diff --git a/gitnexus/test/unit/r-owner-hooks.test.ts b/gitnexus/test/unit/r-owner-hooks.test.ts new file mode 100644 index 000000000..110d7b08b --- /dev/null +++ b/gitnexus/test/unit/r-owner-hooks.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { createParserForLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { rResolveMemberOwnerNode } from '../../src/core/ingestion/languages/r/owner-hooks.js'; +import { rProvider } from '../../src/core/ingestion/languages/r.js'; +import { getProvider } from '../../src/core/ingestion/languages/index.js'; + +// Fork #19, step 5: R's member-owner node is found through +// `LanguageProvider.resolveMemberOwnerNode`, not a `language === R` branch in the worker. +// R classes are calls/assignments, not syntactic containers, so the hook returns the R6 +// `binary_operator` or the `setRefClass(...)` call that owns a member. + +const SOURCE = `Foo <- R6::R6Class("Foo", public = list(n = 1, bar = function() 1)) +Acc <- setRefClass("Acc", fields = list(a = "numeric"), methods = list(m = function() 1)) +setMethod("area", "Circle", function(shape) 1) +plain <- function(x) x +`; + +let root: SyntaxNode; + +const walk = (n: SyntaxNode, visit: (n: SyntaxNode) => void): void => { + visit(n); + for (let i = 0; i < n.namedChildCount; i++) { + const child = n.namedChild(i); + if (child) walk(child, visit); + } +}; + +const find = (pred: (n: SyntaxNode) => boolean): SyntaxNode => { + let hit: SyntaxNode | undefined; + walk(root, (n) => { + if (hit === undefined && pred(n)) hit = n; + }); + if (hit === undefined) throw new Error('fixture node not found'); + return hit; +}; + +const fnDefinitionOn = (row: number): SyntaxNode => + find((n) => n.type === 'function_definition' && n.startPosition.row === row); + +beforeAll(async () => { + const parser = await createParserForLanguage(SupportedLanguages.R); + root = parser.parse(SOURCE).rootNode as unknown as SyntaxNode; +}); + +describe('rResolveMemberOwnerNode', () => { + it('resolves an R6 method to the enclosing `Foo <- R6Class(...)` binary_operator', () => { + const owner = rResolveMemberOwnerNode(fnDefinitionOn(0)); + expect(owner?.type).toBe('binary_operator'); + expect(owner?.childForFieldName('lhs')?.text).toBe('Foo'); + }); + + it('resolves an R6 field to the same binary_operator', () => { + const field = find((n) => n.type === 'argument' && n.childForFieldName('name')?.text === 'n'); + const owner = rResolveMemberOwnerNode(field); + expect(owner?.type).toBe('binary_operator'); + expect(owner?.childForFieldName('lhs')?.text).toBe('Foo'); + }); + + it('resolves a RefClass method to the `setRefClass(...)` call', () => { + const owner = rResolveMemberOwnerNode(fnDefinitionOn(1)); + expect(owner?.type).toBe('call'); + expect(owner?.childForFieldName('function')?.text).toBe('setRefClass'); + }); + + it('returns null for a top-level function', () => { + expect(rResolveMemberOwnerNode(fnDefinitionOn(3))).toBeNull(); + }); + + it('returns null for a setMethod(...) call (its owner arrives via ownerNameHint, not a node)', () => { + const call = find( + (n) => n.type === 'call' && n.childForFieldName('function')?.text === 'setMethod', + ); + expect(rResolveMemberOwnerNode(call)).toBeNull(); + expect(rResolveMemberOwnerNode(fnDefinitionOn(2))).toBeNull(); + }); +}); + +describe('resolveMemberOwnerNode provider wiring', () => { + it('is registered on the R provider', () => { + expect(rProvider.resolveMemberOwnerNode).toBe(rResolveMemberOwnerNode); + expect(getProvider(SupportedLanguages.R).resolveMemberOwnerNode).toBe(rResolveMemberOwnerNode); + }); + + it('is left undefined on other providers', () => { + for (const lang of [ + SupportedLanguages.Python, + SupportedLanguages.TypeScript, + SupportedLanguages.Java, + SupportedLanguages.Zig, + ]) { + expect(getProvider(lang).resolveMemberOwnerNode).toBeUndefined(); + } + }); +});