fix(r): bind a roxygen @param type only from an anchored form naming a local class

A @param description is free prose, but the first word of any capitalised
description was bound as the parameter's type (`@param df Data frame ...`
gave `df: Data`). A type is now bound only when the description is the type
alone or starts with it in braces, and the type names an R6, S4 or RefClass
class defined in the same file. Tags are read only at the start of a roxygen
line and a description runs to the next tag.

Remove the unwired extractReturnType, which took the first word of @return the
same way.
This commit is contained in:
Mick Cooney 2026-10-01 02:45:17 +01:00
parent 82084db7a4
commit 165cf12508
8 changed files with 339 additions and 98 deletions

View file

@ -708,7 +708,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions (S3 methods appear as dotted-name functions such as `print.foo`), S4 classes (`setClass`, `setGeneric`, `setMethod`, slots), R6 classes with their methods and fields, R5 (`setRefClass`) methods, and roxygen2 `@param` types. The scope-resolution pipeline resolves `library()` / `require()` / `source()` imports to local packages and files, binds NAMESPACE `importFrom()` names to definitions in local packages, resolves `obj$method()` calls (receiver type inferred from `Type$new()`), follows calls inside native pipe (`|>`) chains, and links R6 `inherit=` / S4 `contains=` heritage. Export status is read from each local package's NAMESPACE (a `DESCRIPTION` marks the package): `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()` (multi-line directives, R string escapes and POSIX character classes handled) decide which symbols keep `isExported`; the language-level export check treats every symbol as public, and the NAMESPACE pass then marks the rest of the `R/` symbols of a NAMESPACE-bearing package non-exported (symbols outside `R/`, such as `tests/`, `vignettes/` and `inst/`, are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them). Packages without a NAMESPACE stay fully public and a nested package without one does not inherit its enclosing package's; roxygen2 `@export` tags are not consulted. An index of an R repository built before these rules needs a re-analyse to pick them up: export status (outside `R/`, and inside `R/` for a nested package without a NAMESPACE), `library()` of a root-level package (now linked), and `foo::f()` where `foo` is only the directory name of a package with a different `Package:` name (the call loses its name-based edge when package discovery was not truncated, or binds to a definition that duplicate definitions had previously dropped it for). Known limits: a bare call to a function in another file of the same package binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`), not package-namespace lookup, and an ambiguous name yields no edge; dotted (S3-style) names are never import-resolved and bind by qualified-name lookup at 0.35; a qualified call `pkg::name()` is decided by its qualifier: when `pkg` is a package of the repository and defines `name` exactly once, the call binds to that definition at confidence 0.85 (`import-resolved`); when `pkg` is certainly not a repository package (package discovery completed and found no such package, including a repository with no `DESCRIPTION` at all), the call produces no edge; when `pkg` defines `name` more than once the call produces no edge; and when discovery may have missed a package (its depth and directory limits were reached and an unscanned subtree may hold an undiscovered package) the call keeps the name-based behaviour. Remaining gaps: a repository package that does not itself define `name` (for example a re-export) keeps the name-based behaviour; `pkg::name` used as a value rather than called (`lapply(x, pkg::name)`) is not resolved; a `::` to a name the package does not export still binds to its definition; and packages are discovered only to a bounded depth and directory count, so a package outside those limits is treated like an unknown one, while directories named exactly `renv`, `packrat`, `revdep` or `<name>.Rcheck` (installed libraries and check output) are never searched, so a package found only there is not a repository package; `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read; `exportPattern()` character classes use ASCII semantics; S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker, so basic-type bases add no edge; imports of external (CRAN) packages produce no edges; an edit to NAMESPACE alone is not re-resolved by an incremental re-index, so run a full analyze after changing it; no framework or entry-point detection.
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions (S3 methods appear as dotted-name functions such as `print.foo`), S4 classes (`setClass`, `setGeneric`, `setMethod`, slots), R6 classes with their methods and fields, R5 (`setRefClass`) methods, and roxygen2 `@param` types (when the description is a class defined in the same file, alone or in braces). The scope-resolution pipeline resolves `library()` / `require()` / `source()` imports to local packages and files, binds NAMESPACE `importFrom()` names to definitions in local packages, resolves `obj$method()` calls (receiver type inferred from `Type$new()`), follows calls inside native pipe (`|>`) chains, and links R6 `inherit=` / S4 `contains=` heritage. Export status is read from each local package's NAMESPACE (a `DESCRIPTION` marks the package): `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()` (multi-line directives, R string escapes and POSIX character classes handled) decide which symbols keep `isExported`; the language-level export check treats every symbol as public, and the NAMESPACE pass then marks the rest of the `R/` symbols of a NAMESPACE-bearing package non-exported (symbols outside `R/`, such as `tests/`, `vignettes/` and `inst/`, are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them). Packages without a NAMESPACE stay fully public and a nested package without one does not inherit its enclosing package's; roxygen2 `@export` tags are not consulted. An index of an R repository built before these rules needs a re-analyse to pick them up: export status (outside `R/`, and inside `R/` for a nested package without a NAMESPACE), `library()` of a root-level package (now linked), and `foo::f()` where `foo` is only the directory name of a package with a different `Package:` name (the call loses its name-based edge when package discovery was not truncated, or binds to a definition that duplicate definitions had previously dropped it for). Known limits: a bare call to a function in another file of the same package binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`), not package-namespace lookup, and an ambiguous name yields no edge; dotted (S3-style) names are never import-resolved and bind by qualified-name lookup at 0.35; a qualified call `pkg::name()` is decided by its qualifier: when `pkg` is a package of the repository and defines `name` exactly once, the call binds to that definition at confidence 0.85 (`import-resolved`); when `pkg` is certainly not a repository package (package discovery completed and found no such package, including a repository with no `DESCRIPTION` at all), the call produces no edge; when `pkg` defines `name` more than once the call produces no edge; and when discovery may have missed a package (its depth and directory limits were reached and an unscanned subtree may hold an undiscovered package) the call keeps the name-based behaviour. Remaining gaps: a repository package that does not itself define `name` (for example a re-export) keeps the name-based behaviour; `pkg::name` used as a value rather than called (`lapply(x, pkg::name)`) is not resolved; a `::` to a name the package does not export still binds to its definition; and packages are discovered only to a bounded depth and directory count, so a package outside those limits is treated like an unknown one, while directories named exactly `renv`, `packrat`, `revdep` or `<name>.Rcheck` (installed libraries and check output) are never searched, so a package found only there is not a repository package; `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read; `exportPattern()` character classes use ASCII semantics; S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker, so basic-type bases add no edge; imports of external (CRAN) packages produce no edges; an edit to NAMESPACE alone is not re-resolved by an incremental re-index, so run a full analyze after changing it; no framework or entry-point detection.
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.

