docs(r): say that NAMESPACE refines graph nodes, not scope definitions

refineRExportStatus flips isExported on graph nodes only. The scope
definitions that finalization reads carry no export verdict for R
(undefined means unknown), which is deliberate: library() attaches every
top-level name, as devtools::load_all() does. State that in the provider
guide and pin it with a test, so a later change that stamps a verdict
onto scope definitions is a conscious one.
This commit is contained in:
Mick Cooney 2026-10-17 14:55:58 +01:00
parent 248031b796
commit 06ae82c4e3
2 changed files with 41 additions and 1 deletions

View file

@ -58,7 +58,7 @@ The scope query (`languages/r/query.ts`) captures three call shapes only: `f(x)`
- Discovery may have missed a package (its limits were reached): the call keeps the name-based behaviour.
- `::` and `:::` are not distinguished, so `pkg::name` binds to a definition whether or not the package exports it.
- Package discovery is bounded to depth 3 and 200 directories (when the bound may have hidden a package this is flagged internally and nothing is logged), reads directories in code-unit order, ranks duplicate package names by shallowest then by whole path, and never searches directories named exactly `renv`, `packrat`, `revdep` or `<name>.Rcheck`. The shared file scanner has no such ignore entries, so `.R` files inside those trees are still parsed even though discovery skips them.
- Export status: the language-level export check treats every symbol as public. The NAMESPACE pass then marks the remaining `R/` symbols of a NAMESPACE-bearing package non-exported. Symbols outside `R/` (`tests/`, `vignettes/`, `inst/`) are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them. A package without a NAMESPACE stays fully public, and a nested package without one does not inherit its enclosing package's. roxygen2 `@export` tags are not consulted.
- Export status: the language-level export check treats every symbol as public. The NAMESPACE pass then marks the remaining `R/` symbols of a NAMESPACE-bearing package non-exported. Symbols outside `R/` (`tests/`, `vignettes/`, `inst/`) are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them. A package without a NAMESPACE stays fully public, and a nested package without one does not inherit its enclosing package's. roxygen2 `@export` tags are not consulted. The refinement changes the graph nodes' `isExported` only; the definitions scope resolution works from carry no export verdict, so `library()`, `pkg::name` and the global-name fallback do not consult NAMESPACE (only `importFrom()` does).
- `exportPattern()` text comes from the analysed repository, so patterns are compiled to a linear-time automaton (`linear-regex.ts`) with a work budget. Constructs with no linear-time algorithm (back-references, look-around) and oversized patterns are dropped with one logged warning per pattern per package. Character classes use ASCII semantics. A directive argument the NAMESPACE reader cannot read at all is dropped without a warning: an R 4.0 raw string (`exportPattern(r"(^[^.])")`, `export(r"(foo)")`) and any string that spans lines. It exports nothing, so the names it was meant to cover read as non-exported.
## Limits and known gaps

View file

@ -0,0 +1,40 @@
/**
* R gives scope resolution no export verdict. `SymbolDefinition.isExported` is tri-state and
* `undefined` means "unknown" (finalization then keeps its prior behaviour); R's export checker
* says "public" for every graph node and the NAMESPACE refinement later flips graph nodes to
* private, but neither writes a verdict onto a scope definition. That is deliberate: a
* `library()` of a local package makes every top-level name visible, as `devtools::load_all()`
* does (docs/languages/r-provider.md, Resolution model), so resolution must not treat a name
* NAMESPACE omits as module-private. A verdict of `false` would make finalization drop it from
* wildcard providers.
*/
import { describe, expect, it } from 'vitest';
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
import { rProvider } from '../../../../src/core/ingestion/languages/r.js';
describe('R scope definitions carry no export verdict', () => {
const parsed = extractParsedFile(
rProvider,
[
'exported_fn <- function() 1',
'Widget <- R6Class("Widget", public = list(size = 1, grow = function() 1))',
'setClass("Point", representation(x = "numeric"))',
'setGeneric("area", function(shape) standardGeneric("area"))',
].join('\n'),
'R/a.R',
);
it('extracts functions, classes and members (positive control)', () => {
const types = new Set(parsed?.localDefs.map((d) => d.type));
expect(types.has('Function')).toBe(true);
expect(types.has('Class')).toBe(true);
expect(types.has('Method')).toBe(true);
});
it('leaves isExported unset on every definition', () => {
expect(parsed?.localDefs.length).toBeGreaterThan(0);
for (const def of parsed?.localDefs ?? []) {
expect(def.isExported, `${def.type} ${def.qualifiedName}`).toBeUndefined();
}
});
});