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.
This commit is contained in:
Mick Cooney 2026-09-30 01:51:14 +01:00
parent 1e7985db60
commit c2fcdd1bc5
5 changed files with 135 additions and 14 deletions

View file

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

View file

@ -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<string> = new Set([
// Base R
@ -185,4 +186,5 @@ export const rProvider = defineLanguage({
interpretImport: interpretRImport,
interpretTypeBinding: interpretRTypeBinding,
postParse: rPostParse,
resolveMemberOwnerNode: rResolveMemberOwnerNode,
});

View file

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

View file

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

View file

@ -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();
}
});
});