View file

@ -484,7 +484,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions (S3 methods appear as dotted-name functions such as `print.foo`), S4 classes (`setClass`, `setGeneric`, `setMethod`, slots), R6 classes with their methods and fields, R5 (`setRefClass`) methods, and roxygen2 `@param` types. The scope-resolution pipeline resolves `library()` / `require()` / `source()` imports to local packages and files, binds NAMESPACE `importFrom()` names to definitions in local packages, resolves `obj$method()` calls (receiver type inferred from `Type$new()`), follows calls inside native pipe (`|>`) chains, and links R6 `inherit=` / S4 `contains=` heritage. Export status is read from each local package's NAMESPACE (a `DESCRIPTION` marks the package): `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()` (multi-line directives, R string escapes and POSIX character classes handled) decide which symbols keep `isExported`; the language-level export check treats every symbol as public, and the NAMESPACE pass then marks the rest of the `R/` symbols of a NAMESPACE-bearing package non-exported (symbols outside `R/`, such as `tests/`, `vignettes/` and `inst/`, are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them). Packages without a NAMESPACE stay fully public and a nested package without one does not inherit its enclosing package's; roxygen2 `@export` tags are not consulted. An index of an R repository built before these rules needs a re-analyse to pick them up: export status (outside `R/`, and inside `R/` for a nested package without a NAMESPACE), `library()` of a root-level package (now linked), and `foo::f()` where `foo` is only the directory name of a package with a different `Package:` name (the call loses its name-based edge when package discovery was not truncated, or binds to a definition that duplicate definitions had previously dropped it for). Known limits: a bare call to a function in another file of the same package binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`), not package-namespace lookup, and an ambiguous name yields no edge; dotted (S3-style) names are never import-resolved and bind by qualified-name lookup at 0.35; a qualified call `pkg::name()` is decided by its qualifier: when `pkg` is a package of the repository and defines `name` exactly once, the call binds to that definition at confidence 0.85 (`import-resolved`); when `pkg` is certainly not a repository package (package discovery completed and found no such package, including a repository with no `DESCRIPTION` at all), the call produces no edge; when `pkg` defines `name` more than once the call produces no edge; and when discovery may have missed a package (its depth and directory limits were reached and an unscanned subtree may hold an undiscovered package) the call keeps the name-based behaviour. Remaining gaps: a repository package that does not itself define `name` (for example a re-export) keeps the name-based behaviour; `pkg::name` used as a value rather than called (`lapply(x, pkg::name)`) is not resolved; a `::` to a name the package does not export still binds to its definition; and packages are discovered only to a bounded depth and directory count, so a package outside those limits is treated like an unknown one, while directories named exactly `renv`, `packrat`, `revdep` or `<name>.Rcheck` (installed libraries and check output) are never searched, so a package found only there is not a repository package; `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read; `exportPattern()` character classes use ASCII semantics; S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker, so basic-type bases add no edge; imports of external (CRAN) packages produce no edges; an edit to NAMESPACE alone is not re-resolved by an incremental re-index, so run a full analyze after changing it; no framework or entry-point detection.
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions (S3 methods appear as dotted-name functions such as `print.foo`), S4 classes (`setClass`, `setGeneric`, `setMethod`, slots), R6 classes with their methods and fields, R5 (`setRefClass`) methods, and roxygen2 `@param` types (when the description is a class defined in the same file, alone or in braces). The scope-resolution pipeline resolves `library()` / `require()` / `source()` imports to local packages and files, binds NAMESPACE `importFrom()` names to definitions in local packages, resolves `obj$method()` calls (receiver type inferred from `Type$new()`), follows calls inside native pipe (`|>`) chains, and links R6 `inherit=` / S4 `contains=` heritage. Export status is read from each local package's NAMESPACE (a `DESCRIPTION` marks the package): `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()` (multi-line directives, R string escapes and POSIX character classes handled) decide which symbols keep `isExported`; the language-level export check treats every symbol as public, and the NAMESPACE pass then marks the rest of the `R/` symbols of a NAMESPACE-bearing package non-exported (symbols outside `R/`, such as `tests/`, `vignettes/` and `inst/`, are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them). Packages without a NAMESPACE stay fully public and a nested package without one does not inherit its enclosing package's; roxygen2 `@export` tags are not consulted. An index of an R repository built before these rules needs a re-analyse to pick them up: export status (outside `R/`, and inside `R/` for a nested package without a NAMESPACE), `library()` of a root-level package (now linked), and `foo::f()` where `foo` is only the directory name of a package with a different `Package:` name (the call loses its name-based edge when package discovery was not truncated, or binds to a definition that duplicate definitions had previously dropped it for). Known limits: a bare call to a function in another file of the same package binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`), not package-namespace lookup, and an ambiguous name yields no edge; dotted (S3-style) names are never import-resolved and bind by qualified-name lookup at 0.35; a qualified call `pkg::name()` is decided by its qualifier: when `pkg` is a package of the repository and defines `name` exactly once, the call binds to that definition at confidence 0.85 (`import-resolved`); when `pkg` is certainly not a repository package (package discovery completed and found no such package, including a repository with no `DESCRIPTION` at all), the call produces no edge; when `pkg` defines `name` more than once the call produces no edge; and when discovery may have missed a package (its depth and directory limits were reached and an unscanned subtree may hold an undiscovered package) the call keeps the name-based behaviour. Remaining gaps: a repository package that does not itself define `name` (for example a re-export) keeps the name-based behaviour; `pkg::name` used as a value rather than called (`lapply(x, pkg::name)`) is not resolved; a `::` to a name the package does not export still binds to its definition; and packages are discovered only to a bounded depth and directory count, so a package outside those limits is treated like an unknown one, while directories named exactly `renv`, `packrat`, `revdep` or `<name>.Rcheck` (installed libraries and check output) are never searched, so a package found only there is not a repository package; `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read; `exportPattern()` character classes use ASCII semantics; S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker, so basic-type bases add no edge; imports of external (CRAN) packages produce no edges; an edit to NAMESPACE alone is not re-resolved by an incremental re-index, so run a full analyze after changing it; no framework or entry-point detection.
## Agent Skills

