diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/DESCRIPTION new file mode 100644 index 000000000..1431fc39e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/DESCRIPTION @@ -0,0 +1,3 @@ +Package: caller +Title: Qualified Call Fixture Truncated Discovery +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/NAMESPACE new file mode 100644 index 000000000..37b192e9f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/NAMESPACE @@ -0,0 +1 @@ +export(unknown_user) diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/R/calls.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/R/calls.R new file mode 100644 index 000000000..71a7dbb27 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/caller/R/calls.R @@ -0,0 +1,23 @@ +# Package discovery stops at directory depth 3, so the packages under deep/a/b/c/ are never +# found: whether they are local is UNKNOWN, and the qualified sites must be left as they are. + +# Same-file decoys for the two qualifiers below. +unk_fn <- function(d) d +filter <- function(d) d + +# `invisiblepkg` is a real package (DESCRIPTION `Package:` in deep/a/b/c/impl), invisible to +# discovery, and its directory name does not match: nothing shows it is local. +unknown_user <- function(d) { + invisiblepkg::unk_fn(d) +} + +# With discovery truncated this cannot be shown to be external either. +truncated_external_user <- function(d) { + dplyr::filter(d) +} + +# `pathpkg` has no DESCRIPTION, but its parsed files sit under `pathpkg/R/`. +path_local_user <- function(d) { + pathpkg::path_fn(d) +} +path_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/impl/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/impl/DESCRIPTION new file mode 100644 index 000000000..b432c9faa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/impl/DESCRIPTION @@ -0,0 +1,3 @@ +Package: invisiblepkg +Title: Package Invisible To Discovery +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/impl/R/x.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/impl/R/x.R new file mode 100644 index 000000000..c9112ea8d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/impl/R/x.R @@ -0,0 +1 @@ +unk_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/pathpkg/R/p.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/pathpkg/R/p.R new file mode 100644 index 000000000..de513dbc8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls-truncated/deep/a/b/c/pathpkg/R/p.R @@ -0,0 +1 @@ +path_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/DESCRIPTION new file mode 100644 index 000000000..a0f89d39f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/DESCRIPTION @@ -0,0 +1,3 @@ +Package: altlib +Title: Qualified Call Fixture Decoy Provider +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/NAMESPACE new file mode 100644 index 000000000..03ef472f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/NAMESPACE @@ -0,0 +1,2 @@ +export(shared) +export(libshared) diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/R/alt.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/R/alt.R new file mode 100644 index 000000000..0b76fe76d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/altlib/R/alt.R @@ -0,0 +1,2 @@ +shared <- function(d) d +libshared <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/DESCRIPTION new file mode 100644 index 000000000..32cf7f99d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/DESCRIPTION @@ -0,0 +1,3 @@ +Package: caller +Title: Qualified Call Fixture Caller +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/NAMESPACE new file mode 100644 index 000000000..eb978aa7d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/NAMESPACE @@ -0,0 +1,3 @@ +export(local_user) +# Decoy: names altlib, but the calls below are explicitly qualified with locallib. +importFrom(altlib, shared) diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/duplicate_calls.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/duplicate_calls.R new file mode 100644 index 000000000..5b4d229c2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/duplicate_calls.R @@ -0,0 +1,15 @@ +# Qualified calls to names that duplicate inside the named package (no single valid target). + +# `twice` is defined twice in ONE file of duplib (a redefinition); this file holds a decoy. +twice <- function(d) d + +dup_same_file_user <- function(d) { + duplib::twice(d) +} + +# `across` is defined in two DIFFERENT files of duplib; this file holds a decoy. +across <- function(d) d + +dup_cross_file_user <- function(d) { + duplib::across(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/external_calls.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/external_calls.R new file mode 100644 index 000000000..ca22ef5f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/external_calls.R @@ -0,0 +1,21 @@ +# Qualified calls to packages that are not part of the repository (certainly external). +# Each name collides with a local definition that the qualifier rules out. + +# Same-file decoy. +filter <- function(d) d + +external_same_file_user <- function(d) { + dplyr::filter(d) +} + +# The colliding definition lives in another local package (provlib), not in this file. +external_other_pkg_user <- function(d) { + dplyr::mutate(d) +} + +# `:::` to an external package. +internal_helper <- function(d) d + +external_internal_user <- function(d) { + dplyr:::internal_helper(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/local_calls.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/local_calls.R new file mode 100644 index 000000000..1c92ca457 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/R/local_calls.R @@ -0,0 +1,51 @@ +# Callers of functions in OTHER local packages, always written with an explicit qualifier. +# Every callee is defined exactly once in the package the qualifier names; the same-name +# definitions in THIS package are decoys the qualifier must never bind to. + +# Wrapper self-loop: the wrapper carries the name of the function it forwards to, so the +# scope chain binds the qualified call to the wrapper itself. +tidy <- function(d) { + locallib::tidy(d) +} + +# Same-file decoy: `decoy_fn` is also defined below, in this very file. +decoy_fn <- function(d) d + +local_user <- function(d) { + locallib::decoy_fn(d) +} + +# Package whose directory name (renamed_impl) differs from its DESCRIPTION `Package:` (renamed). +compute_it <- function(d) d + +renamed_user <- function(d) { + renamed::compute_it(d) +} + +# `:::` reaches an unexported function of the named package. +internal_user <- function(d) { + locallib:::hidden(d) +} + +# Quoted, backticked and spaced qualifiers all name locallib. +quoted_user <- function(d) { + "locallib"::quoted_fn(d) +} + +backticked_user <- function(d) { + `locallib`::backticked_fn(d) +} + +spaced_user <- function(d) { + locallib ::: spaced_fn(d) +} + +# NAMESPACE importFrom(altlib, shared) decoy: the qualifier names locallib. +importfrom_decoy_user <- function(d) { + locallib::shared(d) +} + +# locallib re-exports `reexp_fn` from provlib and defines nothing of that name itself. +reexport_user <- function(d) { + locallib::reexp_fn(d) +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/scripts/run.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/scripts/run.R new file mode 100644 index 000000000..e2b694f5a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/caller/scripts/run.R @@ -0,0 +1,5 @@ +# Outside any package R/ directory. `library(altlib)` attaches altlib, but the call is +# explicitly qualified with locallib. +library(altlib) + +script_result <- locallib::libshared(1) diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/DESCRIPTION new file mode 100644 index 000000000..71d252e92 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/DESCRIPTION @@ -0,0 +1,3 @@ +Package: duplib +Title: Qualified Call Fixture Duplicate Definitions +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/NAMESPACE new file mode 100644 index 000000000..f2394cf55 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/NAMESPACE @@ -0,0 +1,2 @@ +export(twice) +export(across) diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/R/a.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/R/a.R new file mode 100644 index 000000000..41e7005dc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/R/a.R @@ -0,0 +1,5 @@ +# Redefinition in the same file: the later definition wins in R. +twice <- function(d) d +twice <- function(d) d + 1 + +across <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/R/b.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/R/b.R new file mode 100644 index 000000000..69e3e4add --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/duplib/R/b.R @@ -0,0 +1,2 @@ +# A second definition of `across` in another file of the same package. +across <- function(d) d * 2 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/DESCRIPTION new file mode 100644 index 000000000..80e614e6e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/DESCRIPTION @@ -0,0 +1,3 @@ +Package: locallib +Title: Qualified Call Fixture Local Provider +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/NAMESPACE new file mode 100644 index 000000000..0ed19cfdc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/NAMESPACE @@ -0,0 +1,9 @@ +export(tidy) +export(decoy_fn) +export(quoted_fn) +export(backticked_fn) +export(shared) +export(libshared) +# Re-export: imported from provlib, never defined here. +importFrom(provlib, reexp_fn) +export(reexp_fn) diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/R/functions.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/R/functions.R new file mode 100644 index 000000000..5646550ed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/R/functions.R @@ -0,0 +1,6 @@ +tidy <- function(d) d +decoy_fn <- function(d) d +quoted_fn <- function(d) d +backticked_fn <- function(d) d +shared <- function(d) d +libshared <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/R/internal.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/R/internal.R new file mode 100644 index 000000000..64f260a86 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/locallib/R/internal.R @@ -0,0 +1,3 @@ +# Not exported: reachable only through `:::`. +hidden <- function(d) d +spaced_fn <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/DESCRIPTION new file mode 100644 index 000000000..97c901d44 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/DESCRIPTION @@ -0,0 +1,3 @@ +Package: provlib +Title: Qualified Call Fixture Origin Provider +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/NAMESPACE new file mode 100644 index 000000000..f5f88aa78 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/NAMESPACE @@ -0,0 +1,2 @@ +export(reexp_fn) +export(mutate) diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/R/prov.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/R/prov.R new file mode 100644 index 000000000..df5d2a33e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/provlib/R/prov.R @@ -0,0 +1,3 @@ +reexp_fn <- function(d) d +# Same name as dplyr::mutate, which callers write with an explicit external qualifier. +mutate <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/DESCRIPTION new file mode 100644 index 000000000..63280d4d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/DESCRIPTION @@ -0,0 +1,3 @@ +Package: renamed +Title: Qualified Call Fixture Renamed Directory +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/NAMESPACE new file mode 100644 index 000000000..ef36bc936 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/NAMESPACE @@ -0,0 +1 @@ +export(compute_it) diff --git a/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/R/compute.R b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/R/compute.R new file mode 100644 index 000000000..268baf606 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-qualified-calls/renamed_impl/R/compute.R @@ -0,0 +1 @@ +compute_it <- function(d) d diff --git a/gitnexus/test/integration/resolvers/r-qualified-calls.test.ts b/gitnexus/test/integration/resolvers/r-qualified-calls.test.ts new file mode 100644 index 000000000..fb6019d2e --- /dev/null +++ b/gitnexus/test/integration/resolvers/r-qualified-calls.test.ts @@ -0,0 +1,261 @@ +/** + * R `pkg::fn()` / `pkg:::fn()` qualified calls (fork #7): failing-first characterisation. + * + * Fixtures: `r-qualified-calls/` (package discovery completes: certainly-external + * qualifiers exist) and `r-qualified-calls-truncated/` (discovery stops at depth 3, so a + * package nested deeper is invisible and its locality is UNKNOWN). + * + * Design being pinned (plan step 3): the qualifier names the package. + * - exactly one definition of the name in a local package -> precise binding (0.85); + * - two or more definitions in that package (same-file redefinition or across files) + * -> the site is dropped, no edge (every valid target is inside the named package); + * - certainly external package (discovery complete, not local) -> dropped, no edge; + * - local package with ZERO definitions (re-export), or UNKNOWN locality -> the site is + * kept: today's behaviour, unchanged; + * - the global-name-fallback veto (R4) allows a candidate inside the named package. + * + * Test kinds in this file (all green at this commit; none can pass for the wrong reason): + * - plain `it` behaviour that holds before AND after step 3 (permanent); + * - `it.fails('TARGET ...')` the behaviour step 3 must produce; flip to `it` at step 3; + * - `it('TEMPORARY-PIN ...')` today's actual result for the SAME edge, so the `it.fails` + * above cannot pass merely because the edge is absent or a + * fixture broke. Remove (or invert) at step 3. + * + * Existing tests that flip at step 3 (NOT edited in this commit; re-derived by re-running + * the suite against a temporary spike that implements the design): + * r.test.ts (integration/resolvers) + * :844 'resolves it by the 0.5 global-name-fallback (the qualifier is unused)' + * now ['global-name-fallback:0.5'] -> after ['import-resolved:0.85'] (pkgother::ext_fn, one def) + * :854 'documents current name-only behaviour (fork #7)' (ns_amb_user, pkgother::amb_stage) + * now ['amb_stage:pkgmain/R/a.R:local-call'] -> after ['amb_stage:pkgother/R/e.R:import-resolved'] + * :864 'documents current name-only behaviour (fork #7)' (ns_amb2_user, two packages one def each) + * now callsFrom = [] -> after ['amb_only'] (two edges: pkgother/R/e.R and pkgthird/R/t.R, + * pkgthird has no DESCRIPTION: local through its `pkgthird/R/` path) + * :1181 inside 'records every refusal as fallback-refused ...' (:1177) + * now ['dup_ext', 'mutate', 'mutate'] -> after ['dup_ext', 'mutate'] (the qualified + * legacyscore::mutate() is no longer refused; the bare mutate_user() refusal stays) + * :1186 'documents current name-only behaviour after importFrom() binding (fork #7)' (qualified_user) + * now ['tidy_scores:scorelib/R/s.R:import-resolved:0.85'] (false edge) + * -> after ['tidy_scores:legacyscore/R/l.R:import-resolved:0.85'] + * :1195 'documents current name-only behaviour (fork #7): a correct qualified edge is refused + * by the veto' (:1199 is its expect; qualified_mutate_user) + * now [] -> after ['mutate:legacyscore/R/l.R:import-resolved:0.85'] + * scope-resolution/r/r-namespace-imports.test.ts (unit) + * :976 T11 isRGlobalNameFallbackPlausible for legacyscore::mutate with candidate in legacyscore + * now false -> after true (R4 allows a candidate inside the named package) + * Stay green (verified): r.test.ts :99 'resolves cross-package pkgB::CleanData call' and the + * `pkgB::CleanData` importFrom test (~:1211): one definition, bound by import already. + * No existing fixture expects an edge from a duplicate-definition qualified call. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, type PipelineResult, runPipelineFromRepo } from './helpers.js'; + +const summaries = (result: PipelineResult, sourceName: string, file: string): string[] => + getRelationships(result, 'CALLS') + .filter((e) => e.source === sourceName && e.sourceFilePath === file) + .map((e) => `${e.target}:${e.targetFilePath}:${e.rel.reason}:${e.rel.confidence}`) + .sort(); + +/** One qualified-call caller and the edge set it has today and must have after step 3. */ +interface Case { + readonly title: string; + readonly caller: string; + readonly file: string; + /** Actual edge set at this commit (asserted by the TEMPORARY-PIN test). */ + readonly today: readonly string[]; + /** Edge set the design must produce (asserted by the `it.fails` test). */ + readonly after: readonly string[]; +} + +const L = 'caller/R/local_calls.R'; +const LOCAL = 'locallib/R/functions.R'; + +describe('R qualified calls: discovery completes (r-qualified-calls)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'r-qualified-calls'), () => {}); + }, 60000); + + const cases: readonly Case[] = [ + { + title: 'wrapper self-loop: tidy() forwarding to locallib::tidy() binds to itself', + caller: 'tidy', + file: L, + today: [`tidy:${L}:local-call:0.85`], + after: [`tidy:${LOCAL}:import-resolved:0.85`], + }, + { + title: 'same-file decoy: locallib::decoy_fn() binds to the decoy defined in the caller file', + caller: 'local_user', + file: L, + today: [`decoy_fn:${L}:local-call:0.85`], + after: [`decoy_fn:${LOCAL}:import-resolved:0.85`], + }, + { + title: 'package directory name differs from DESCRIPTION Package: (renamed_impl -> renamed)', + caller: 'renamed_user', + file: L, + today: [`compute_it:${L}:local-call:0.85`], + after: ['compute_it:renamed_impl/R/compute.R:import-resolved:0.85'], + }, + { + title: 'pkg:::name reaches an unexported definition of the named local package', + caller: 'internal_user', + file: L, + today: ['hidden:locallib/R/internal.R:global-name-fallback:0.5'], + after: ['hidden:locallib/R/internal.R:import-resolved:0.85'], + }, + { + title: 'quoted qualifier "locallib"::quoted_fn()', + caller: 'quoted_user', + file: L, + today: [`quoted_fn:${LOCAL}:global-name-fallback:0.5`], + after: [`quoted_fn:${LOCAL}:import-resolved:0.85`], + }, + { + title: 'backticked qualifier `locallib`::backticked_fn()', + caller: 'backticked_user', + file: L, + today: [`backticked_fn:${LOCAL}:global-name-fallback:0.5`], + after: [`backticked_fn:${LOCAL}:import-resolved:0.85`], + }, + { + title: 'spaced qualifier locallib ::: spaced_fn()', + caller: 'spaced_user', + file: L, + today: ['spaced_fn:locallib/R/internal.R:global-name-fallback:0.5'], + after: ['spaced_fn:locallib/R/internal.R:import-resolved:0.85'], + }, + { + title: 'importFrom(altlib, shared) decoy: the qualifier names locallib', + caller: 'importfrom_decoy_user', + file: L, + today: ['shared:altlib/R/alt.R:import-resolved:0.85'], + after: [`shared:${LOCAL}:import-resolved:0.85`], + }, + { + title: 'library(altlib) decoy in a script: the qualifier names locallib', + caller: 'run.R', + file: 'caller/scripts/run.R', + today: ['libshared:altlib/R/alt.R:import-resolved:0.85'], + after: [`libshared:${LOCAL}:import-resolved:0.85`], + }, + { + title: 'duplicate definitions, same-file redefinition in duplib (decoy in the caller file)', + caller: 'dup_same_file_user', + file: 'caller/R/duplicate_calls.R', + today: ['twice:caller/R/duplicate_calls.R:local-call:0.85'], + after: [], + }, + { + title: 'duplicate definitions across two files of duplib (decoy in the caller file)', + caller: 'dup_cross_file_user', + file: 'caller/R/duplicate_calls.R', + today: ['across:caller/R/duplicate_calls.R:local-call:0.85'], + after: [], + }, + { + title: 'certainly external dplyr::filter() with a same-file decoy', + caller: 'external_same_file_user', + file: 'caller/R/external_calls.R', + today: ['filter:caller/R/external_calls.R:local-call:0.85'], + after: [], + }, + { + title: 'certainly external dplyr::mutate() colliding with a definition in another package', + caller: 'external_other_pkg_user', + file: 'caller/R/external_calls.R', + today: ['mutate:provlib/R/prov.R:global-name-fallback:0.5'], + after: [], + }, + { + title: 'certainly external dplyr:::internal_helper() with a same-file decoy', + caller: 'external_internal_user', + file: 'caller/R/external_calls.R', + today: ['internal_helper:caller/R/external_calls.R:local-call:0.85'], + after: [], + }, + ]; + + for (const c of cases) { + describe(c.title, () => { + // TEMPORARY-PIN (remove or invert at step 3): today's actual edges for this caller. + it(`TEMPORARY-PIN ${c.caller}: current edges (qualifier discarded)`, () => { + expect(summaries(result, c.caller, c.file)).toEqual([...c.today]); + }); + + // TARGET (step 3): flip to `it`. Fails today because the qualifier is ignored. + it.fails(`TARGET ${c.caller}: the qualifier decides the edge`, () => { + expect(summaries(result, c.caller, c.file)).toEqual([...c.after]); + }); + }); + } + + describe('local package with zero definitions of the name (re-export): site kept', () => { + // locallib re-exports reexp_fn from provlib. The design keeps the site, and the R4 veto + // must keep allowing a candidate outside the named package when that package defines + // nothing of that name (same allowance the importFrom veto already has). Permanent. + it('keeps the 0.5 fallback edge to the origin package', () => { + expect(summaries(result, 'reexport_user', L)).toEqual([ + 'reexp_fn:provlib/R/prov.R:global-name-fallback:0.5', + ]); + }); + }); + + describe('callers outside the case table', () => { + it('emits no CALLS edge out of the caller package from any other source', () => { + const callers = new Set( + getRelationships(result, 'CALLS') + .filter((e) => e.sourceFilePath.startsWith('caller/')) + .map((e) => e.source), + ); + // Step 3 removes the dropped callers' edges, so only a subset check holds before and after. + const known = new Set([...cases.map((c) => c.caller), 'reexport_user']); + expect([...callers].filter((s) => !known.has(s))).toEqual([]); + }); + }); +}); + +describe('R qualified calls: discovery truncated (r-qualified-calls-truncated)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'r-qualified-calls-truncated'), + () => {}, + ); + }, 60000); + + const F = 'caller/R/calls.R'; + + // Permanent: locality is UNKNOWN, so the sites are kept and today's edges must not change. + it('keeps the same-file edge of a qualifier naming a package invisible to discovery', () => { + // invisiblepkg lives in deep/a/b/c/impl (depth 5, directory name != Package:). Nothing shows + // it is local, and nothing shows it is external: today's scope-chain edge stays. + expect(summaries(result, 'unknown_user', F)).toEqual([`unk_fn:${F}:local-call:0.85`]); + }); + + it('does not treat a qualifier as certainly external while discovery is truncated', () => { + expect(summaries(result, 'truncated_external_user', F)).toEqual([ + `filter:${F}:local-call:0.85`, + ]); + }); + + describe('package without DESCRIPTION whose files sit under /R/', () => { + const PATH_FN = 'deep/a/b/c/pathpkg/R/p.R'; + + // TEMPORARY-PIN (remove or invert at step 3). + it('TEMPORARY-PIN path_local_user: current edge goes to the same-file decoy', () => { + expect(summaries(result, 'path_local_user', F)).toEqual([`path_fn:${F}:local-call:0.85`]); + }); + + // TARGET (step 3): local through the path segment, exactly one definition -> precise. + it.fails('TARGET path_local_user: bound to the pathpkg definition', () => { + expect(summaries(result, 'path_local_user', F)).toEqual([ + `path_fn:${PATH_FN}:import-resolved:0.85`, + ]); + }); + }); +}); diff --git a/gitnexus/test/unit/r-qualified-capture.test.ts b/gitnexus/test/unit/r-qualified-capture.test.ts new file mode 100644 index 000000000..853fca58e --- /dev/null +++ b/gitnexus/test/unit/r-qualified-capture.test.ts @@ -0,0 +1,74 @@ +/** + * R scope query: `@reference.qualified-name` on `pkg::fn()` / `pkg:::fn()` calls (fork #7, R1). + * + * The free-call qualified hook fires only when a site carries `rawQualifiedName`, which the + * scope extractor copies from the `@reference.qualified-name` capture. Cached files cannot be + * re-read, hence the capture (and a SCHEMA_BUMP) rather than re-parsing at resolve time. + * + * Before R1 the query captures the call and its name but never the qualifier, so the + * positive tests are `it.fails` (flip to `it` at step 2/3 when the capture lands) and the + * TEMPORARY-PIN tests assert today's actual captures for the same sources (they stop an + * `it.fails` from passing because a form failed to parse, or lost its name capture). + */ +import { describe, expect, it } from 'vitest'; +import { emitRScopeCaptures } from '../../src/core/ingestion/languages/r/captures.js'; + +type Tags = Record; + +function callMatches(source: string): Tags[] { + return emitRScopeCaptures(source, 'fixture.R') + .map((m) => Object.fromEntries(Object.entries(m).map(([tag, cap]) => [tag, cap.text])) as Tags) + .filter((t) => t['@reference.call.free'] !== undefined && t['@reference.name'] !== undefined); +} + +/** The single `@reference.name === name` call match in `source`. */ +function only(source: string, name: string): Tags { + const found = callMatches(source).filter((t) => t['@reference.name'] === name); + if (found.length !== 1) throw new Error(`expected one match for ${name}, got ${found.length}`); + return found[0]; +} + +const FORMS: readonly { + readonly label: string; + readonly source: string; + readonly written: string; +}[] = [ + { label: 'pkg::f', source: 'run <- function(d) pkg::f(d)', written: 'pkg::f' }, + { label: 'pkg:::f', source: 'run <- function(d) pkg:::f(d)', written: 'pkg:::f' }, + { label: 'spaced pkg ::: f', source: 'run <- function(d) pkg ::: f(d)', written: 'pkg ::: f' }, + { label: 'spaced pkg :: f', source: 'run <- function(d) pkg :: f(d)', written: 'pkg :: f' }, + { label: 'quoted "pkg"::f', source: 'run <- function(d) "pkg"::f(d)', written: '"pkg"::f' }, + { label: "quoted 'pkg'::f", source: "run <- function(d) 'pkg'::f(d)", written: "'pkg'::f" }, + { label: 'backticked `pkg`::f', source: 'run <- function(d) `pkg`::f(d)', written: '`pkg`::f' }, +]; + +describe('R query: qualified-name capture on namespace_operator calls', () => { + for (const form of FORMS) { + describe(form.label, () => { + // TEMPORARY-PIN (remove at step 2): the call and its name are captured today, the + // qualifier is not. + it('TEMPORARY-PIN: captures the call and its name but no qualified name', () => { + const t = only(form.source, 'f'); + expect(t['@reference.name']).toBe('f'); + expect(t['@reference.call.free']).toBe(form.written + '(d)'); + expect(t['@reference.qualified-name']).toBeUndefined(); + }); + + // TARGET (R1): flip to `it`. + it.fails('captures the whole namespace_operator text as @reference.qualified-name', () => { + expect(only(form.source, 'f')['@reference.qualified-name']).toBe(form.written); + }); + }); + } + + describe('unqualified and non-namespace calls carry no qualified name (stay green after R1)', () => { + it('a bare call f(x)', () => { + const t = only('run <- function(d) f(d)', 'f'); + expect(t['@reference.qualified-name']).toBeUndefined(); + }); + + it('a member call obj$f(x) is not a free call', () => { + expect(callMatches('run <- function(o) o$f(1)')).toEqual([]); + }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/r/r-qualified-call.test.ts b/gitnexus/test/unit/scope-resolution/r/r-qualified-call.test.ts new file mode 100644 index 000000000..21559e308 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/r/r-qualified-call.test.ts @@ -0,0 +1,207 @@ +/** + * R qualified-call helpers (fork #7, plan step 3): + * + * - `parseRQualifier(raw)`: the package named by a `@reference.qualified-name` text; + * - `rQualifierLocality(pkg, cfg, filePaths)`: `local` | `external` | `unknown`; + * - `RPackageConfig.truncated`: set by `loadRPackageConfig` when its depth-3 / 200-directory + * walk stopped with directories unvisited. + * + * The helpers live in `languages/r/qualified-call.ts`, which does not exist at this commit. + * Decision: the helper tests are `it.fails`, importing the module through a non-literal + * dynamic specifier. Today the import rejects, so each test fails and `it.fails` passes; at + * step 3 the module exists, the tests pass, `it.fails` turns red, and that forces the flip to + * `it`. (`describe.skip` would keep the suite green too, but nothing would ever prompt the + * un-skip; a literal `import` of the missing file would break the whole file at transform time.) + * The assertions themselves were checked against a temporary spike implementation. + * TODO step 3: change `it.fails` to `it`, delete the TEMPORARY-PIN, make the import static. + */ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import type { RPackageConfig } from '../../../../src/core/ingestion/languages/r/package-config.js'; +import { loadRPackageConfig } from '../../../../src/core/ingestion/languages/r/package-config.js'; + +// Non-literal specifier: vite must not resolve it at transform time (the file does not exist yet). +const QUALIFIED_CALL_MODULE = '../../../../src/core/ingestion/languages/r/qualified-call.js'; + +type Locality = 'local' | 'external' | 'unknown'; +interface QualifiedCallModule { + parseRQualifier(raw: string): string | undefined; + rQualifierLocality( + pkg: string, + cfg: RPackageConfig | null, + filePaths: ReadonlySet, + ): Locality; +} +const load = (): Promise => import(/* @vite-ignore */ QUALIFIED_CALL_MODULE); + +const FIXTURES = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution'); + +function config(packages: Record, truncated?: boolean): RPackageConfig { + const cfg: RPackageConfig = { + packages: new Map(Object.entries(packages)), + namespaceInfoByPackageDir: new Map(), + }; + // `truncated` is added to RPackageConfig at step 3; the cast keeps this file compiling now. + return truncated === undefined ? cfg : Object.assign(cfg, { truncated }); +} + +describe('parseRQualifier', () => { + const cases: readonly (readonly [raw: string, expected: string | undefined])[] = [ + ['pkg::f', 'pkg'], + ['pkg:::f', 'pkg'], + ['pkg ::: f', 'pkg'], + ['pkg :: f', 'pkg'], + ['"pkg"::f', 'pkg'], + ["'pkg'::f", 'pkg'], + ['`pkg`::f', 'pkg'], + ['my.pkg::f', 'my.pkg'], + ['not a qualifier', undefined], + ['::f', undefined], + ]; + + // TEMPORARY-PIN (remove at step 3): the module is absent, so the failures above are the + // intended "not implemented yet" and not a typo in the specifier. + it('TEMPORARY-PIN: languages/r/qualified-call.ts does not exist yet', async () => { + await expect(load()).rejects.toThrow(); + }); + + for (const [raw, expected] of cases) { + it.fails(`TODO step 3: ${JSON.stringify(raw)} -> ${JSON.stringify(expected)}`, async () => { + expect((await load()).parseRQualifier(raw)).toBe(expected); + }); + } +}); + +describe('rQualifierLocality', () => { + const paths = (...p: string[]): ReadonlySet => new Set(p); + + const cases: readonly { + readonly title: string; + readonly pkg: string; + readonly cfg: RPackageConfig | null; + readonly files: ReadonlySet; + readonly expected: Locality; + }[] = [ + { + title: 'a package in the config is local', + pkg: 'renamed', + cfg: config({ renamed: 'renamed_impl' }), + files: paths('renamed_impl/R/a.R'), + expected: 'local', + }, + { + title: 'a package in the config is local even when its discovery was truncated', + pkg: 'pkg', + cfg: config({ pkg: 'pkg' }, true), + files: paths('pkg/R/a.R'), + expected: 'local', + }, + { + title: 'a root-level package is local through the config', + pkg: 'rootpkg', + cfg: config({ rootpkg: '' }), + files: paths('R/a.R'), + expected: 'local', + }, + { + title: 'not in the config but a parsed file sits under /R/ (no DESCRIPTION)', + pkg: 'pkgthird', + cfg: config({ pkgmain: 'pkgmain' }), + files: paths('pkgmain/R/a.R', 'pkgthird/R/t.R'), + expected: 'local', + }, + { + title: 'the /R/ segment may be nested below the repo root', + pkg: 'pathpkg', + cfg: config({ caller: 'caller' }, true), + files: paths('caller/R/a.R', 'deep/a/b/c/pathpkg/R/p.R'), + expected: 'local', + }, + { + title: 'a path that merely contains the name is not a package directory', + pkg: 'path', + cfg: config({ caller: 'caller' }), + files: paths('caller/R/a.R', 'deep/pathpkg/R/p.R', 'x/path/scripts/s.R'), + expected: 'external', + }, + { + title: 'not local and discovery completed: certainly external', + pkg: 'dplyr', + cfg: config({ caller: 'caller' }, false), + files: paths('caller/R/a.R'), + expected: 'external', + }, + { + title: 'not local and the truncated flag is absent (complete discovery): external', + pkg: 'dplyr', + cfg: config({ caller: 'caller' }), + files: paths('caller/R/a.R'), + expected: 'external', + }, + { + title: 'not local but discovery was truncated: unknown', + pkg: 'invisiblepkg', + cfg: config({ caller: 'caller' }, true), + files: paths('caller/R/a.R', 'deep/a/b/c/impl/R/x.R'), + expected: 'unknown', + }, + { + title: 'no package config could be built: unknown', + pkg: 'dplyr', + cfg: null, + files: paths('R/a.R'), + expected: 'unknown', + }, + ]; + + for (const c of cases) { + it.fails(`TODO step 3: ${c.title}`, async () => { + expect((await load()).rQualifierLocality(c.pkg, c.cfg, c.files)).toBe(c.expected); + }); + } +}); + +describe('loadRPackageConfig: discovery completeness on the qualified-call fixtures', () => { + const load2 = (fixture: string) => loadRPackageConfig(path.join(FIXTURES, fixture)); + + // Permanent geometry checks: the fixtures really are shaped as the integration tests assume. + it('finds every package of r-qualified-calls, including a directory named differently', async () => { + const cfg = await load2('r-qualified-calls'); + expect([...(cfg?.packages ?? [])].sort()).toEqual([ + ['altlib', 'altlib'], + ['caller', 'caller'], + ['duplib', 'duplib'], + ['locallib', 'locallib'], + ['provlib', 'provlib'], + ['renamed', 'renamed_impl'], + ]); + }); + + it('does not find the packages nested below depth 3 in r-qualified-calls-truncated', async () => { + const cfg = await load2('r-qualified-calls-truncated'); + expect([...(cfg?.packages ?? [])]).toEqual([['caller', 'caller']]); + }); + + // TEMPORARY-PIN (remove at step 3): the flag does not exist yet. + it('TEMPORARY-PIN: RPackageConfig carries no truncated flag today', async () => { + for (const fixture of ['r-qualified-calls', 'r-qualified-calls-truncated']) { + const cfg = await load2(fixture); + expect((cfg as { truncated?: boolean } | null)?.truncated).toBeUndefined(); + } + }); + + it.fails('TODO step 3: truncated is false when the walk completed', async () => { + expect(((await load2('r-qualified-calls')) as { truncated?: boolean } | null)?.truncated).toBe( + false, + ); + }); + + it.fails( + 'TODO step 3: truncated is true when a directory below depth 3 was skipped', + async () => { + expect( + ((await load2('r-qualified-calls-truncated')) as { truncated?: boolean } | null)?.truncated, + ).toBe(true); + }, + ); +});