From f80cfa09f20dfc62533599636820d458da3caa93 Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Thu, 15 Oct 2026 20:42:56 +0100 Subject: [PATCH] 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). --- .../ingestion/languages/r/scope-resolver.ts | 10 ++++-- .../r/r-dotted-s3-hooks.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts index 98175fe1a..48c389e5d 100644 --- a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts @@ -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(); 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); diff --git a/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts b/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts index b860bd137..3df1da79d 100644 --- a/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts @@ -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([]); });