From 49db332589f052cd68b8913fe62fb26fab997189 Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Tue, 29 Sep 2026 17:46:36 +0100 Subject: [PATCH] feat(r): bind NAMESPACE importFrom names to local package definitions Synthesise a `named` import for every NAMESPACE importFrom(pkg, name) whose pkg is a local package, into each R/ file of the importing package (populateRNamespaceImports, wired via populateWorkspaceReferences with postExtractSourceTextPolicy 'uncached-files'), and resolve it in resolveRImportTarget's named branch. Rules: last entry wins per name over all entries; a self-import, an external last entry, and a name the importing package defines at top level (own namespace masks) synthesise nothing. The named branch builds no workspace file index, returns null for external or unknown packages, dotted names (finalize keys defs by the text after the last dot), providers whose NAMESPACE does not export the name (backticks stripped), names with no top-level definition, and a defining file that also holds a dotted def whose tail equals the name. Top-level definitions are read from the Module scope's hoisted bindings, not ownedDefs/localDefs. Known limitations, pinned by tests: pkg::name() qualifiers are discarded at this point, a nested same-named function can still win the binding, and POSIX-class exportPattern() never matches in a JS RegExp. --- .../src/core/ingestion/import-resolvers/r.ts | 114 +++ .../languages/r/namespace-imports.ts | 220 ++++++ .../ingestion/languages/r/scope-resolver.ts | 16 +- .../r-namespace-imports/analytics/DESCRIPTION | 3 + .../r-namespace-imports/analytics/NAMESPACE | 32 + .../analytics/R/decoy_first.R | 3 + .../analytics/R/dotted_use.R | 15 + .../r-namespace-imports/analytics/R/fork7.R | 4 + .../analytics/R/nested_use.R | 3 + .../r-namespace-imports/analytics/R/own.R | 1 + .../r-namespace-imports/analytics/R/r6_use.R | 7 + .../analytics/R/reexport_use.R | 3 + .../r-namespace-imports/analytics/R/second.R | 4 + .../r-namespace-imports/analytics/R/use.R | 11 + .../analytics/scripts/helper.R | 2 + .../analytics/tests/testthat/test-x.R | 3 + .../r-namespace-imports/corelib/DESCRIPTION | 3 + .../r-namespace-imports/corelib/NAMESPACE | 1 + .../r-namespace-imports/corelib/R/c.R | 1 + .../legacyscore/DESCRIPTION | 3 + .../r-namespace-imports/legacyscore/NAMESPACE | 3 + .../r-namespace-imports/legacyscore/R/l.R | 3 + .../r-namespace-imports/scorelib/DESCRIPTION | 3 + .../r-namespace-imports/scorelib/NAMESPACE | 16 + .../r-namespace-imports/scorelib/R/dotted.R | 7 + .../r-namespace-imports/scorelib/R/dup.R | 2 + .../r-namespace-imports/scorelib/R/internal.R | 2 + .../r-namespace-imports/scorelib/R/nested.R | 6 + .../r-namespace-imports/scorelib/R/own_dup.R | 2 + .../r-namespace-imports/scorelib/R/plain.R | 1 + .../r-namespace-imports/scorelib/R/r6.R | 15 + .../r-namespace-imports/scorelib/R/s.R | 4 + .../r-namespace-imports/vendor/dplyr.py | 4 + gitnexus/test/integration/resolvers/r.test.ts | 258 +++++++ .../external-import-conformance.test.ts | 31 + .../r/r-namespace-imports.test.ts | 690 ++++++++++++++++++ 36 files changed, 1495 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/decoy_first.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/dotted_use.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/fork7.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/nested_use.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/own.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/r6_use.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/reexport_use.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/second.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/use.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/scripts/helper.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/tests/testthat/test-x.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/R/c.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/R/l.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dotted.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dup.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/internal.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/nested.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/own_dup.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/plain.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/r6.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/s.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-namespace-imports/vendor/dplyr.py create mode 100644 gitnexus/test/unit/scope-resolution/r/r-namespace-imports.test.ts 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]); + }); + }); +});