diff --git a/README.md b/README.md index a67eb4c99..1fe261ba9 100644 --- a/README.md +++ b/README.md @@ -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 `.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 `.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. diff --git a/gitnexus/README.md b/gitnexus/README.md index 955d04f12..6947a38e5 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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 `.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 `.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 diff --git a/gitnexus/src/core/ingestion/type-extractors/r.ts b/gitnexus/src/core/ingestion/type-extractors/r.ts index fd97879df..66037c69d 100644 --- a/gitnexus/src/core/ingestion/type-extractors/r.ts +++ b/gitnexus/src/core/ingestion/type-extractors/r.ts @@ -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>(); +const localClassNames = (node: SyntaxNode): ReadonlySet => { + const cached = localClassNamesByTree.get(node.tree); + if (cached) return cached; + const names = new Set(); + 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 => { const params = new Map(); - 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, diff --git a/gitnexus/test/fixtures/lang-resolution/r-roxygen-param-prose/R/model.R b/gitnexus/test/fixtures/lang-resolution/r-roxygen-param-prose/R/model.R new file mode 100644 index 000000000..4f720392a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-roxygen-param-prose/R/model.R @@ -0,0 +1,5 @@ +Data <- R6::R6Class("Data", + public = list( + fit = function() 1 + ) +) diff --git a/gitnexus/test/fixtures/lang-resolution/r-roxygen-param-prose/R/run.R b/gitnexus/test/fixtures/lang-resolution/r-roxygen-param-prose/R/run.R new file mode 100644 index 000000000..1be963c07 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-roxygen-param-prose/R/run.R @@ -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() +} diff --git a/gitnexus/test/integration/resolvers/r-roxygen-param.test.ts b/gitnexus/test/integration/resolvers/r-roxygen-param.test.ts new file mode 100644 index 000000000..a3a843025 --- /dev/null +++ b/gitnexus/test/integration/resolvers/r-roxygen-param.test.ts @@ -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([]); + }); +}); diff --git a/gitnexus/test/unit/type-env-r.test.ts b/gitnexus/test/unit/type-env-r.test.ts index 199c70b9b..a6ec1f9fc 100644 --- a/gitnexus/test/unit/type-env-r.test.ts +++ b/gitnexus/test/unit/type-env-r.test.ts @@ -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(); + }); + }); }); }); diff --git a/type-resolution-system.md b/type-resolution-system.md index cec934e34..6621bb53a 100644 --- a/type-resolution-system.md +++ b/type-resolution-system.md @@ -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"`). ---