diff --git a/gitnexus/src/core/ingestion/import-resolvers/r.ts b/gitnexus/src/core/ingestion/import-resolvers/r.ts index 1d7735f3a..e155245c7 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/r.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/r.ts @@ -6,7 +6,13 @@ import path from 'path'; import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; +import type { ParsedFile } from 'gitnexus-shared'; import type { RPackageConfig } from '../language-config.js'; +import { + rFileTopLevel, + rPackageDirForFile, + type RFileTopLevel, +} from '../languages/r/namespace-imports.js'; import type { ImportResult, ResolveCtx } from './types.js'; import type { ImportResolutionContext } from '../scope-resolution/contract/scope-resolver.js'; import { getWorkspaceFileIndex } from './workspace-file-index.js'; @@ -83,6 +89,102 @@ export function resolveRImport( return { kind: 'files', files }; } +/** Per-package index of top-level definitions, built once per `parsedFiles` array. */ +interface RPackageDefIndex { + /** Files of the package's `R/` directory, with what each defines at top level. */ + readonly files: ReadonlyMap; + /** Top-level name → files (in `parsedFiles` order) that define it. */ + readonly definingFiles: ReadonlyMap; +} + +const packageDefIndexes = new WeakMap< + readonly ParsedFile[], + WeakMap> +>(); + +function packageDefIndex( + parsedFiles: readonly ParsedFile[], + rConfig: RPackageConfig, + pkg: string, +): RPackageDefIndex | undefined { + let byConfig = packageDefIndexes.get(parsedFiles); + if (byConfig === undefined) packageDefIndexes.set(parsedFiles, (byConfig = new WeakMap())); + let byPackage = byConfig.get(rConfig); + if (byPackage === undefined) { + const files = new Map>(); + const defining = new Map>(); + for (const parsed of parsedFiles) { + const location = rPackageDirForFile(parsed.filePath, rConfig); + if (location === undefined) continue; + const topLevel = rFileTopLevel(parsed); + let pkgFiles = files.get(location.name); + let pkgDefining = defining.get(location.name); + if (pkgFiles === undefined || pkgDefining === undefined) { + files.set(location.name, (pkgFiles = new Map())); + defining.set(location.name, (pkgDefining = new Map())); + } + pkgFiles.set(parsed.filePath, topLevel); + for (const name of topLevel.names) { + const list = pkgDefining.get(name); + if (list === undefined) pkgDefining.set(name, [parsed.filePath]); + else list.push(parsed.filePath); + } + } + const built = new Map(); + for (const [name, pkgFiles] of files) { + built.set(name, { files: pkgFiles, definingFiles: defining.get(name) ?? new Map() }); + } + byConfig.set(rConfig, (byPackage = built)); + } + return byPackage.get(pkg); +} + +const stripBackticks = (s: string): string => s.replace(/^`+|`+$/g, ''); + +/** + * Resolve a NAMESPACE `importFrom(pkg, importedName)` to the `R/` file(s) of the + * local package `pkg` that define `importedName` at top level, or `null`. + * + * `null` (no binding, no IMPORTS edge) when: `pkg` is not a local package + * (external packages never suffix-match anything); `importedName` is dotted + * (finalize keys defs by the text after the last `.`, so it can never bind); + * the provider has a NAMESPACE that does not export the name; no file defines + * it at top level; or a defining file also holds a dotted top-level def whose + * tail equals `importedName` (finalize would index `print.tidy` under `tidy` + * and bind the WRONG def — the guard is order-blind, so it also refuses the + * layout where the bare def comes first). `null` degrades the call site to the + * baseline `global-name-fallback` guess. Never builds a workspace file index. + */ +function resolveRNamedImport( + rConfig: RPackageConfig | null, + targetRaw: string, + importedName: string, + parsedFiles: readonly ParsedFile[], +): readonly string[] | null { + const pkg = targetRaw.replace(/^["']|["']$/g, ''); + const pkgDir = rConfig?.packages.get(pkg); + if (rConfig === null || pkgDir === undefined) return null; + + if (importedName.includes('.')) return null; + + const info = rConfig.namespaceInfoByPackageDir.get(pkgDir); + if (info !== undefined && info.hasNamespaceFile) { + const exported = + info.namedExports.has(importedName) || + [...info.namedExports].some((entry) => stripBackticks(entry) === importedName) || + info.exportPatterns.some((pattern) => pattern.test(importedName)); + if (!exported) return null; + } + + const index = packageDefIndex(parsedFiles, rConfig, pkg); + const defining = index?.definingFiles.get(importedName); + if (index === undefined || defining === undefined || defining.length === 0) return null; + if (defining.some((file) => index.files.get(file)?.dottedTails.has(importedName) === true)) { + return null; + } + return defining; +} + /** * `ScopeResolver.resolveImportTarget`-shaped adapter over * `resolveRImportInternal`. Two responsibilities live only here, not in the @@ -102,6 +204,9 @@ export function resolveRImport( * `library()`/`require()` shape (see `interpretRImport`), so the guard is * scoped to that kind only — `source()`'s own suffix-based file-path * fallback is unaffected. + * - NAMESPACE `importFrom()` names (`kind: 'named'`, synthesised by + * `populateRNamespaceImports`): resolved by {@link resolveRNamedImport} + * before `getWorkspaceFileIndex`, so they build no file index. */ export function resolveRImportTarget( targetRaw: string, @@ -117,6 +222,15 @@ export function resolveRImportTarget( if (rConfig === null || !rConfig.packages.has(cleaned)) return null; } + if (context?.parsedImport?.kind === 'named') { + return resolveRNamedImport( + rConfig, + targetRaw, + context.parsedImport.importedName, + context.parsedFiles, + ); + } + const { normalized, all, index } = getWorkspaceFileIndex(allFilePaths); return resolveRImportInternal(fromFile, targetRaw, normalized, all, rConfig, index); } diff --git a/gitnexus/src/core/ingestion/languages/r/namespace-imports.ts b/gitnexus/src/core/ingestion/languages/r/namespace-imports.ts index 830fdaaa6..02c1915ce 100644 --- a/gitnexus/src/core/ingestion/languages/r/namespace-imports.ts +++ b/gitnexus/src/core/ingestion/languages/r/namespace-imports.ts @@ -19,8 +19,17 @@ * Runtime import direction is `language-config.ts` → this file; any type from * `language-config.ts` must be taken with `import type` only, so no runtime * import cycle forms. + * + * The second half of the file binds those entries to workspace definitions: + * {@link populateRNamespaceImports} synthesises `named` imports for + * `importFrom()` names that a *local* package provides, and + * {@link resolveRImportTarget}'s `named` branch (`import-resolvers/r.ts`) + * resolves them using {@link rFileTopLevel} / {@link rPackageDirForFile}. */ +import type { ParsedFile, ParsedImport } from 'gitnexus-shared'; +import type { RPackageConfig } from '../../language-config.js'; + /** One `importFrom(pkg, name)` pair, in NAMESPACE file order. */ export interface RNamespaceImportFromEntry { readonly pkg: string; @@ -236,3 +245,214 @@ export function parseRNamespaceImportFrom(text: string): RNamespaceImportFromEnt } return entries; } + +// ─── Package lookup ───────────────────────────────────────────────────────── + +/** A local R package located by {@link rPackageDirForFile}. */ +export interface RPackageLocation { + /** `Package:` name from `DESCRIPTION`. */ + readonly name: string; + /** Package directory relative to the repo root; `''` for a root-level package. */ + readonly dir: string; +} + +/** `R/` directory prefix of a package dir (`'R/'` for a root-level package). */ +export function rRDirPrefix(pkgDir: string): string { + return pkgDir === '' ? 'R/' : `${pkgDir}/R/`; +} + +/** + * The local package whose `/R/**` contains `filePath`, or `undefined`. + * + * NAMESPACE effects apply only to a package's `R/` sources (R loads nothing + * else into the namespace), so files under `tests/`, `scripts/`, `inst/`, a + * root-level `plumber.R`, … belong to no package here. When package dirs nest, + * the longest matching dir wins. A root-level package (`dir === ''`) is + * handled without ever building a `'' + '/'` prefix. + */ +export function rPackageDirForFile( + filePath: string, + cfg: RPackageConfig | null | undefined, +): RPackageLocation | undefined { + if (cfg === null || cfg === undefined) return undefined; + const normalized = filePath.replace(/\\/g, '/'); + let best: RPackageLocation | undefined; + for (const [name, dir] of cfg.packages) { + if (!normalized.startsWith(rRDirPrefix(dir))) continue; + if (best === undefined || dir.length > best.dir.length) best = { name, dir }; + } + return best; +} + +// ─── Top-level definitions of a parsed file ──────────────────────────────── + +/** Def kinds an R `importFrom()` can bind to (functions and R6/R5/S4 classes). */ +const TOP_LEVEL_BINDABLE_TYPES: ReadonlySet = new Set(['Function', 'Class']); + +/** What one R file defines at top level, read from its Module scope. */ +export interface RFileTopLevel { + /** Names of top-level function/class definitions, exactly as bound (backticks kept). */ + readonly names: ReadonlySet; + /** For each dotted name in {@link names}, the text after its last `.`. */ + readonly dottedTails: ReadonlySet; +} + +const EMPTY_TOP_LEVEL: RFileTopLevel = { names: new Set(), dottedTails: new Set() }; +const topLevelCache = new WeakMap(); + +/** + * Top-level definitions of `parsed`, read from the Module scope's hoisted + * bindings. + * + * Since the caller-attribution fix (fork #5) a top-level function def is + * owned by its *own* Function scope and only its binding is hoisted into the + * Module scope, so neither the Module scope's `ownedDefs` nor the flat + * `localDefs` (which also lists nested functions and R6/R5 members) answers + * "is `name` defined at top level?". A `local`-origin Module binding to a + * function/class def does. Bindings are read at extraction time, before + * `finalizeScopeModel` adds import/wildcard bindings. + */ +export function rFileTopLevel(parsed: ParsedFile): RFileTopLevel { + const cached = topLevelCache.get(parsed); + if (cached !== undefined) return cached; + const moduleScope = parsed.scopes.find((s) => s.id === parsed.moduleScope); + let result = EMPTY_TOP_LEVEL; + if (moduleScope !== undefined) { + const names = new Set(); + const dottedTails = new Set(); + for (const [name, refs] of moduleScope.bindings) { + if (!refs.some((r) => r.origin === 'local' && TOP_LEVEL_BINDABLE_TYPES.has(r.def.type))) { + continue; + } + names.add(name); + const dot = name.lastIndexOf('.'); + if (dot >= 0) dottedTails.add(name.slice(dot + 1)); + } + result = { names, dottedTails }; + } + topLevelCache.set(parsed, result); + return result; +} + +// ─── Import synthesis (populateWorkspaceReferences) ───────────────────────── + +/** + * Per local package, the set of names it defines at top level in its `R/` + * files, recorded by {@link populateRNamespaceImports} for the packages that + * take part in an `importFrom()` (as caller or as provider). Keyed by the + * `resolutionConfig` object, which reaches both this hook and the + * free-call-fallback veto. A missing entry means "cannot decide". + */ +const packageTopLevelNames = new WeakMap>>(); + +/** Recorded top-level names per package for `resolutionConfig`, if the hook ran with imports. */ +export function rRecordedPackageTopLevelNames( + resolutionConfig: unknown, +): ReadonlyMap> | undefined { + if (typeof resolutionConfig !== 'object' || resolutionConfig === null) return undefined; + return packageTopLevelNames.get(resolutionConfig); +} + +interface CallerPlan { + readonly dir: string; + /** Surviving `name → provider package` after last-wins / self-drop / external-skip. */ + readonly bindable: ReadonlyMap; +} + +/** + * Synthesise `named` imports for NAMESPACE `importFrom(pkg, name)` entries + * whose `pkg` is a local package, into every `R/` file of the caller package. + * + * NOTE: `populateWorkspaceReferences` is documented as reference-site + * enrichment; it is reused here (same call site and ctx as the Zig static + * gating precedent) because it runs before `finalizeScopeModel`, receives the + * mutable `ParsedFile[]`, and also runs over warm-cache ParsedFiles, so the + * synthesised imports are never persisted stale. If this is ever upstreamed, + * propose widening that contract's doc string. + * + * Order of operations per caller package (decided rules): + * 1. last-wins over ALL entries for a name, in file order, external + * packages included (R replaces the earlier `importFrom` binding); + * 2. drop the surviving entry when its `pkg` is the caller itself; + * 3. skip it when its `pkg` is not a local package (an earlier local entry + * never resurrects); + * 4. skip it when the caller's own package defines `name` at top level in + * any `R/` file (the own namespace masks its imports; `rMergeBindings` + * only shadows within a single scope); + * 5. append a `named` import to every `R/` file of the caller package. + */ +export function populateRNamespaceImports( + parsedFiles: ParsedFile[], + ctx: { readonly resolutionConfig?: unknown }, +): void { + const cfg = (ctx.resolutionConfig as RPackageConfig | null | undefined) ?? null; + if (cfg === null || cfg.packages.size === 0) return; + + // Steps 1-3: collapse each caller's entries; note which packages we need names for. + const plans = new Map(); + const needNamesFor = new Set(); + for (const [callerName, dir] of cfg.packages) { + const info = cfg.namespaceInfoByPackageDir.get(dir); + if (info === undefined || info.importFrom.length === 0) continue; + const last = new Map(); + for (const entry of info.importFrom) last.set(entry.name, entry.pkg); + const bindable = new Map(); + for (const [name, pkg] of last) { + if (cfg.packages.has(pkg)) needNamesFor.add(pkg); // C5 reads the provider's names too + if (pkg === callerName || !cfg.packages.has(pkg)) continue; + bindable.set(name, pkg); + } + if (bindable.size === 0) continue; + plans.set(callerName, { dir, bindable }); + needNamesFor.add(callerName); + } + if (plans.size === 0) return; + + // One pass over the parsed files: group caller files, collect top-level names. + const namesByPackage = new Map>(); + const callerFiles = new Map(); + for (let i = 0; i < parsedFiles.length; i++) { + const location = rPackageDirForFile(parsedFiles[i].filePath, cfg); + if (location === undefined) continue; + if (needNamesFor.has(location.name)) { + let names = namesByPackage.get(location.name); + if (names === undefined) namesByPackage.set(location.name, (names = new Set())); + for (const name of rFileTopLevel(parsedFiles[i]).names) names.add(name); + } + if (plans.has(location.name)) { + const indices = callerFiles.get(location.name); + if (indices === undefined) callerFiles.set(location.name, [i]); + else indices.push(i); + } + } + for (const pkg of needNamesFor) if (!namesByPackage.has(pkg)) namesByPackage.set(pkg, new Set()); + packageTopLevelNames.set(cfg, namesByPackage); + + // Steps 4-5. + for (const [callerName, plan] of plans) { + const ownNames = namesByPackage.get(callerName); + const synthesised: Extract[] = []; + for (const [name, pkg] of plan.bindable) { + if (ownNames?.has(name) === true) continue; + synthesised.push({ kind: 'named', localName: name, importedName: name, targetRaw: pkg }); + } + if (synthesised.length === 0) continue; + for (const index of callerFiles.get(callerName) ?? []) { + const parsed = parsedFiles[index]; + const fresh = synthesised.filter( + (imp) => + !parsed.parsedImports.some( + (existing) => + existing.kind === 'named' && + existing.localName === imp.localName && + existing.targetRaw === imp.targetRaw, + ), + ); + if (fresh.length === 0) continue; + parsedFiles[index] = Object.freeze({ + ...parsed, + parsedImports: Object.freeze([...parsed.parsedImports, ...fresh]), + }); + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts index a83d1d558..06c5cd8c6 100644 --- a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts @@ -23,7 +23,10 @@ import type { ParsedFile, ScopeId } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; -import { findEnclosingClassDef, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { + findEnclosingClassDef, + populateClassOwnedMembers, +} from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; @@ -32,6 +35,7 @@ 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 { populateRNamespaceImports } from './namespace-imports.js'; import { rProvider } from '../r.js'; import { rArityCompatibility, rMergeBindings } from './simple-hooks.js'; @@ -164,4 +168,14 @@ export const rScopeResolver: ScopeResolver = { // simple name, so an R6/R5 method named like an imported symbol could win // the binding over the top-level function. namedImportsBindTopLevelOnly: true, + + // NAMESPACE `importFrom()` names provided by a LOCAL package become + // synthesised `named` imports (`resolveRImportTarget`'s named branch binds + // them). Runs before finalize and over warm-cache ParsedFiles too. + populateWorkspaceReferences: populateRNamespaceImports, + + // `populateRNamespaceImports` reads only ParsedFile scopes, never source + // text: without this, merely declaring the hook makes the pipeline read the + // text of every R file on the main thread. + postExtractSourceTextPolicy: 'uncached-files', }; diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/DESCRIPTION new file mode 100644 index 000000000..4876b221a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/DESCRIPTION @@ -0,0 +1,3 @@ +Package: analytics +Title: NAMESPACE Import Fixture Caller +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/NAMESPACE new file mode 100644 index 000000000..b8a16f804 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/NAMESPACE @@ -0,0 +1,32 @@ +export(run_all) +# Single-line and multi-line importFrom() directives from local packages. +importFrom(scorelib, tidy_scores) +importFrom( + scorelib, + normalise_scores, + rank_scores +) +importFrom("scorelib", "quoted_fn") +# Exported by scorelib, but also defined in this package: the own package masks it. +importFrom(scorelib, own_dup) +# Duplicate names: the later directive wins (legacyscore). +importFrom(scorelib, dup_fn) +importFrom(legacyscore, dup_fn) +# Not exported by scorelib; never exported name with no definition; whole-package import. +importFrom(scorelib, hidden_helper) +importFrom(scorelib, no_such_fn) +import(reportlib) +# Dotted names cannot bind precisely. +importFrom(scorelib, normalise.scores) +importFrom(scorelib, tidy) +importFrom(scorelib, tally) +importFrom(scorelib, plain_fn) +# R6 members versus top-level definitions. +importFrom(scorelib, score_it) +importFrom(scorelib, describe_run) +# Re-export through a local provider (scorelib imports it from corelib). +importFrom(scorelib, reexp_fn) +# Nested function sharing a name with a later top-level function. +importFrom(scorelib, nest_fn) +# External packages never bind. +importFrom(dplyr, mutate) diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/decoy_first.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/decoy_first.R new file mode 100644 index 000000000..ea38165ee --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/decoy_first.R @@ -0,0 +1,3 @@ +# First function of the package in load order: a decoy that owns none of the +# edges emitted from the functions written in later files. +first_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/dotted_use.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/dotted_use.R new file mode 100644 index 000000000..a0972c65b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/dotted_use.R @@ -0,0 +1,15 @@ +dotted_name_user <- function(d) { + normalise.scores(d) +} + +print_before_bare_user <- function(d) { + tidy(d) +} + +bare_before_print_user <- function(d) { + tally(d) +} + +underscore_user <- function(d) { + plain_fn(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/fork7.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/fork7.R new file mode 100644 index 000000000..fbe270d00 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/fork7.R @@ -0,0 +1,4 @@ +# Explicitly qualified call to a package the NAMESPACE does not import it from. +qualified_user <- function(d) { + legacyscore::tidy_scores(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/nested_use.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/nested_use.R new file mode 100644 index 000000000..811ff6a3b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/nested_use.R @@ -0,0 +1,3 @@ +nested_user <- function(d) { + nest_fn(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/own.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/own.R new file mode 100644 index 000000000..bd1ac7d6d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/own.R @@ -0,0 +1 @@ +own_dup <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/r6_use.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/r6_use.R new file mode 100644 index 000000000..0ce8297e8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/r6_use.R @@ -0,0 +1,7 @@ +r6_flag_user <- function(d) { + score_it(d) +} + +r6_method_only_user <- function(d) { + describe_run(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/reexport_use.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/reexport_use.R new file mode 100644 index 000000000..5f99dff5c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/reexport_use.R @@ -0,0 +1,3 @@ +reexport_user <- function(d) { + reexp_fn(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/second.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/second.R new file mode 100644 index 000000000..fddfc34ac --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/second.R @@ -0,0 +1,4 @@ +# A second R file: every R file of the package sees the NAMESPACE imports. +second_user <- function(d) { + tidy_scores(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/use.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/use.R new file mode 100644 index 000000000..b497c6140 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/use.R @@ -0,0 +1,11 @@ +run_all <- function(d) { + tidy_scores(d) + normalise_scores(d) + rank_scores(d) + quoted_fn(d) + own_dup(d) + dup_fn(d) + hidden_helper(d) + no_such_fn(d) + d +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/scripts/helper.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/scripts/helper.R new file mode 100644 index 000000000..f88f56a5a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/scripts/helper.R @@ -0,0 +1,2 @@ +# Script outside any package R/ directory: NAMESPACE imports do not apply here. +scripts_only_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/tests/testthat/test-x.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/tests/testthat/test-x.R new file mode 100644 index 000000000..d38482b7c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/tests/testthat/test-x.R @@ -0,0 +1,3 @@ +test_that_user <- function(d) { + tidy_scores(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/DESCRIPTION new file mode 100644 index 000000000..fc54af712 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/DESCRIPTION @@ -0,0 +1,3 @@ +Package: corelib +Title: NAMESPACE Import Fixture Origin +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/NAMESPACE new file mode 100644 index 000000000..dd8405a31 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/NAMESPACE @@ -0,0 +1 @@ +export(reexp_fn) diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/R/c.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/R/c.R new file mode 100644 index 000000000..6784b1965 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/R/c.R @@ -0,0 +1 @@ +reexp_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/DESCRIPTION new file mode 100644 index 000000000..54b283547 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/DESCRIPTION @@ -0,0 +1,3 @@ +Package: legacyscore +Title: NAMESPACE Import Fixture Decoy +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/NAMESPACE new file mode 100644 index 000000000..fe3e3b7e0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/NAMESPACE @@ -0,0 +1,3 @@ +export(tidy_scores) +export(dup_fn) +export(mutate) diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/R/l.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/R/l.R new file mode 100644 index 000000000..8c36341a5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/R/l.R @@ -0,0 +1,3 @@ +tidy_scores <- function(d) d +dup_fn <- function(d) d +mutate <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/DESCRIPTION new file mode 100644 index 000000000..297c17b2a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/DESCRIPTION @@ -0,0 +1,3 @@ +Package: scorelib +Title: NAMESPACE Import Fixture Provider +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/NAMESPACE new file mode 100644 index 000000000..054c7f000 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/NAMESPACE @@ -0,0 +1,16 @@ +export(tidy_scores) +export(normalise_scores) +export(rank_scores) +export(quoted_fn) +export(own_dup) +export(dup_fn) +export(no_such_fn) +export(normalise.scores) +export(tidy) +export(tally) +export(plain_fn) +export(score_it) +export(describe_run) +export(reexp_fn) +export(nest_fn) +importFrom(corelib, reexp_fn) diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dotted.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dotted.R new file mode 100644 index 000000000..5de739876 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dotted.R @@ -0,0 +1,7 @@ +# S3-style dotted definitions: a dotted def is indexed under its last segment. +print.tidy <- function(x, ...) x +tidy <- function(d) d +normalise.scores <- function(d) d + +tally <- function(d) d +print.tally <- function(x, ...) x diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dup.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dup.R new file mode 100644 index 000000000..3c5c82370 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dup.R @@ -0,0 +1,2 @@ +# Also defined (and exported) by legacyscore, which the importer names last. +dup_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/internal.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/internal.R new file mode 100644 index 000000000..00c702b42 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/internal.R @@ -0,0 +1,2 @@ +# Not listed in NAMESPACE: defined but not exported. +hidden_helper <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/nested.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/nested.R new file mode 100644 index 000000000..b55592d80 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/nested.R @@ -0,0 +1,6 @@ +nest_host <- function(d) { + nest_fn <- function(y) y + nest_fn(d) +} + +nest_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/own_dup.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/own_dup.R new file mode 100644 index 000000000..a41b9ac12 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/own_dup.R @@ -0,0 +1,2 @@ +# Also defined in the importing package, whose own definition masks this one. +own_dup <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/plain.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/plain.R new file mode 100644 index 000000000..808ade737 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/plain.R @@ -0,0 +1 @@ +plain_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/r6.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/r6.R new file mode 100644 index 000000000..af3531be5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/r6.R @@ -0,0 +1,15 @@ +Scorer <- R6::R6Class("Scorer", + public = list( + # Method defined BEFORE the top-level function of the same name. + score_it = function(d) d + ) +) + +score_it <- function(d) d + +Reporter <- R6::R6Class("Reporter", + public = list( + # Only a method: no top-level function carries this name. + describe_run = function(d) d + ) +) diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/s.R b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/s.R new file mode 100644 index 000000000..ef42032f4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/s.R @@ -0,0 +1,4 @@ +tidy_scores <- function(d) d +normalise_scores <- function(d) d +rank_scores <- function(d) d +quoted_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/vendor/dplyr.py b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/vendor/dplyr.py new file mode 100644 index 000000000..55924f7a7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-namespace-imports/vendor/dplyr.py @@ -0,0 +1,4 @@ +# Same-named file in another language: an external R package must never +# suffix-match it. +def mutate(d): + return d diff --git a/gitnexus/test/integration/resolvers/r.test.ts b/gitnexus/test/integration/resolvers/r.test.ts index 3cd929af6..9cc8dbd94 100644 --- a/gitnexus/test/integration/resolvers/r.test.ts +++ b/gitnexus/test/integration/resolvers/r.test.ts @@ -898,3 +898,261 @@ describe('R native pipe chains', () => { }); }); }); + +describe('R NAMESPACE importFrom() bindings to local packages', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'r-namespace-imports'), () => {}); + }, 60000); + + const callEdges = (sourceName: string, fileSuffix: string) => + getRelationships(result, 'CALLS').filter( + (e) => e.source === sourceName && e.sourceFilePath.endsWith(fileSuffix), + ); + + /** Sorted unique CALLS targets of the source named `sourceName` in the file ending `fileSuffix`. */ + const callsFrom = (sourceName: string, fileSuffix: string): string[] => + [...new Set(callEdges(sourceName, fileSuffix).map((e) => e.target))].sort(); + + /** `target:targetFile:reason:confidence` of every edge from `sourceName`, sorted. */ + const edgeSummaries = (sourceName: string, fileSuffix: string): string[] => + callEdges(sourceName, fileSuffix) + .map((e) => `${e.target}:${e.targetFilePath}:${e.rel.reason}:${e.rel.confidence}`) + .sort(); + + /** Sorted unique IMPORTS target files of the file `sourceFile` (fixture-root-relative). */ + const importTargets = (sourceFile: string): string[] => + [ + ...new Set( + getRelationships(result, 'IMPORTS') + .filter((e) => e.sourceFilePath === sourceFile) + .map((e) => e.targetFilePath), + ), + ].sort(); + + const USE = 'analytics/R/use.R'; + + // Every `R/` file of the caller package, with the import set the synthesised + // named imports must produce for it (defining provider files only). + const CALLER_FILES = [ + 'analytics/R/decoy_first.R', + 'analytics/R/dotted_use.R', + 'analytics/R/fork7.R', + 'analytics/R/nested_use.R', + 'analytics/R/own.R', + 'analytics/R/r6_use.R', + 'analytics/R/reexport_use.R', + 'analytics/R/second.R', + 'analytics/R/use.R', + ]; + const BOUND_TARGET_FILES = [ + 'legacyscore/R/l.R', // dup_fn: the later importFrom() (legacyscore) wins + 'scorelib/R/nested.R', + 'scorelib/R/plain.R', + 'scorelib/R/r6.R', + 'scorelib/R/s.R', + ]; + + describe('bare calls to names imported from a local package', () => { + it('resolves every imported name from the one calling function, and nothing else', () => { + expect(callsFrom('run_all', USE)).toEqual([ + 'dup_fn', + 'hidden_helper', + 'normalise_scores', + 'quoted_fn', + 'rank_scores', + 'tidy_scores', + ]); + }); + + it('binds a single-line importFrom() name to the provider even though another package defines it', () => { + // Baseline (before this change): no edge — `tidy_scores` is defined in scorelib AND + // legacyscore, so the name-only guess is ambiguous and drops it. + expect( + callEdges('run_all', USE) + .filter((e) => e.target === 'tidy_scores') + .map((e) => `${e.targetFilePath}:${e.rel.reason}:${e.rel.confidence}`), + ).toEqual(['scorelib/R/s.R:import-resolved:0.85']); + }); + + it('binds names from a multi-line importFrom() and a quoted importFrom() by import, not by name guess', () => { + // Baseline: 0.5 `global-name-fallback` edges only (the multi-line/quoted forms were unparsed). + expect( + ['normalise_scores', 'rank_scores', 'quoted_fn'].map((name) => + callEdges('run_all', USE) + .filter((e) => e.target === name) + .map((e) => `${name}:${e.targetFilePath}:${e.rel.reason}`), + ), + ).toEqual([ + ['normalise_scores:scorelib/R/s.R:import-resolved'], + ['rank_scores:scorelib/R/s.R:import-resolved'], + ['quoted_fn:scorelib/R/s.R:import-resolved'], + ]); + }); + + it('applies the imports to every R file of the caller package, not only the caller of the name', () => { + expect(edgeSummaries('second_user', 'analytics/R/second.R')).toEqual([ + 'tidy_scores:scorelib/R/s.R:import-resolved:0.85', + ]); + }); + + it('lets the later importFrom() win when two local packages are imported for one name', () => { + // scorelib is imported first, legacyscore last: R replaces the earlier binding. + expect( + callEdges('run_all', USE) + .filter((e) => e.target === 'dup_fn') + .map((e) => `${e.targetFilePath}:${e.rel.reason}`), + ).toEqual(['legacyscore/R/l.R:import-resolved']); + }); + + it('does not bind an imported name that the calling package also defines (own namespace masks)', () => { + // `own_dup` is defined in analytics/R/own.R AND scorelib: ambiguous, so no edge. An edge to + // scorelib's `own_dup` would mean the synthesised import outranked R's own-namespace rule. + expect(callsFrom('run_all', USE)).not.toContain('own_dup'); + }); + + it('does not bind a name the provider defines but does not export, and keeps the fallback edge', () => { + expect( + callEdges('run_all', USE) + .filter((e) => e.target === 'hidden_helper') + .map((e) => `${e.targetFilePath}:${e.rel.reason}:${e.rel.confidence}`), + ).toEqual(['scorelib/R/internal.R:global-name-fallback:0.5']); + }); + + it('emits no edge for an exported name that no package defines', () => { + expect(callsFrom('run_all', USE)).not.toContain('no_such_fn'); + }); + + it('emits no edge for a call outside the package R/ directory (NAMESPACE does not apply)', () => { + // `tidy_scores` is ambiguous by name (scorelib + legacyscore); tests/ gets no import. + expect(callsFrom('test_that_user', 'analytics/tests/testthat/test-x.R')).toEqual([]); + }); + }); + + describe('IMPORTS edges', () => { + it('gives every caller R file IMPORTS edges to exactly the provider files that define an imported name', () => { + expect(CALLER_FILES.map((f) => [f, importTargets(f)])).toEqual( + CALLER_FILES.map((f) => [f, BOUND_TARGET_FILES]), + ); + }); + + it('does not link the provider file of a shadowed, unexported or dotted-only name', () => { + const all = CALLER_FILES.flatMap((f) => importTargets(f)); + expect(all.filter((t) => /own_dup|dup\.R|internal|dotted/.test(t))).toEqual([]); + }); + + it('never links an external package import to a same-named file in another language', () => { + const targets = getRelationships(result, 'IMPORTS').map((e) => e.targetFilePath); + expect(targets.filter((t) => t.startsWith('vendor/'))).toEqual([]); + }); + + it('adds no IMPORTS edge for files outside the package R/ directory', () => { + const sources = getRelationships(result, 'IMPORTS').map((e) => e.sourceFilePath); + expect( + sources.filter((s) => s.startsWith('analytics/scripts/') || s.includes('/tests/')), + ).toEqual([]); + }); + }); + + describe('dotted names', () => { + it('leaves a dotted importedName unbound (baseline call edge unchanged)', () => { + // `normalise.scores` can never bind by import (finalize keys defs by the text after the + // last `.`); the call keeps the baseline low-confidence edge, not an import-resolved one. + expect(edgeSummaries('dotted_name_user', 'analytics/R/dotted_use.R')).toEqual([ + 'normalise.scores:scorelib/R/dotted.R:scope-resolution: call:0.35', + ]); + }); + + 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([]); + }); + + 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([]); + }); + + it('does not fire for an underscore name (plain_fn binds by import)', () => { + expect(edgeSummaries('underscore_user', 'analytics/R/dotted_use.R')).toEqual([ + 'plain_fn:scorelib/R/plain.R:import-resolved:0.85', + ]); + }); + }); + + describe('R6 members and nested definitions in the provider', () => { + it('binds the top-level def, not the same-named R6 method defined earlier in the same file', () => { + // Fails without `namedImportsBindTopLevelOnly` (the wide index would store the method). + expect( + callEdges('r6_flag_user', 'analytics/R/r6_use.R').map( + (e) => `${e.targetLabel}:${e.target}:${e.rel.reason}`, + ), + ).toEqual(['Function:score_it:import-resolved']); + }); + + 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', + ]); + }); + + it('documents residual O1: a nested function defined before the top-level one still wins the binding', () => { + // The named branch can only choose FILES; finalize's first-callable rule then picks the + // nested def. Fixing it needs an `isExported` capture (query change): out of scope here. + const edges = callEdges('nested_user', 'analytics/R/nested_use.R'); + expect(edges.map((e) => `${e.target}:${e.targetFilePath}:${e.rel.reason}`)).toEqual([ + 'nest_fn:scorelib/R/nested.R:import-resolved', + ]); + const nestFnLines = getNodesByLabelFull(result, 'Function') + .filter((n) => n.name === 'nest_fn' && n.properties.filePath === 'scorelib/R/nested.R') + .map((n) => n.properties.startLine as number); + const boundLine = result.graph.getNode(edges[0].rel.targetId)?.properties.startLine; + expect(boundLine).toBe(Math.min(...nestFnLines)); + }); + }); + + describe('re-export through a local provider', () => { + it('keeps the baseline fallback edge to the defining package (no import binding)', () => { + // scorelib re-exports `reexp_fn` from corelib and defines no top-level def of the name. + expect(edgeSummaries('reexport_user', 'analytics/R/reexport_use.R')).toEqual([ + 'reexp_fn:corelib/R/c.R:global-name-fallback:0.5', + ]); + }); + }); + + describe('explicitly qualified call to another local package', () => { + it('documents current name-only behaviour after importFrom() binding (fork #7)', () => { + // `legacyscore::tidy_scores()` names legacyscore, yet the qualifier is discarded and the + // synthesised import (scorelib) binds it: a FALSE 0.85 edge (baseline: ambiguous, no edge). + // Fork #7 should flip this. + expect(edgeSummaries('qualified_user', 'analytics/R/fork7.R')).toEqual([ + 'tidy_scores:scorelib/R/s.R:import-resolved:0.85', + ]); + }); + }); +}); + +describe('R importFrom(pkgB, CleanData) in the shared r-packages fixture', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'r-packages'), () => {}); + }, 60000); + + it('binds the pkgB::CleanData call by import instead of the name guess', () => { + // Baseline: 0.5 `global-name-fallback`. pkgA/NAMESPACE now yields a real import binding. + expect( + getRelationships(result, 'CALLS') + .filter((e) => e.source === 'AddOutlierStatuses' && e.target === 'CleanData') + .map((e) => `${e.targetFilePath}:${e.rel.reason}:${e.rel.confidence}`), + ).toEqual(['pkgB/R/clean_data.R:import-resolved:0.85']); + }); + + it('links pkgA R files to the pkgB file that defines the imported name', () => { + const imports = getRelationships(result, 'IMPORTS').filter( + (e) => e.sourceFilePath.startsWith('pkgA/R/') && e.targetFilePath === 'pkgB/R/clean_data.R', + ); + expect(imports.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index 91f887b37..672ef35a9 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -556,4 +556,35 @@ describe('external imports never resolve into the repository (#2953)', () => { `${language}: '${testCase.external}' names nothing in this repo, but resolved to ${JSON.stringify(resolved)}`, ).toEqual([]); }); + // R's NAMESPACE `importFrom(ggplot2, geom)` is a `named` import (the wildcard/side-effect arms + // above cover `library()`/`source()`). The bare package name must not suffix-match the + // same-named `vendor/ggplot2.py`, whether or not a local package config exists. + it.each([ + ['no package config', undefined], + [ + 'a package config that lists only other packages', + { packages: new Map([['pkgA', 'pkgA']]), namespaceInfoByPackageDir: new Map() }, + ], + ])( + 'R: a named importFrom() of an external package resolves to nothing (%s)', + (_label, config) => { + const testCase = CASES.get(SupportedLanguages.R)!; + const answer = SCOPE_RESOLVERS.get(SupportedLanguages.R)!.resolveImportTarget( + testCase.external, + testCase.fromFile, + new Set(testCase.files), + config, + { + parsedFiles: [], + parsedImport: { + kind: 'named', + localName: 'geom', + importedName: 'geom', + targetRaw: testCase.external, + }, + }, + ); + expect(filesOf(answer)).toEqual([]); + }, + ); }); diff --git a/gitnexus/test/unit/scope-resolution/r/r-namespace-imports.test.ts b/gitnexus/test/unit/scope-resolution/r/r-namespace-imports.test.ts new file mode 100644 index 000000000..1b1df2215 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/r/r-namespace-imports.test.ts @@ -0,0 +1,690 @@ +/** + * R NAMESPACE `importFrom()` binding (kaybenleroll/GitNexus#2, step 3): + * + * - `rPackageDirForFile`: which package's `R/` directory a file belongs to; + * - `populateRNamespaceImports`: synthesised `named` imports for local + * providers (last-wins, self-import drop, external skip, own-package mask); + * - `resolveRImportTarget`'s `named` branch: local-package check, dotted-name + * guards, NAMESPACE export check, top-level-definition lookup, and the + * "builds no workspace file index" property. + * + * ParsedFiles come from the real R extractor; package configs are built by hand + * (`RPackageConfig` has no other constructor than `loadRPackageConfig`). + */ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, ParsedImport } 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 type { + RNamespaceInfo, + RPackageConfig, +} from '../../../../src/core/ingestion/language-config.js'; +import { + populateRNamespaceImports, + rFileTopLevel, + rPackageDirForFile, + rRecordedPackageTopLevelNames, +} from '../../../../src/core/ingestion/languages/r/namespace-imports.js'; +import { resolveRImportTarget } from '../../../../src/core/ingestion/import-resolvers/r.js'; +import { CountingSet } from '../../../helpers/counting-file-set.js'; + +// ─── builders ─────────────────────────────────────────────────────────────── + +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; +} + +interface NsSpec { + readonly exports?: readonly string[]; + readonly exportPatterns?: readonly RegExp[]; + readonly importFrom?: readonly (readonly [pkg: string, name: string])[]; + /** Omit the NAMESPACE entry altogether (package without a NAMESPACE file). */ + readonly none?: boolean; +} + +function config( + packages: Record, + namespaces: Record = {}, +): RPackageConfig { + const namespaceInfoByPackageDir = new Map(); + for (const [name, dir] of Object.entries(packages)) { + const spec = namespaces[name]; + if (spec === undefined || spec.none === true) continue; + namespaceInfoByPackageDir.set(dir, { + hasNamespaceFile: true, + namedExports: new Set(spec.exports ?? []), + exportPatterns: [...(spec.exportPatterns ?? [])], + importFrom: (spec.importFrom ?? []).map(([pkg, name]) => ({ pkg, name })), + }); + } + return { packages: new Map(Object.entries(packages)), namespaceInfoByPackageDir }; +} + +const namedImportsOf = (parsed: ParsedFile): string[] => + parsed.parsedImports + .filter((i): i is Extract => i.kind === 'named') + .map((i) => `${i.targetRaw}::${i.importedName}`) + .sort(); + +const populate = (files: ParsedFile[], cfg: RPackageConfig | null): ParsedFile[] => { + populateRNamespaceImports(files, { resolutionConfig: cfg ?? undefined }); + return files; +}; + +// ─── rPackageDirForFile ───────────────────────────────────────────────────── + +describe('rPackageDirForFile', () => { + const cfg = config({ alpha: 'alpha', beta: 'sub/beta' }); + + it('finds the package whose R/ directory holds the file', () => { + expect(rPackageDirForFile('alpha/R/a.R', cfg)).toEqual({ name: 'alpha', dir: 'alpha' }); + }); + + it('finds a nested package directory', () => { + expect(rPackageDirForFile('sub/beta/R/b.R', cfg)).toEqual({ name: 'beta', dir: 'sub/beta' }); + }); + + it('accepts Windows separators', () => { + expect(rPackageDirForFile('alpha\\R\\a.R', cfg)?.name).toBe('alpha'); + }); + + it('returns nothing for a file outside every package R/ directory', () => { + expect( + [ + 'alpha/tests/testthat/test-a.R', + 'alpha/scripts/x.R', + 'alpha/plumber.R', + 'other/R/x.R', + 'alphaR/R/x.R', + ].map((f) => rPackageDirForFile(f, cfg)), + ).toEqual([undefined, undefined, undefined, undefined, undefined]); + }); + + it('handles a root-level package (dir "") without building a "/" prefix', () => { + const root = config({ rootpkg: '' }); + expect([ + rPackageDirForFile('R/a.R', root), + rPackageDirForFile('R/sub/a.R', root), + rPackageDirForFile('tests/R/a.R', root), + rPackageDirForFile('plumber.R', root), + ]).toEqual([{ name: 'rootpkg', dir: '' }, { name: 'rootpkg', dir: '' }, undefined, undefined]); + }); + + it('prefers the longest package directory when package directories nest', () => { + const nested = config({ outer: 'outer', inner: 'outer/inner' }); + expect(rPackageDirForFile('outer/inner/R/x.R', nested)?.name).toBe('inner'); + expect(rPackageDirForFile('outer/R/x.R', nested)?.name).toBe('outer'); + }); + + it('returns nothing without a config', () => { + expect([ + rPackageDirForFile('alpha/R/a.R', null), + rPackageDirForFile('a/R/a.R', undefined), + ]).toEqual([undefined, undefined]); + }); +}); + +// ─── rFileTopLevel ────────────────────────────────────────────────────────── + +describe('rFileTopLevel', () => { + const parsed = parse( + [ + 'x <- 1', + 'tidy <- function(a) { inner <- function(b) b; inner(a) }', + 'print.tidy <- function(x) x', + 'Scorer <- R6::R6Class("Scorer", public = list(method_only = function(z) z))', + ].join('\n'), + 'p/R/a.R', + ); + const top = rFileTopLevel(parsed); + + it('lists top-level functions and classes, dotted names included', () => { + expect([...top.names].sort()).toEqual(['Scorer', 'print.tidy', 'tidy']); + }); + + it('excludes nested functions, R6 members and plain variables', () => { + expect([...top.names].filter((n) => ['inner', 'method_only', 'x'].includes(n))).toEqual([]); + }); + + it('records the tail of every dotted name', () => { + expect([...top.dottedTails]).toEqual(['tidy']); + }); +}); + +// ─── populateRNamespaceImports ────────────────────────────────────────────── + +describe('populateRNamespaceImports', () => { + const pkgs = { analytics: 'analytics', scorelib: 'scorelib', legacyscore: 'legacyscore' }; + const provider = (path: string, names: string[]) => + parse(names.map((n) => `${n} <- function(d) d`).join('\n'), path); + const callerFile = (path: string, src = 'noop <- function() 1') => parse(src, path); + + it('synthesises a named import for a local-package importFrom() into every R/ file of the caller', () => { + const files = populate( + [ + callerFile('analytics/R/a.R'), + callerFile('analytics/R/b.R'), + callerFile('analytics/R/c.R'), + provider('scorelib/R/s.R', ['tidy_scores']), + ], + config(pkgs, { + analytics: { importFrom: [['scorelib', 'tidy_scores']] }, + scorelib: { exports: ['tidy_scores'] }, + }), + ); + expect(files.map(namedImportsOf)).toEqual([ + ['scorelib::tidy_scores'], + ['scorelib::tidy_scores'], + ['scorelib::tidy_scores'], + [], + ]); + }); + + it('shapes the import as a per-name, un-renamed named import of the package', () => { + const [caller] = populate( + [callerFile('analytics/R/a.R'), provider('scorelib/R/s.R', ['tidy_scores'])], + config(pkgs, { analytics: { importFrom: [['scorelib', 'tidy_scores']] } }), + ); + expect(caller.parsedImports).toEqual([ + { + kind: 'named', + localName: 'tidy_scores', + importedName: 'tidy_scores', + targetRaw: 'scorelib', + }, + ]); + }); + + it('skips an importFrom() of an external package (nothing local to bind)', () => { + const files = populate( + [callerFile('analytics/R/a.R'), provider('scorelib/R/s.R', ['mutate'])], + config(pkgs, { analytics: { importFrom: [['dplyr', 'mutate']] } }), + ); + expect(files.map(namedImportsOf)).toEqual([[], []]); + }); + + it('drops a self-import (the parser returns it; the package cannot import from itself)', () => { + const files = populate( + [callerFile('analytics/R/a.R'), provider('scorelib/R/s.R', ['f'])], + config(pkgs, { analytics: { importFrom: [['analytics', 'f']] } }), + ); + expect(files.map(namedImportsOf)).toEqual([[], []]); + }); + + it('skips a name the caller package defines at top level, even in a different file', () => { + const files = populate( + [ + callerFile('analytics/R/a.R', 'uses_it <- function() 1'), + callerFile('analytics/R/own.R', 'own_dup <- function(d) d'), + provider('scorelib/R/s.R', ['own_dup', 'other_fn']), + ], + config(pkgs, { + analytics: { + importFrom: [ + ['scorelib', 'own_dup'], + ['scorelib', 'other_fn'], + ], + }, + }), + ); + expect(files.map(namedImportsOf)).toEqual([['scorelib::other_fn'], ['scorelib::other_fn'], []]); + }); + + it('does not treat an R6 method of the caller package as a top-level definition', () => { + const files = populate( + [ + callerFile( + 'analytics/R/own.R', + 'Runner <- R6::R6Class("Runner", public = list(tidy_scores = function(d) d))', + ), + provider('scorelib/R/s.R', ['tidy_scores']), + ], + config(pkgs, { analytics: { importFrom: [['scorelib', 'tidy_scores']] } }), + ); + expect(namedImportsOf(files[0])).toEqual(['scorelib::tidy_scores']); + }); + + it('keeps only the last entry when two local packages import one name (last wins)', () => { + const files = populate( + [ + callerFile('analytics/R/a.R'), + provider('scorelib/R/s.R', ['dup']), + provider('legacyscore/R/l.R', ['dup']), + ], + config(pkgs, { + analytics: { + importFrom: [ + ['scorelib', 'dup'], + ['legacyscore', 'dup'], + ], + }, + }), + ); + expect(namedImportsOf(files[0])).toEqual(['legacyscore::dup']); + }); + + it('synthesises nothing when an external package is imported LAST for a name (no resurrection)', () => { + const files = populate( + [callerFile('analytics/R/a.R'), provider('scorelib/R/s.R', ['dup_ext'])], + config(pkgs, { + analytics: { + importFrom: [ + ['scorelib', 'dup_ext'], + ['dplyr', 'dup_ext'], + ], + }, + }), + ); + expect(namedImportsOf(files[0])).toEqual([]); + }); + + it('binds the local package when it is imported after an external one', () => { + const files = populate( + [callerFile('analytics/R/a.R'), provider('scorelib/R/s.R', ['dup_ext'])], + config(pkgs, { + analytics: { + importFrom: [ + ['dplyr', 'dup_ext'], + ['scorelib', 'dup_ext'], + ], + }, + }), + ); + expect(namedImportsOf(files[0])).toEqual(['scorelib::dup_ext']); + }); + + it('touches only R/ files: scripts/ and tests/ of the caller package get no import', () => { + const files = populate( + [ + callerFile('analytics/R/a.R'), + callerFile('analytics/scripts/x.R'), + callerFile('analytics/tests/testthat/test-x.R'), + callerFile('analytics/plumber.R'), + provider('scorelib/R/s.R', ['f']), + ], + config(pkgs, { analytics: { importFrom: [['scorelib', 'f']] } }), + ); + expect(files.map(namedImportsOf)).toEqual([['scorelib::f'], [], [], [], []]); + }); + + it('handles a root-level caller package', () => { + const files = populate( + [callerFile('R/a.R'), callerFile('scripts/x.R'), provider('scorelib/R/s.R', ['f'])], + config( + { rootpkg: '', scorelib: 'scorelib' }, + { rootpkg: { importFrom: [['scorelib', 'f']] } }, + ), + ); + expect(files.map(namedImportsOf)).toEqual([['scorelib::f'], [], []]); + }); + + it('replaces array entries with frozen copies and leaves the originals untouched', () => { + const original = callerFile('analytics/R/a.R'); + const files = [original, provider('scorelib/R/s.R', ['f'])]; + populate(files, config(pkgs, { analytics: { importFrom: [['scorelib', 'f']] } })); + expect([ + files[0] === original, + Object.isFrozen(files[0]), + original.parsedImports.length, + ]).toEqual([false, true, 0]); + }); + + it('is idempotent: a second pass does not duplicate the synthesised imports', () => { + const files = [callerFile('analytics/R/a.R'), provider('scorelib/R/s.R', ['f'])]; + const cfg = config(pkgs, { analytics: { importFrom: [['scorelib', 'f']] } }); + populate(files, cfg); + populate(files, cfg); + expect(namedImportsOf(files[0])).toEqual(['scorelib::f']); + }); + + it('is a no-op without a config, with no packages, or when no package imports a local one', () => { + const files = [callerFile('analytics/R/a.R'), provider('scorelib/R/s.R', ['f'])]; + const before = [...files]; + populate(files, null); + populate(files, config({})); + populate(files, config(pkgs, { analytics: { importFrom: [['dplyr', 'f']] } })); + populate(files, config(pkgs, { scorelib: { exports: ['f'] } })); + expect(files.every((f, i) => f === before[i])).toBe(true); + }); + + it("records each participating package's top-level names for the fallback veto", () => { + const cfg = config(pkgs, { analytics: { importFrom: [['scorelib', 'f']] } }); + populate( + [ + callerFile('analytics/R/a.R', 'run <- function() 1'), + provider('scorelib/R/s.R', ['f', 'g']), + provider('legacyscore/R/l.R', ['h']), + ], + cfg, + ); + const recorded = rRecordedPackageTopLevelNames(cfg); + expect([ + [...(recorded?.get('analytics') ?? [])], + [...(recorded?.get('scorelib') ?? [])].sort(), + recorded?.has('legacyscore'), + ]).toEqual([['run'], ['f', 'g'], false]); + }); + + it('records nothing for an unrelated config object', () => { + expect(rRecordedPackageTopLevelNames(config(pkgs))).toBeUndefined(); + expect(rRecordedPackageTopLevelNames(undefined)).toBeUndefined(); + }); +}); + +// ─── resolveRImportTarget: named branch ───────────────────────────────────── + +describe('resolveRImportTarget — named imports (NAMESPACE importFrom)', () => { + const namedImport = (pkg: string, name: string): ParsedImport => ({ + kind: 'named', + localName: name, + importedName: name, + targetRaw: pkg, + }); + + interface Setup { + readonly cfg: RPackageConfig; + readonly files: ParsedFile[]; + } + + /** scorelib provider files (source per path) plus a caller file. */ + function setup( + providerSources: Record, + ns: NsSpec | undefined, + dir = 'scorelib', + ): Setup { + const files = [ + parse('run <- function() 1', 'analytics/R/use.R'), + ...Object.entries(providerSources).map(([p, src]) => parse(src, p)), + ]; + return { + cfg: config( + { analytics: 'analytics', scorelib: dir }, + ns === undefined ? {} : { scorelib: ns }, + ), + files, + }; + } + + const resolve = (s: Setup, pkg: string, name: string) => + resolveRImportTarget(pkg, 'analytics/R/use.R', new Set(s.files.map((f) => f.filePath)), s.cfg, { + parsedFiles: s.files, + parsedImport: namedImport(pkg, name), + }); + + const F = 'scorelib/R/s.R'; + + it('returns nothing for an external package', () => { + const s = setup({ [F]: 'mutate <- function(d) d' }, { exports: ['mutate'] }); + expect(resolve(s, 'dplyr', 'mutate')).toBeNull(); + }); + + it('returns nothing without a package config', () => { + const s = setup({ [F]: 'f <- function(d) d' }, undefined); + expect( + resolveRImportTarget('scorelib', 'analytics/R/use.R', new Set([F]), undefined, { + parsedFiles: s.files, + parsedImport: namedImport('scorelib', 'f'), + }), + ).toBeNull(); + }); + + it('returns nothing when the local package has no definition of the name', () => { + const s = setup({ [F]: 'other <- function(d) d' }, { exports: ['f', 'other'] }); + expect(resolve(s, 'scorelib', 'f')).toBeNull(); + }); + + it('returns only the files that define the name at top level', () => { + const s = setup( + { + [F]: 'f <- function(d) d', + 'scorelib/R/t.R': 'g <- function(d) d', + 'scorelib/R/u.R': 'f <- function(d) d', + }, + { exports: ['f', 'g'] }, + ); + expect(resolve(s, 'scorelib', 'f')).toEqual([F, 'scorelib/R/u.R']); + }); + + it('ignores same-named definitions outside the package R/ directory and in other packages', () => { + const s = setup( + { + 'scorelib/scripts/x.R': 'f <- function(d) d', + 'legacyscore/R/l.R': 'f <- function(d) d', + }, + { exports: ['f'] }, + ); + expect(resolve(s, 'scorelib', 'f')).toBeNull(); + }); + + it('does not bind an R6 method, nor a nested function, as a top-level definition', () => { + const s = setup( + { + [F]: [ + 'Scorer <- R6::R6Class("Scorer", public = list(method_only = function(d) d))', + 'host <- function(d) { nested_only <- function(y) y; nested_only(d) }', + ].join('\n'), + }, + { exports: ['method_only', 'nested_only'] }, + ); + expect([resolve(s, 'scorelib', 'method_only'), resolve(s, 'scorelib', 'nested_only')]).toEqual([ + null, + null, + ]); + }); + + it('resolves an R6 class defined at top level', () => { + const s = setup( + { [F]: 'Scorer <- R6::R6Class("Scorer", public = list())' }, + { exports: ['Scorer'] }, + ); + expect(resolve(s, 'scorelib', 'Scorer')).toEqual([F]); + }); + + it('strips quotes around the package name', () => { + const s = setup({ [F]: 'f <- function(d) d' }, { exports: ['f'] }); + expect(resolve(s, '"scorelib"', 'f')).toEqual([F]); + }); + + describe('dotted-name guards', () => { + it('refuses a dotted importedName (finalize can never bind it)', () => { + const s = setup( + { [F]: 'normalise.scores <- function(d) d' }, + { exports: ['normalise.scores'] }, + ); + expect(resolve(s, 'scorelib', 'normalise.scores')).toBeNull(); + }); + + it('refuses tidy when print.tidy is defined BEFORE it in the same file', () => { + const s = setup( + { [F]: 'print.tidy <- function(x) x\ntidy <- function(d) d' }, + { exports: ['tidy'] }, + ); + expect(resolve(s, 'scorelib', 'tidy')).toBeNull(); + }); + + it('refuses tidy when print.tidy is defined AFTER it too (order-blind, conservative)', () => { + const s = setup( + { [F]: 'tidy <- function(d) d\nprint.tidy <- function(x) x' }, + { exports: ['tidy'] }, + ); + expect(resolve(s, 'scorelib', 'tidy')).toBeNull(); + }); + + it('does not fire for an underscore name in the same file as dotted defs', () => { + const s = setup( + { [F]: 'print.tidy <- function(x) x\nplain_fn <- function(d) d' }, + { exports: ['plain_fn'] }, + ); + expect(resolve(s, 'scorelib', 'plain_fn')).toEqual([F]); + }); + + it('does not fire when the dotted def sits in a different file from the definition', () => { + const s = setup( + { + [F]: 'tidy <- function(d) d', + 'scorelib/R/t.R': 'print.tidy <- function(x) x', + }, + { exports: ['tidy'] }, + ); + expect(resolve(s, 'scorelib', 'tidy')).toEqual([F]); + }); + + it('does not fire for a same-named R6 method (only Module-scope bindings count)', () => { + const s = setup( + { + [F]: [ + 'Scorer <- R6::R6Class("Scorer", public = list(tidy = function(d) d))', + 'tidy <- function(d) d', + ].join('\n'), + }, + { exports: ['tidy'] }, + ); + expect(resolve(s, 'scorelib', 'tidy')).toEqual([F]); + }); + }); + + describe('NAMESPACE export check', () => { + const src = 'f <- function(d) d\nhidden <- function(d) d\n`%+%` <- function(a, b) a'; + + it('resolves a name listed in export()', () => { + const s = setup({ [F]: src }, { exports: ['f'] }); + expect(resolve(s, 'scorelib', 'f')).toEqual([F]); + }); + + it('refuses a defined name the provider NAMESPACE does not export', () => { + const s = setup({ [F]: src }, { exports: ['f'] }); + expect(resolve(s, 'scorelib', 'hidden')).toBeNull(); + }); + + it('resolves a name matched by an exportPattern()', () => { + const s = setup({ [F]: src }, { exportPatterns: [/^hid/] }); + expect(resolve(s, 'scorelib', 'hidden')).toEqual([F]); + }); + + it('matches a backticked export() entry against the unquoted name (backticks stripped)', () => { + const s = setup({ [F]: src }, { exports: ['`%+%`'] }); + // Export check passes; the def keeps its backticks, so the lookup ends with no file. + expect(resolve(s, 'scorelib', '%+%')).toBeNull(); + const t = setup({ [F]: 'g <- function(d) d' }, { exports: ['`g`'] }); + expect(resolve(t, 'scorelib', 'g')).toEqual([F]); + }); + + it('treats a package without a NAMESPACE file as exporting everything', () => { + const s = setup({ [F]: src }, undefined); + expect(resolve(s, 'scorelib', 'hidden')).toEqual([F]); + }); + + it('treats a package whose NAMESPACE entry is absent as exporting everything', () => { + const s = setup({ [F]: src }, { none: true }); + expect(resolve(s, 'scorelib', 'hidden')).toEqual([F]); + }); + + it('checks a root-level provider package (dir "")', () => { + const files = [ + parse('run <- function() 1', 'analytics/R/use.R'), + parse('f <- function(d) d\nhidden <- function(d) d', 'R/s.R'), + ]; + const cfg = config({ analytics: 'analytics', rootpkg: '' }, { rootpkg: { exports: ['f'] } }); + const ask = (name: string) => + resolveRImportTarget('rootpkg', 'analytics/R/use.R', new Set(), cfg, { + parsedFiles: files, + parsedImport: namedImport('rootpkg', name), + }); + expect([ask('f'), ask('hidden')]).toEqual([['R/s.R'], null]); + }); + + it('characterisation: a POSIX-class exportPattern never matches in a JS RegExp, so every name is refused', () => { + // `exportPattern("^[[:alpha:]]+")` (the RStudio default) compiles to a RegExp that matches + // nothing, so the export check judges every name non-exported and the import degrades to + // the baseline name-guess. Existing loadRPackageConfig behaviour, not fixed here. + const s = setup( + { [F]: 'tidy_scores <- function(d) d' }, + { + exportPatterns: [new RegExp('^[[:alpha:]]+')], + }, + ); + expect(resolve(s, 'scorelib', 'tidy_scores')).toBeNull(); + }); + }); + + describe('workspace file index', () => { + it('builds no file index for named imports (the file Set is never traversed)', () => { + const s = setup({ [F]: 'f <- function(d) d' }, { exports: ['f'] }); + const paths = new CountingSet(s.files.map((f) => f.filePath)); + for (let i = 0; i < 25; i++) { + resolveRImportTarget('scorelib', 'analytics/R/use.R', paths, s.cfg, { + parsedFiles: s.files, + parsedImport: namedImport('scorelib', 'f'), + }); + resolveRImportTarget('dplyr', 'analytics/R/use.R', paths, s.cfg, { + parsedFiles: s.files, + parsedImport: namedImport('dplyr', 'f'), + }); + } + expect(paths.scans).toBe(0); + }); + + it('still builds one memoised index for wildcard imports', () => { + const s = setup({ [F]: 'f <- function(d) d' }, { exports: ['f'] }); + const paths = new CountingSet(s.files.map((f) => f.filePath)); + for (let i = 0; i < 25; i++) { + resolveRImportTarget('scorelib', 'analytics/R/use.R', paths, s.cfg, { + parsedFiles: s.files, + parsedImport: { kind: 'wildcard', targetRaw: 'scorelib' }, + }); + } + expect(paths.scans).toBe(1); + }); + + it('indexes the parsed files once for many named imports', () => { + const s = setup({ [F]: 'f <- function(d) d\ng <- function(d) d' }, { exports: ['f', 'g'] }); + const first = resolve(s, 'scorelib', 'f'); + const second = resolve(s, 'scorelib', 'f'); + expect(second).toBe(first); + }); + }); + + describe('non-named kinds and context-less calls', () => { + it('leaves a wildcard import of an external package unresolved', () => { + const s = setup({ [F]: 'f <- function(d) d' }, { exports: ['f'] }); + expect( + resolveRImportTarget('dplyr', 'analytics/R/use.R', new Set([F]), s.cfg, { + parsedFiles: s.files, + parsedImport: { kind: 'wildcard', targetRaw: 'dplyr' }, + }), + ).toBeNull(); + }); + + it('resolves a wildcard import of a local package to all its R/ files, unchanged', () => { + const s = setup( + { [F]: 'f <- function(d) d', 'scorelib/R/t.R': 'g <- function(d) d' }, + { exports: ['f'] }, + ); + expect( + resolveRImportTarget( + 'scorelib', + 'analytics/R/use.R', + new Set(s.files.map((f) => f.filePath)), + s.cfg, + { parsedFiles: s.files, parsedImport: { kind: 'wildcard', targetRaw: 'scorelib' } }, + ), + ).toEqual([F, 'scorelib/R/t.R']); + }); + + it('a context-less call is treated as before (suffix resolution, no named branch)', () => { + const s = setup({ [F]: 'f <- function(d) d' }, { exports: ['f'] }); + expect( + resolveRImportTarget( + 'scorelib', + 'analytics/R/use.R', + new Set(s.files.map((f) => f.filePath)), + s.cfg, + ), + ).toEqual([F]); + }); + }); +});