From b7728e72b5990a0785adbf54381b6039fd06ff9d Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Tue, 29 Sep 2026 22:00:45 +0100 Subject: [PATCH] fix(r): keep S3 method names from colliding with bare names in simple-name indexes The shared simple-name indexes key every def by the text after the last '.' of its qualifiedName, so an S3 method `print.foo` is indexed under `foo` next to a bare `foo`. Two measured defects followed: - `library(prov)` bound `foo` to `print.foo` (a wrong 0.85 import-resolved edge) when the S3 method was declared before the bare def; - a same-package cross-file bare call `foo(v)` saw two candidates under `foo` and got no CALLS edge at all. R-only fix, no shared-core edit, no SCHEMA_BUMP (both hooks run at resolve time over cached ParsedFiles): - rScopeResolver.isCallableVisibleFromCaller refuses dotted candidates: a bare R call can never invoke `print.foo`. - expandRWildcardNames drops a bare name whose file also defines a dotted top-level def with that tail (order-blind, like the importFrom guard). Characterisation tests deliberately updated to the new behaviour (r.test.ts, R NAMESPACE importFrom fixture): - print.tidy before tidy / tally before print.tally: was no edge, now a 0.5 global-name-fallback edge to the bare def; - bare call to a name that only an R6 method carries: was a 0.5 guess to the method, now no edge (a bare call cannot reach an R6 member). Residual: dotted names bind only through qualified-name lookup (0.35), never import-resolved; a bare name sharing a tail with an S3 method in the same file resolves at 0.5 (fallback), not 0.85; backticked names unchanged. --- .../ingestion/languages/r/scope-resolver.ts | 43 ++++++- .../r-dotted-s3-collision/cons/DESCRIPTION | 3 + .../r-dotted-s3-collision/cons/NAMESPACE | 1 + .../r-dotted-s3-collision/cons/R/cons.R | 7 ++ .../r-dotted-s3-collision/prov/DESCRIPTION | 3 + .../r-dotted-s3-collision/prov/NAMESPACE | 7 ++ .../r-dotted-s3-collision/prov/R/other.R | 6 + .../r-dotted-s3-collision/prov/R/prov.R | 12 ++ .../r-dotted-s3-collision/scripts/run.R | 4 + .../resolvers/r-dotted-s3-collision.test.ts | 111 +++++++++++++++++ gitnexus/test/integration/resolvers/r.test.ts | 20 ++- .../r/r-dotted-s3-hooks.test.ts | 115 ++++++++++++++++++ 12 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/R/cons.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/other.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/prov.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/scripts/run.R create mode 100644 gitnexus/test/integration/resolvers/r-dotted-s3-collision.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts diff --git a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts index f81f046b3..77bf3813d 100644 --- a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts @@ -20,7 +20,7 @@ * heritage target this plan's fixtures name but never declare. */ -import type { ParsedFile, ScopeId } from 'gitnexus-shared'; +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { @@ -35,7 +35,11 @@ import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { loadRPackageConfig } from '../../language-config.js'; import { resolveRImportTarget } from '../../import-resolvers/r.js'; -import { isRGlobalNameFallbackPlausible, populateRNamespaceImports } from './namespace-imports.js'; +import { + isRGlobalNameFallbackPlausible, + populateRNamespaceImports, + rFileTopLevel, +} from './namespace-imports.js'; import { rProvider } from '../r.js'; import { rArityCompatibility, rMergeBindings } from './simple-hooks.js'; @@ -106,18 +110,29 @@ function emitRVirtualHeritageEdges( * scope regardless of what NAMESPACE declares as exported — verified during * the plan's stress-test round: `pkgA/NAMESPACE` does not export `ResultSet`, * yet `require("pkgA")` must still resolve it). + * + * A bare name is dropped when the same file also defines a dotted top-level + * name with that tail (`foo` next to `print.foo`). The shared finalize indexes + * every def by the text after its last `.`, so `foo` would be bound to + * whichever of the two comes first in the file, and `print.foo` (an S3 method, + * never callable as `foo()`) can win. The dropped name resolves through the + * free-call fallback instead, where {@link rIsCallableVisibleFromCaller} + * removes the dotted def from the candidates. Order-blind, like the matching + * guard in `import-resolvers/r.ts`. */ -function expandRWildcardNames( +export function expandRWildcardNames( targetModuleScope: ScopeId, parsedFiles: readonly ParsedFile[], ): readonly string[] { const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); if (target === undefined) return []; + const { 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 (dottedTails.has(qn)) continue; // shares its tail with an S3 method: see above if (seen.has(qn)) continue; seen.add(qn); names.push(qn); @@ -125,6 +140,22 @@ function expandRWildcardNames( return names; } +/** + * A bare R call `foo(x)` can never invoke a def whose name contains a `.` + * (`print.foo` is only reachable as `print.foo(...)` or through S3 dispatch), + * yet the free-call fallback's simple-name index keys it under `foo`. Refuse + * such candidates so `foo(x)` cannot collide with `print.foo`. + * + * Applies to every candidate the hook sees, including R6/R5 members whose + * qualified name is `Class.member`: a bare `member(x)` never reaches those + * either. + */ +export function rIsCallableVisibleFromCaller(ctx: { + readonly candidate: SymbolDefinition; +}): boolean { + return !(ctx.candidate.qualifiedName ?? '').includes('.'); +} + export const rScopeResolver: ScopeResolver = { language: SupportedLanguages.R, languageProvider: rProvider, @@ -184,4 +215,10 @@ export const rScopeResolver: ScopeResolver = { // in R (see `isRGlobalNameFallbackPlausible` for the exact rule and the // documented `pkg::name()` / `import()` limitations). isGlobalNameFallbackPlausible: isRGlobalNameFallbackPlausible, + + // The free-call fallback keys defs by the text after the last `.` of their + // qualified name, so an S3 method `print.foo` sits next to a bare `foo` and + // makes the bare call ambiguous (no edge). A bare call can never invoke a + // dotted def, so drop those candidates. + isCallableVisibleFromCaller: rIsCallableVisibleFromCaller, }; diff --git a/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/DESCRIPTION new file mode 100644 index 000000000..3da631519 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/DESCRIPTION @@ -0,0 +1,3 @@ +Package: cons +Title: Consumer +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/NAMESPACE new file mode 100644 index 000000000..2b5a41351 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/NAMESPACE @@ -0,0 +1 @@ +export(use_foo) diff --git a/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/R/cons.R b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/R/cons.R new file mode 100644 index 000000000..8b4e1d325 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/R/cons.R @@ -0,0 +1,7 @@ +library(prov) +use_foo <- function(v) foo(v) +use_bar <- function(v) bar(v) +use_baz <- function(v) baz(v) +use_dotted <- function(x) print.foo(x) +use_qux <- function(v) qux(v) +use_odd <- function(v) `odd name`(v) diff --git a/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/DESCRIPTION new file mode 100644 index 000000000..58fd50ad1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/DESCRIPTION @@ -0,0 +1,3 @@ +Package: prov +Title: Provider +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/NAMESPACE new file mode 100644 index 000000000..65e09c213 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/NAMESPACE @@ -0,0 +1,7 @@ +export(foo) +export(bar) +export(baz) +S3method(print, foo) +S3method(print, bar) +S3method(summary, baz) +export(qux) diff --git a/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/other.R b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/other.R new file mode 100644 index 000000000..8eb28c97b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/other.R @@ -0,0 +1,6 @@ +same_pkg_foo <- function(v) foo(v) +same_pkg_bar <- function(v) bar(v) +same_pkg_baz <- function(v) baz(v) +dotted_call <- function(x) print.foo(x) +same_pkg_qux <- function(v) qux(v) +same_pkg_odd <- function(v) `odd name`(v) diff --git a/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/prov.R b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/prov.R new file mode 100644 index 000000000..00705b696 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/prov.R @@ -0,0 +1,12 @@ +print.foo <- function(x, ...) x +foo <- function(v) structure(list(v = v), class = "foo") + +bar <- function(v) structure(list(v = v), class = "bar") +print.bar <- function(x, ...) x + +baz <- function(v) v +summary.baz <- function(object, ...) object + +qux <- function(v) v + +`odd name` <- function(v) v diff --git a/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/scripts/run.R b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/scripts/run.R new file mode 100644 index 000000000..4fcac72fc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/scripts/run.R @@ -0,0 +1,4 @@ +library(prov) +script_foo <- function(v) foo(v) +script_bar <- function(v) bar(v) +script_qux <- function(v) qux(v) diff --git a/gitnexus/test/integration/resolvers/r-dotted-s3-collision.test.ts b/gitnexus/test/integration/resolvers/r-dotted-s3-collision.test.ts new file mode 100644 index 000000000..4fcead581 --- /dev/null +++ b/gitnexus/test/integration/resolvers/r-dotted-s3-collision.test.ts @@ -0,0 +1,111 @@ +/** + * R: dotted (S3 method) names must not collide with same-tailed bare names + * in the shared simple-name indexes (kaybenleroll/GitNexus#11). + * + * The shared indexes key every def by the text after the last `.` of its + * qualifiedName, so `print.foo` is indexed under `foo` next to a bare `foo`. + * Two defects followed: a `library(prov)` wildcard bound `foo` to `print.foo` + * when the S3 method was declared first (a wrong 0.85 edge), and a bare + * cross-file call `foo(v)` saw two candidates and got no edge at all. + * + * Fixture layout: `prov/R/prov.R` declares `print.foo` BEFORE `foo`, `bar` + * BEFORE `print.bar`, `summary.baz` after `baz`, a control `qux` with no S3 + * method and a backticked `odd name`; `cons/` and `scripts/run.R` reach them + * through `library(prov)`; `prov/R/other.R` calls them from the same package. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('R dotted (S3) names vs same-tailed bare names', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'r-dotted-s3-collision'), () => {}); + }, 60000); + + /** `target:targetFile:reason:confidence` of every CALLS edge from `source` in `fileSuffix`. */ + const edges = (source: string, fileSuffix: string): string[] => + getRelationships(result, 'CALLS') + .filter((e) => e.source === source && e.sourceFilePath.endsWith(fileSuffix)) + .map((e) => `${e.target}:${e.targetFilePath}:${e.rel.reason}:${e.rel.confidence}`) + .sort(); + + const PROV = 'prov/R/prov.R'; + + describe('library(prov) wildcard import', () => { + it('binds foo to the bare def, never to print.foo declared before it', () => { + const e = edges('use_foo', 'cons/R/cons.R'); + expect(e.filter((x) => x.startsWith('print.foo:'))).toEqual([]); + expect(e).toEqual([`foo:${PROV}:global-name-fallback:0.5`]); + }); + + it('also keeps a script caller (no package NAMESPACE) off print.foo', () => { + expect(edges('script_foo', 'scripts/run.R')).toEqual([ + `foo:${PROV}:global-name-fallback:0.5`, + ]); + }); + + it('reversed declaration order (bare bar before print.bar) resolves to the bare def', () => { + expect(edges('use_bar', 'cons/R/cons.R')).toEqual([`bar:${PROV}:global-name-fallback:0.5`]); + expect(edges('script_bar', 'scripts/run.R')).toEqual([ + `bar:${PROV}:global-name-fallback:0.5`, + ]); + }); + + it('bare name with an S3 method declared after it (baz / summary.baz) resolves to the bare def', () => { + expect(edges('use_baz', 'cons/R/cons.R')).toEqual([`baz:${PROV}:global-name-fallback:0.5`]); + }); + + it('control: a name with no same-tailed S3 method is still import-resolved at 0.85', () => { + expect(edges('use_qux', 'cons/R/cons.R')).toEqual([`qux:${PROV}:import-resolved:0.85`]); + expect(edges('script_qux', 'scripts/run.R')).toEqual([`qux:${PROV}:import-resolved:0.85`]); + }); + }); + + describe('same-package cross-file bare calls', () => { + const OTHER = 'prov/R/other.R'; + + it('foo(v) gets a fallback edge to the bare def (was: no edge, two tail candidates)', () => { + expect(edges('same_pkg_foo', OTHER)).toEqual([`foo:${PROV}:global-name-fallback:0.5`]); + }); + + it('bar(v) and baz(v) resolve to the bare defs', () => { + expect(edges('same_pkg_bar', OTHER)).toEqual([`bar:${PROV}:global-name-fallback:0.5`]); + expect(edges('same_pkg_baz', OTHER)).toEqual([`baz:${PROV}:global-name-fallback:0.5`]); + }); + + it('control: qux(v) keeps its fallback edge', () => { + expect(edges('same_pkg_qux', OTHER)).toEqual([`qux:${PROV}:global-name-fallback:0.5`]); + }); + }); + + describe('dotted call sites', () => { + it('print.foo(x) in the same package resolves to the S3 def via qualified lookup (0.35)', () => { + expect(edges('dotted_call', 'prov/R/other.R')).toEqual([ + `print.foo:${PROV}:scope-resolution: call:0.35`, + ]); + }); + + it('print.foo(x) through library(prov) resolves to the S3 def (0.35)', () => { + expect(edges('use_dotted', 'cons/R/cons.R')).toEqual([ + `print.foo:${PROV}:scope-resolution: call:0.35`, + ]); + }); + }); + + describe('backticked names (characterisation, unchanged by this fix)', () => { + it('a backticked def keeps its backticks in the name and still resolves like any bare name', () => { + // The qualifiedName keeps the backticks, so the wildcard binds the literal name and a + // same-package call falls back to a unique-name guess. There is no dotted tail involved; + // pinned so a future change to backtick handling is a deliberate decision. + expect({ + cons: edges('use_odd', 'cons/R/cons.R'), + samePkg: edges('same_pkg_odd', 'prov/R/other.R'), + }).toEqual({ + cons: [`\`odd name\`:${PROV}:import-resolved:0.85`], + samePkg: [`\`odd name\`:${PROV}:global-name-fallback:0.5`], + }); + }); + }); +}); diff --git a/gitnexus/test/integration/resolvers/r.test.ts b/gitnexus/test/integration/resolvers/r.test.ts index b2b07da1f..78e80b1af 100644 --- a/gitnexus/test/integration/resolvers/r.test.ts +++ b/gitnexus/test/integration/resolvers/r.test.ts @@ -1067,12 +1067,19 @@ describe('R NAMESPACE importFrom() bindings to local packages', () => { it('never binds tidy to print.tidy when print.tidy is defined first', () => { // finalize would index `print.tidy` under `tidy`; the guard refuses the import instead, so - // there is no 0.85 edge to the wrong def (baseline: ambiguous, no edge). - expect(edgeSummaries('print_before_bare_user', 'analytics/R/dotted_use.R')).toEqual([]); + // there is no 0.85 edge to the wrong def. Since #11 the fallback no longer sees `print.tidy` + // as a tail-`tidy` candidate (a bare call cannot invoke it), so the call resolves to the + // bare def at 0.5 (was: ambiguous, no edge). + expect(edgeSummaries('print_before_bare_user', 'analytics/R/dotted_use.R')).toEqual([ + 'tidy:scorelib/R/dotted.R:global-name-fallback:0.5', + ]); }); it('also refuses the mirror layout (bare def first): the guard is order-blind', () => { - expect(edgeSummaries('bare_before_print_user', 'analytics/R/dotted_use.R')).toEqual([]); + // Same 0.5 fallback edge to the bare def since #11 (was: no edge). + expect(edgeSummaries('bare_before_print_user', 'analytics/R/dotted_use.R')).toEqual([ + 'tally:scorelib/R/dotted.R:global-name-fallback:0.5', + ]); }); it('does not fire for an underscore name (plain_fn binds by import)', () => { @@ -1093,9 +1100,10 @@ describe('R NAMESPACE importFrom() bindings to local packages', () => { }); it('does not bind an import to an R6 method when no top-level def carries the name', () => { - expect(edgeSummaries('r6_method_only_user', 'analytics/R/r6_use.R')).toEqual([ - 'describe_run:scorelib/R/r6.R:global-name-fallback:0.5', - ]); + // A bare `describe_run()` cannot invoke an R6 method (`Reporter.describe_run`, reached only + // as `obj$describe_run()`), so since #11 the fallback offers no candidate either (was: a + // 0.5 guess to the method). + expect(edgeSummaries('r6_method_only_user', 'analytics/R/r6_use.R')).toEqual([]); }); it('documents residual O1: a nested function defined before the top-level one still wins the binding', () => { 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 new file mode 100644 index 000000000..62dc3d7ab --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/r/r-dotted-s3-hooks.test.ts @@ -0,0 +1,115 @@ +/** + * R dotted (S3) names vs bare names in the shared simple-name indexes + * (kaybenleroll/GitNexus#11): the `isCallableVisibleFromCaller` hook and the + * `expandRWildcardNames` tail guard. ParsedFiles come from the real R extractor. + */ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; +import { rProvider } from '../../../../src/core/ingestion/languages/r.js'; +import { populateClassOwnedMembers } from '../../../../src/core/ingestion/scope-resolution/scope/walkers.js'; +import { + expandRWildcardNames, + rIsCallableVisibleFromCaller, + rScopeResolver, +} from '../../../../src/core/ingestion/languages/r/scope-resolver.js'; + +function parse(src: string, filePath: string): ParsedFile { + const parsed = extractParsedFile(rProvider, src, filePath); + if (parsed === undefined) throw new Error(`no ParsedFile for ${filePath}`); + populateClassOwnedMembers(parsed); + return parsed; +} + +const def = (parsed: ParsedFile, qualifiedName: string): SymbolDefinition => { + const found = parsed.localDefs.find((d) => d.qualifiedName === qualifiedName); + if (found === undefined) throw new Error(`no def ${qualifiedName}`); + return found; +}; + +const visible = (callerParsed: ParsedFile, candidate: SymbolDefinition): boolean => + rIsCallableVisibleFromCaller({ callerParsed, candidate } as never); + +describe('rIsCallableVisibleFromCaller', () => { + const p = parse( + [ + 'print.foo <- function(x, ...) x', + 'foo <- function(v) v', + '`odd name` <- function(v) v', + 'Scorer <- R6::R6Class("Scorer", public = list(score = function(d) d))', + ].join('\n'), + 'pkg/R/a.R', + ); + + it('is registered on rScopeResolver', () => { + expect(rScopeResolver.isCallableVisibleFromCaller).toBe(rIsCallableVisibleFromCaller); + }); + + it('refuses a dotted S3 def for a bare call', () => { + expect(visible(p, def(p, 'print.foo'))).toBe(false); + }); + + it('allows a bare def and a backticked non-dotted def', () => { + expect(visible(p, def(p, 'foo'))).toBe(true); + expect(visible(p, def(p, '`odd name`'))).toBe(true); + }); + + it('refuses a class member whose qualified name is dotted (never callable bare)', () => { + const member = p.localDefs.find( + (d) => d.type === 'Method' && d.qualifiedName?.endsWith('score'), + ); + if (member === undefined) throw new Error('no R6 member def'); + expect(member.qualifiedName).toContain('.'); + expect(visible(p, member)).toBe(false); + }); + + it('allows a def with no qualified name (nothing to prove)', () => { + expect(visible(p, { ...def(p, 'foo'), qualifiedName: undefined })).toBe(true); + }); +}); + +describe('expandRWildcardNames', () => { + const names = (src: string): readonly string[] => { + const parsed = parse(src, 'pkg/R/a.R'); + return expandRWildcardNames(parsed.moduleScope, [parsed]); + }; + + it('drops a bare name whose file defines a dotted def with that tail (S3 method first)', () => { + expect(names('print.foo <- function(x) x\nfoo <- function(v) v\nqux <- function(v) v')).toEqual( + ['qux'], + ); + }); + + it('drops it in the mirror layout too (order-blind)', () => { + expect(names('bar <- function(v) v\nprint.bar <- function(x) x\nqux <- function(v) v')).toEqual( + ['qux'], + ); + }); + + it('keeps names that share no tail with a dotted def', () => { + expect(names('print.foo <- function(x) x\nplain_fn <- function(v) v')).toEqual(['plain_fn']); + }); + + it('keeps a bare name when the dotted def sits in a different file', () => { + const a = parse('foo <- function(v) v', 'pkg/R/a.R'); + const b = parse('print.foo <- function(x) x', 'pkg/R/b.R'); + expect(expandRWildcardNames(a.moduleScope, [a, b])).toEqual(['foo']); + }); + + it('keeps a bare name that only matches a class member (not a top-level dotted def)', () => { + expect( + names( + 'Scorer <- R6::R6Class("Scorer", public = list(tidy = function(d) d))\ntidy <- function(d) d', + ), + ).toEqual(['Scorer', 'tidy']); + }); + + it('never lists dotted names themselves', () => { + expect(names('print.foo <- function(x) x')).toEqual([]); + }); + + it('returns nothing for an unknown module scope', () => { + const a = parse('foo <- function(v) v', 'pkg/R/a.R'); + expect(expandRWildcardNames('scope:missing' as never, [a])).toEqual([]); + }); +});