test(r): characterise qualified-call resolution and pin the pkg:: decoy cases

Add the r-qualified-calls and r-qualified-calls-truncated fixtures and failing-first
tests for qualifier-aware calls: the qualifier of pkg::fn() / pkg:::fn() decides the edge
(precise binding into a local package, no edge for duplicate definitions or certainly
external packages, sites kept when locality is unknown).

New expectations that encode not-yet-true behaviour are it.fails; TEMPORARY-PIN tests
assert today's edges for the same callers so an it.fails cannot pass for the wrong
reason. Existing tests are untouched; the seven that flip are listed in the header of
r-qualified-calls.test.ts.
This commit is contained in:
Mick Cooney 2026-09-30 13:10:03 +01:00
parent 1e3b680a36
commit d347c14296
32 changed files with 725 additions and 0 deletions

View file

@ -0,0 +1,3 @@
Package: caller
Title: Qualified Call Fixture Truncated Discovery
Version: 0.1.0

View file

@ -0,0 +1 @@
export(unknown_user)

View file

@ -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

View file

@ -0,0 +1,3 @@
Package: invisiblepkg
Title: Package Invisible To Discovery
Version: 0.1.0

View file

@ -0,0 +1 @@
unk_fn <- function(d) d

View file

@ -0,0 +1 @@
path_fn <- function(d) d

View file

@ -0,0 +1,3 @@
Package: altlib
Title: Qualified Call Fixture Decoy Provider
Version: 0.1.0

View file

@ -0,0 +1,2 @@
export(shared)
export(libshared)

View file

@ -0,0 +1,2 @@
shared <- function(d) d
libshared <- function(d) d

View file

@ -0,0 +1,3 @@
Package: caller
Title: Qualified Call Fixture Caller
Version: 0.1.0

View file

@ -0,0 +1,3 @@
export(local_user)
# Decoy: names altlib, but the calls below are explicitly qualified with locallib.
importFrom(altlib, shared)

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)

View file

@ -0,0 +1,3 @@
Package: duplib
Title: Qualified Call Fixture Duplicate Definitions
Version: 0.1.0

View file

@ -0,0 +1,2 @@
export(twice)
export(across)

View file

@ -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

View file

@ -0,0 +1,2 @@
# A second definition of `across` in another file of the same package.
across <- function(d) d * 2

View file

@ -0,0 +1,3 @@
Package: locallib
Title: Qualified Call Fixture Local Provider
Version: 0.1.0

View file

@ -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)

View file

@ -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

View file

@ -0,0 +1,3 @@
# Not exported: reachable only through `:::`.
hidden <- function(d) d
spaced_fn <- function(d) d

View file

@ -0,0 +1,3 @@
Package: provlib
Title: Qualified Call Fixture Origin Provider
Version: 0.1.0

View file

@ -0,0 +1,2 @@
export(reexp_fn)
export(mutate)

View file

@ -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

View file

@ -0,0 +1,3 @@
Package: renamed
Title: Qualified Call Fixture Renamed Directory
Version: 0.1.0

View file

@ -0,0 +1 @@
export(compute_it)

View file

@ -0,0 +1 @@
compute_it <- function(d) d

View file

@ -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 <pkg>/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`,
]);
});
});
});

View file

@ -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<string, string>;
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([]);
});
});
});

View file

@ -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<string>,
): Locality;
}
const load = (): Promise<QualifiedCallModule> => import(/* @vite-ignore */ QUALIFIED_CALL_MODULE);
const FIXTURES = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution');
function config(packages: Record<string, string>, 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<string> => new Set(p);
const cases: readonly {
readonly title: string;
readonly pkg: string;
readonly cfg: RPackageConfig | null;
readonly files: ReadonlySet<string>;
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 <pkg>/R/ (no DESCRIPTION)',
pkg: 'pkgthird',
cfg: config({ pkgmain: 'pkgmain' }),
files: paths('pkgmain/R/a.R', 'pkgthird/R/t.R'),
expected: 'local',
},
{
title: 'the <pkg>/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);
},
);
});