View file

@ -10,29 +10,35 @@ import type { SyntaxNode } from '../utils/ast-helpers.js';
/**
* R type extractor — roxygen2 annotation parsing.
*
* R has no static type system, but the roxygen2 documentation convention
* provides de facto type annotations via comments:
* R has no static type system and roxygen2 has no type syntax: a `@param`
* description is free prose. A parameter is therefore typed from a roxygen
* comment only when the type is stated in an anchored form AND names a class
* the file defines:
*
* #' @param name Character the user's name
* #' @param repo UserRepo the repository
* #' @return User
* create <- function(name, repo) {
* repo$save()
* }
* #' @param repo UserRepo (the description is the type alone)
* #' @param backup {UserRepo} a spare (the type in braces, prose after it)
* save <- function(repo, backup) { ... }
*
* This extractor parses `#' @param name Type` patterns from comment nodes
* preceding function definitions and binds parameter names to their types.
* Prose that merely starts with a capitalised word (`@param df Data frame of
* observations`) binds nothing, even when a class called `Data` exists, and a
* description may run over several lines: it is read up to the next tag.
*
* Resolution tiers:
* - Tier 0: roxygen2 @param annotations (extractDeclaration pre-populates env)
* - Tier 1: Constructor inference via `obj <- ClassName$new()` (R6) or `obj <- new("ClassName")` (S4)
*/
/** Regex to extract @param annotations: `#' @param name Type description` */
const ROXYGEN_PARAM_RE = /#'\s*@param\s+(\w+)\s+(\S+)/g;
/** A `@param` tag: the parameter name and the description text that follows it. */
interface RoxygenParam {
readonly name: string;
readonly description: string;
}
/** Regex to extract @return annotations: `#' @return Type` */
const ROXYGEN_RETURN_RE = /#'\s*@return\s+(\S+)/;
/** The whole description is a single type word: `UserRepo`. */
const BARE_TYPE_RE = /^([A-Z][\w.]*)$/;
/** The description starts with a braced type: `{UserRepo} the repository`. */
const BRACED_TYPE_RE = /^\{([A-Z][\w.]*)\}(?:\s|$)/;
/**
* Walk backwards through preceding sibling nodes collecting consecutive
@ -55,24 +61,95 @@ const collectRoxygenBlock = (node: SyntaxNode): string => {
};
/**
* Collect roxygen2 @param annotations from comment nodes preceding a function definition.
* Split a roxygen block into its `@param` tags. A tag's description is the
* rest of its line plus every following line up to the next `@tag`, joined by
* single spaces. A tag whose name is not one plain identifier (`x,y`, `...`,
* `na.rm`) is skipped, and its continuation lines belong to no tag.
*/
const parseRoxygenParams = (block: string): RoxygenParam[] => {
const params: RoxygenParam[] = [];
let name: string | undefined;
let parts: string[] = [];
const flush = (): void => {
if (name !== undefined) params.push({ name, description: parts.join(' ').trim() });
name = undefined;
parts = [];
};
for (const rawLine of block.split('\n')) {
const line = rawLine.replace(/^#'[ \t]*/, '').trim();
const tag = /^@(\w+)(?:[ \t]+(.*))?$/.exec(line);
if (tag) {
flush();
if (tag[1] !== 'param') continue;
const head = /^(\w+)(?:[ \t]+(.*))?$/.exec(tag[2] ?? '');
if (!head) continue;
name = head[1];
if (head[2]) parts.push(head[2]);
} else if (name !== undefined && line.length > 0) {
parts.push(line);
}
}
flush();
return params;
};
/** The type a `@param` description states in an anchored form, if any. */
const anchoredType = (description: string): string | undefined =>
(BARE_TYPE_RE.exec(description) ?? BRACED_TYPE_RE.exec(description))?.[1];
/**
* The class a node declares, in the shapes `query.ts` captures as
* `@declaration.class`: `X <- R6Class(...)` / `X <- R6::R6Class(...)`, and
* `setClass("X", ...)` / `setRefClass("X", ...)`.
*/
const declaredClassName = (node: SyntaxNode): string | undefined => {
if (node.type === 'binary_operator') {
const lhs = node.childForFieldName('lhs');
const rhs = node.childForFieldName('rhs');
if (lhs?.type !== 'identifier' || rhs?.type !== 'call') return undefined;
const fn = rhs.childForFieldName('function');
const calleeName =
fn?.type === 'namespace_operator' ? fn.childForFieldName('rhs')?.text : fn?.text;
return calleeName === 'R6Class' ? lhs.text : undefined;
}
if (node.type !== 'call') return undefined;
const fn = node.childForFieldName('function');
if (fn?.type !== 'identifier' || (fn.text !== 'setClass' && fn.text !== 'setRefClass')) {
return undefined;
}
const first = node
.childForFieldName('arguments')
?.namedChildren.find((c) => c.type === 'argument');
const value = first?.childForFieldName('value');
if (value?.type !== 'string') return undefined;
return value.namedChildren.find((c) => c.type === 'string_content')?.text;
};
/** Class names defined anywhere in the file, built on first use and kept per tree. */
const localClassNamesByTree = new WeakMap<object, ReadonlySet<string>>();
const localClassNames = (node: SyntaxNode): ReadonlySet<string> => {
const cached = localClassNamesByTree.get(node.tree);
if (cached) return cached;
const names = new Set<string>();
for (const candidate of node.tree.rootNode.descendantsOfType(['binary_operator', 'call'])) {
const name = declaredClassName(candidate);
if (name !== undefined) names.add(name);
}
localClassNamesByTree.set(node.tree, names);
return names;
};
/**
* Collect the roxygen2 `@param` types of a function definition.
* Returns a map of paramName → typeName.
*/
const collectRoxygenParams = (node: SyntaxNode): Map<string, string> => {
const params = new Map<string, string>();
const commentBlock = collectRoxygenBlock(node);
let match: RegExpExecArray | null;
ROXYGEN_PARAM_RE.lastIndex = 0;
while ((match = ROXYGEN_PARAM_RE.exec(commentBlock)) !== null) {
const paramName = match[1];
const typeName = match[2];
// Only accept types that start with uppercase (class/type names)
if (/^[A-Z]/.test(typeName)) {
params.set(paramName, typeName);
}
for (const { name, description } of parseRoxygenParams(collectRoxygenBlock(node))) {
const typeName = anchoredType(description);
if (typeName !== undefined && localClassNames(node).has(typeName)) params.set(name, typeName);
}
return params;
};
@ -185,20 +262,6 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return undefined;
};
/**
* Extract return type from roxygen2 `#' @return Type` annotation preceding
* a function definition. Walks backwards through preceding sibling comment nodes.
*/
export const extractReturnType = (node: SyntaxNode): string | undefined => {
const commentBlock = collectRoxygenBlock(node);
const match = ROXYGEN_RETURN_RE.exec(commentBlock);
if (match) {
const typeName = match[1];
if (/^[A-Z]/.test(typeName)) return typeName;
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
extractDeclaration,

View file

@ -0,0 +1,5 @@
Data <- R6::R6Class("Data",
public = list(
fit = function() 1
)
)

View file

@ -0,0 +1,12 @@
#' Fit a model.
#'
#' @param df Data frame of observations
fit_from_prose <- function(df) {
df$fit()
}
#' Fit a model from a freshly built object.
fit_from_constructor <- function() {
d <- Data$new()
d$fit()
}

View file

@ -0,0 +1,27 @@
/**
* R: a roxygen `@param` description is prose, not a type. The type
* environment is the only place a roxygen type is held, and no graph edge
* consumes it, so this pins the end-to-end outcome: a function documented
* `@param df Data frame ...` is not linked to the unrelated R6 class `Data`.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
describe('R roxygen @param prose does not type a receiver', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'r-roxygen-param-prose'), () => {});
}, 60000);
it('links a receiver typed by a constructor call to the class method (positive control)', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.some((e) => e.source === 'fit_from_constructor' && e.target === 'fit')).toBe(true);
});
it('does not link a receiver documented only by a prose description to the class method', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.filter((e) => e.source === 'fit_from_prose')).toEqual([]);
});
});

View file

@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildTypeEnv } from '../../src/core/ingestion/type-env.js';
import { extractReturnType as rExtractReturnType } from '../../src/core/ingestion/type-extractors/r.js';
import Parser from 'tree-sitter';
import R from '@eagleoutice/tree-sitter-r';
@ -29,8 +28,10 @@ describe('buildTypeEnv', () => {
describe('R roxygen2 annotations', () => {
it('extracts @param type bindings from roxygen2 comments', () => {
const tree = parse(`
#' @param data DataFrame the input data
#' @param outliers DataFrame outlier records
DataFrame <- R6::R6Class("DataFrame", public = list(rows = function() 1))
#' @param data DataFrame
#' @param outliers DataFrame
AddOutlierStatuses <- function(data, outliers) {
data
}
@ -42,15 +43,15 @@ AddOutlierStatuses <- function(data, outliers) {
it('skips lowercase type names (primitive types)', () => {
const tree = parse(`
#' @param x numeric input value
#' @param name Character the name
#' @param x numeric
#' @param name Character
process <- function(x, name) {
x
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'x')).toBeUndefined();
expect(flatGet(typeEnv, 'name')).toBe('Character');
expect(flatGet(typeEnv, 'name')).toBeUndefined();
});
it('extracts no types when no roxygen2 comments present', () => {
@ -63,12 +64,16 @@ process <- function(x, y) {
expect(flatSize(typeEnv)).toBe(0);
});
it('extracts types from = assignment function definitions', () => {
it.each([
['<-', 'helper <- function(repo) {\n repo\n}'],
['=', 'helper = function(repo) {\n repo\n}'],
['<<-', 'helper <<- function(repo) {\n repo\n}'],
])('extracts types from %s function definitions', (_op, definition) => {
const tree = parse(`
#' @param repo UserRepo the repository
helper = function(repo) {
repo
}
UserRepo <- R6::R6Class("UserRepo", public = list(save = function() 1))
#' @param repo UserRepo
${definition}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'repo')).toBe('UserRepo');
@ -84,45 +89,6 @@ rs <- ResultSet$new(items)
expect(binding!.calleeName).toBe('ResultSet');
});
it('extractReturnType extracts @return type from roxygen2 comment', () => {
const tree = parse(`
#' @param name Character the user name
#' @return User
create <- function(name) {
name
}
`);
let defNode: any = null;
for (let i = 0; i < tree.rootNode.childCount; i++) {
const child = tree.rootNode.child(i);
if (child?.type === 'binary_operator') {
defNode = child;
break;
}
}
expect(defNode).not.toBeNull();
const returnType = rExtractReturnType(defNode);
expect(returnType).toBe('User');
});
it('extractReturnType skips lowercase @return types', () => {
const tree = parse(`
#' @return numeric
compute <- function() { 42 }
`);
let defNode: any = null;
for (let i = 0; i < tree.rootNode.childCount; i++) {
const child = tree.rootNode.child(i);
if (child?.type === 'binary_operator') {
defNode = child;
break;
}
}
expect(defNode).not.toBeNull();
const returnType = rExtractReturnType(defNode);
expect(returnType).toBeUndefined();
});
it('returns constructor binding for S4 obj <- new("ClassName")', () => {
const tree = parse(`
model <- new("DataModel", name = "test")
@ -135,8 +101,11 @@ model <- new("DataModel", name = "test")
it('extracts @param types when @examples block is present', () => {
const tree = parse(`
#' @param data DataFrame the input data
#' @param config Config configuration object
DataFrame <- R6::R6Class("DataFrame", public = list(rows = function() 1))
Config <- R6::R6Class("Config", public = list(get = function() 1))
#' @param data DataFrame
#' @param config {Config} configuration object
#' @return Result
#' @examples
#' result <- process(my_data, my_config)
@ -152,12 +121,177 @@ process <- function(data, config) {
it('extracts @param types when regular comments appear before function', () => {
const tree = parse(`
#' @param x DataFrame the input
DataFrame <- R6::R6Class("DataFrame", public = list(rows = function() 1))
#' @param x DataFrame
# TODO: refactor this later
compute <- function(x) { x }
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'x')).toBe('DataFrame');
});
it('binds a parameter only when the tag names a class the file defines', () => {
const tree = parse(`
#' @param data DataFrame
#' @param name Character
process <- function(data, name) {
data
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'data')).toBeUndefined();
expect(flatGet(typeEnv, 'name')).toBeUndefined();
});
it('does not bind a lowercase word even when the file defines a class of that name', () => {
const tree = parse(`
setClass("repo", representation(name = "character"))
#' @param r repo
save <- function(r) { r }
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'r')).toBeUndefined();
});
describe('description prose is not a type', () => {
it('does not bind the first word of a description that names an R6 class', () => {
const tree = parse(`
Data <- R6::R6Class("Data", public = list(fit = function() 1))
#' @param df Data frame of observations
run <- function(df) {
df$fit()
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'df')).toBeUndefined();
});
it('does not bind the first word of a description that names an S4 class', () => {
const tree = parse(`
setClass("Config", representation(path = "character"))
#' @param cfg Config object to read from
load <- function(cfg) {
cfg
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'cfg')).toBeUndefined();
});
it('does not bind an article or determiner that happens to be a class name', () => {
const tree = parse(`
A <- R6::R6Class("A", public = list(go = function() 1))
#' @param x A numeric vector
f <- function(x) { x }
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'x')).toBeUndefined();
});
it('does not bind from the first line of a description that continues on the next line', () => {
const tree = parse(`
Data <- R6::R6Class("Data", public = list(fit = function() 1))
#' @param df Data
#' frame of observations
run <- function(df) {
df$fit()
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'df')).toBeUndefined();
});
it('binds a braced type whose prose continues on the next line', () => {
const tree = parse(`
Data <- R6::R6Class("Data", public = list(fit = function() 1))
#' @param df {Data} the observations,
#' one row per case
run <- function(df) {
df$fit()
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'df')).toBe('Data');
});
});
it('reads a tag only at the start of a roxygen line', () => {
const tree = parse(`
UserRepo <- R6::R6Class("UserRepo", public = list(save = function() 1))
#' Write the tag as #' @param repo UserRepo in your own comments.
save <- function(repo) {
repo
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'repo')).toBeUndefined();
});
describe('class definition forms', () => {
it.each([
['bare R6Class', 'UserRepo <- R6Class("UserRepo", public = list(save = function() 1))'],
[
'namespaced R6::R6Class',
'UserRepo <- R6::R6Class("UserRepo", public = list(save = function() 1))',
],
['setClass', 'setClass("UserRepo", representation(name = "character"))'],
['setRefClass', 'setRefClass("UserRepo", fields = list(name = "character"))'],
])('binds a type defined with %s', (_form, definition) => {
const tree = parse(`
#' @param repo UserRepo
save <- function(repo) {
repo
}
${definition}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'repo')).toBe('UserRepo');
});
it('binds the braced form, with or without prose after it', () => {
const tree = parse(`
UserRepo <- R6::R6Class("UserRepo", public = list(save = function() 1))
#' @param repo {UserRepo} the repository
#' @param backup {UserRepo}
save <- function(repo, backup) {
repo
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'repo')).toBe('UserRepo');
expect(flatGet(typeEnv, 'backup')).toBe('UserRepo');
});
});
describe('forms that are not bound (guards)', () => {
it.each([
['\\code{Data} markup', "#' @param df \\code{Data}", ['df']],
['\\linkS4class{Data} markup', "#' @param df \\linkS4class{Data}", ['df']],
['a comma-separated name list', "#' @param x,y Data", ['x', 'y']],
['the dots parameter', "#' @param ... Data", ['...']],
['a dotted parameter name', "#' @param na.rm Data", ['na.rm']],
])('does not bind %s', (_label, tag, names) => {
const tree = parse(`
Data <- R6::R6Class("Data", public = list(fit = function() 1))
${tag}
run <- function(x, y, df, ..., na.rm = FALSE) {
x
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
for (const name of names) expect(flatGet(typeEnv, name)).toBeUndefined();
});
});
});
});

View file

@ -388,7 +388,7 @@ So return-type-aware receiver inference already exists in a constrained downstre
| Assignment chains | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | Yes | Yes | No |
| Field/property type resolution | Yes | No† | Yes | Yes | Yes | Yes | Yes | Yes* | Yes | YARD | No | Yes | No‡ | No | Partial‖‖‖ |
| Comment-based types | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No | Roxygen2 |
| Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No | Roxygen2 |
| Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No | No |
| Call-result variable binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | Yes | Yes |
| Field access binding | Yes | No† | Yes | Yes | Yes | Yes | Yes | No‖ | Yes | N/A | Yes††† | Yes | No | Yes | No |
| Method-call-result binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | Yes | Yes |
@ -431,7 +431,7 @@ So return-type-aware receiver inference already exists in a constrained downstre
\*\*\* Whole-module-import languages (Go, Ruby, C/C++, Swift, R): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`. Not from import AST node extraction.
‖‖‖ R has no inline type annotations. Parameter types are extracted from roxygen2 `#' @param name Type` comments. Field/property types for S4 slots come from `representation(name = "character")` string literals; R6 fields infer types from default values (`NULL`, `TRUE`, `0L`, `"str"`).
‖‖‖ R has no inline type annotations. Parameter types are extracted from roxygen2 `@param` tags only when the description is the type alone (`#' @param name Type`) or starts with it in braces (`#' @param name {Type} prose`) and `Type` is an R6, S4 or RefClass class defined in the same file; prose that merely begins with a capitalised word binds nothing. Roxygen `@return` is not read. Field/property types for S4 slots come from `representation(name = "character")` string literals; R6 fields infer types from default values (`NULL`, `TRUE`, `0L`, `"str"`).
---