fix(r): limit library() wildcard expansion to top-level definitions

expandRWildcardNames walked the flat localDefs of the target file and filtered
only dotted names, so a function nested inside another function
(`outer <- function() { helper <- function() 1 }`) was offered as a
package-level name to every file that calls library()/require() on the package.

Reproduction: two new cases in r-dotted-s3-hooks.test.ts failed on the
unfixed code (['outer', 'helper', 'top'] instead of ['outer', 'top'];
['a', 'b', 'c', 'd'] instead of ['a']). The expansion now keeps only names bound
at module scope (rFileTopLevel().names, which already feeds the dotted-tail
guard); top-level functions and bare-call S4/R6 classes are still listed
(controls added; test/integration/resolvers/r.test.ts unchanged and green).
This commit is contained in:
Mick Cooney 2026-10-15 20:42:56 +01:00
parent abcc165088
commit f80cfa09f2
2 changed files with 40 additions and 3 deletions

View file

@ -104,7 +104,8 @@ function emitRVirtualHeritageEdges(
}
/**
* Enumerate all top-level names exported from a target module scope's file.
* Enumerate all top-level names exported from a target module scope's file
* (module-scope function and class bindings; functions nested in another function are not).
* `library()`/`require()` are R's wildcard imports — every top-level name of
* the named local package becomes visible, UNFILTERED by NAMESPACE (a
* `library()`/`require()` call brings the whole package's namespace into
@ -127,12 +128,15 @@ export function expandRWildcardNames(
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
const { dottedTails } = rFileTopLevel(target);
const { names: topLevel, dottedTails } = rFileTopLevel(target);
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0 || qn.includes('.')) continue; // top-level only
if (qn === undefined || qn.length === 0 || qn.includes('.')) continue; // no dotted names
// `localDefs` is flat: it also lists functions nested in other functions, which a
// `library()` does not bring into scope. Only module-scope bindings are top-level.
if (!topLevel.has(qn)) continue;
if (dottedTails.has(qn)) continue; // shares its tail with an S3 method: see above
if (seen.has(qn)) continue;
seen.add(qn);

View file

@ -104,6 +104,39 @@ describe('expandRWildcardNames', () => {
).toEqual(['Scorer', 'tidy']);
});
it('lists only top-level definitions, not functions nested in another function', () => {
expect(
names('outer <- function() {\n helper <- function() 1\n helper()\n}\ntop <- function() 2'),
).toEqual(['outer', 'top']);
});
it('does not list a function nested several levels deep or inside a control block', () => {
expect(
names(
[
'a <- function() {',
' b <- function() {',
' c <- function() 1',
' }',
' if (TRUE) d <- function() 2',
'}',
].join('\n'),
),
).toEqual(['a']);
});
it('still lists top-level classes alongside top-level functions', () => {
expect(
names('Scorer <- R6::R6Class("Scorer", public = list(f = function() 1))\nrun = function() 1'),
).toEqual(['Scorer', 'run']);
});
it('still lists an S4 class defined by a bare setClass() call', () => {
expect(
names('setClass("Shape", representation(n = "numeric"))\nplain <- function() 1'),
).toEqual(['Shape', 'plain']);
});
it('never lists dotted names themselves', () => {
expect(names('print.foo <- function(x) x')).toEqual([]);
});