From e9be50073997a5393bc8e26d34fa1ec402829983 Mon Sep 17 00:00:00 2001 From: Mick Cooney Date: Tue, 29 Sep 2026 17:23:35 +0100 Subject: [PATCH] test(r): native pipe chain fixtures and resolution tests --- .../r-native-pipes/pkgmain/DESCRIPTION | 3 + .../r-native-pipes/pkgmain/NAMESPACE | 1 + .../r-native-pipes/pkgmain/R/a.R | 54 +++++ .../r-native-pipes/pkgmain/R/b.R | 19 ++ .../r-native-pipes/pkgmain/R/decoy_first.R | 5 + .../r-native-pipes/pkgmain/R/nested.R | 9 + .../r-native-pipes/pkgother/DESCRIPTION | 3 + .../r-native-pipes/pkgother/R/e.R | 3 + .../r-native-pipes/pkgthird/DESCRIPTION | 3 + .../r-native-pipes/pkgthird/R/t.R | 1 + gitnexus/test/integration/resolvers/r.test.ts | 227 ++++++++++++++++++ 11 files changed, 328 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/a.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/b.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/decoy_first.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/nested.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgother/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgother/R/e.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgthird/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgthird/R/t.R diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/DESCRIPTION new file mode 100644 index 000000000..480a062f1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/DESCRIPTION @@ -0,0 +1,3 @@ +Package: pkgmain +Title: Native Pipe Fixture Main +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/NAMESPACE b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/NAMESPACE new file mode 100644 index 000000000..e31a0772b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/NAMESPACE @@ -0,0 +1 @@ +export(stage_one) diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/a.R b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/a.R new file mode 100644 index 000000000..99b168a3b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/a.R @@ -0,0 +1,54 @@ +stage_one <- function(d) d +stage_two <- function(d, k) d +stage_three <- function(d, data) d +uniq_helper <- function(d) d +amb_stage <- function(d) d + +# Module-level pipe: sourced from the File node. +piped_top <- c(1, 2) |> stage_one() |> stage_two(3) + +# Placeholder argument: `_` is not a call and produces no edge. +placeholder_user <- function(df) { + df |> stage_three(data = _) +} + +# Lambda stage: the call inside the lambda belongs to the enclosing function. +lambda_user <- function(df) { + df |> (\(d) uniq_helper(d))() +} + +# Namespaced stage, unique name in another package (qualifier is unused). +ns_user <- function(df) { + df |> pkgother::ext_fn() |> stage_one() +} + +# Namespaced stage whose name is also defined in THIS file (fork #7). +ns_amb_user <- function(df) { + df |> pkgother::amb_stage() +} + +# Multi-line chain, leading `|>` on continuation lines, builtin `paste` stage. +multiline_user <- function(df) { + df |> + stage_one() |> + stage_two(2) |> + paste("x") |> + stage_three(data = _) +} + +# R6 method chain ending in a self$ member stage. +Cls <- R6::R6Class("Cls", public = list( + run = function(df) { + df |> stage_one() |> self$post() + }, + post = function(d) d +)) + +plain_user <- function(df) { + uniq_helper(df) +} + +# Member stage on an object of unknown type. +member_user <- function(obj, df) { + df |> obj$method(1) |> stage_one() +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/b.R b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/b.R new file mode 100644 index 000000000..3da755bde --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/b.R @@ -0,0 +1,19 @@ +Type <- R6::R6Class("Type", public = list( + score = function(d) d +)) + +# Member stage on a typed local (constructed via Type$new()). +typed_user <- function(df) { + obj <- Type$new() + df |> obj$score() |> stage_one() +} + +# Backticked stage with no definition anywhere. +bt_user <- function(df) { + df |> `my fn`() |> stage_two(1) +} + +# Same name defined in both other packages (fork #7). +ns_amb2_user <- function(df) { + df |> pkgother::amb_only() |> pkgthird::amb_only() +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/decoy_first.R b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/decoy_first.R new file mode 100644 index 000000000..5a8b15ffb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/decoy_first.R @@ -0,0 +1,5 @@ +# First function of the package in load order: a decoy that must own only its +# own pipe edge, never the edges of pipes written in later functions/files. +decoy_first <- function(d) { + d |> uniq_helper() +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/nested.R b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/nested.R new file mode 100644 index 000000000..3eae29c7d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/R/nested.R @@ -0,0 +1,9 @@ +# Nested function sharing a name with a later top-level function (plan O1). +nested_outer <- function(d) { + nest_fn <- function(x) x + d |> nest_fn() |> stage_two(1) +} +nest_fn <- function(x) x +nested_caller <- function(d) { + d |> nest_fn() +} diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgother/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgother/DESCRIPTION new file mode 100644 index 000000000..6c456d81d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgother/DESCRIPTION @@ -0,0 +1,3 @@ +Package: pkgother +Title: Native Pipe Fixture Other +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgother/R/e.R b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgother/R/e.R new file mode 100644 index 000000000..d7a598953 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgother/R/e.R @@ -0,0 +1,3 @@ +ext_fn <- function(d) d +amb_stage <- function(d) d +amb_only <- function(d) d diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgthird/DESCRIPTION b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgthird/DESCRIPTION new file mode 100644 index 000000000..2f8199363 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgthird/DESCRIPTION @@ -0,0 +1,3 @@ +Package: pkgthird +Title: Native Pipe Fixture Third +Version: 0.1.0 diff --git a/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgthird/R/t.R b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgthird/R/t.R new file mode 100644 index 000000000..6740c0f23 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgthird/R/t.R @@ -0,0 +1 @@ +amb_only <- function(d) d diff --git a/gitnexus/test/integration/resolvers/r.test.ts b/gitnexus/test/integration/resolvers/r.test.ts index 002a2f92f..3cd929af6 100644 --- a/gitnexus/test/integration/resolvers/r.test.ts +++ b/gitnexus/test/integration/resolvers/r.test.ts @@ -671,3 +671,230 @@ describe('R caller attribution', () => { } }); }); + +/** + * R native pipe (`|>`) chains: baseline characterisation at the landed + * caller-attribution fix. Every assertion here passes without any pipe-specific + * production support; the tests lock the current behaviour before NAMESPACE / + * importFrom resolution work touches the resolver. + * The fixture is synthetic (`r-native-pipes/`, three packages); one function per + * scenario so each assertion is an exact `source -> targets` set. + * magrittr `%>%` is deliberately NOT covered (no support claimed either way). + * Two tests pin known-wrong name-only results for `pkg::fn` stages (fork #7). + */ +describe('R native pipe chains', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'r-native-pipes'), () => {}); + }, 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(); + + /** Sorted `Label:name` of every CALLS source in the file ending `fileSuffix` that targets `target`. */ + const sourceKeys = (fileSuffix: string, target: string): string[] => + getRelationships(result, 'CALLS') + .filter((e) => e.sourceFilePath.endsWith(fileSuffix) && e.target === target) + .map((e) => `${e.sourceLabel}:${e.source}`) + .sort(); + + const A = 'pkgmain/R/a.R'; + const B = 'pkgmain/R/b.R'; + + // ─── module-level chain ───────────────────────────────────────────────── + describe('module-level chain', () => { + it('sources every stage of a top-level chain from the File node', () => { + expect(callsFrom('a.R', A)).toEqual(['stage_one', 'stage_two']); + }); + + it('labels the top-level chain source as File for stage_one', () => { + expect(sourceKeys(A, 'stage_one').filter((k) => k.startsWith('File:'))).toEqual(['File:a.R']); + }); + + it('labels the top-level chain source as File for stage_two', () => { + expect(sourceKeys(A, 'stage_two').filter((k) => k.startsWith('File:'))).toEqual(['File:a.R']); + }); + + it('resolves the top-level stages as local calls at 0.85', () => { + expect( + callEdges('a.R', A) + .map((e) => `${e.target}:${e.rel.reason}:${e.rel.confidence}`) + .sort(), + ).toEqual(['stage_one:local-call:0.85', 'stage_two:local-call:0.85']); + }); + }); + + // ─── multi-line chain ─────────────────────────────────────────────────── + describe('multi-line chain with a builtin stage', () => { + it('resolves every user-defined stage of a five-stage chain', () => { + expect(callsFrom('multiline_user', A)).toEqual(['stage_one', 'stage_three', 'stage_two']); + }); + + it('emits no edge for the builtin-named paste stage', () => { + expect(callsFrom('multiline_user', A).includes('paste')).toBe(false); + }); + + it('emits no CALLS edge to a paste target anywhere', () => { + expect(getRelationships(result, 'CALLS').filter((e) => e.target === 'paste')).toEqual([]); + }); + }); + + // ─── placeholder ──────────────────────────────────────────────────────── + describe('placeholder argument', () => { + it('resolves only the stage that takes the placeholder', () => { + expect(callsFrom('placeholder_user', A)).toEqual(['stage_three']); + }); + + it('emits no CALLS edge to the placeholder `_`', () => { + expect(getRelationships(result, 'CALLS').filter((e) => e.target === '_')).toEqual([]); + }); + }); + + // ─── lambda stage ─────────────────────────────────────────────────────── + describe('lambda stage', () => { + it('sources the lambda body call from the enclosing function', () => { + expect(callsFrom('lambda_user', A)).toEqual(['uniq_helper']); + }); + + it('resolves the lambda body call as a 0.85 local call', () => { + expect(callEdges('lambda_user', A).map((e) => `${e.rel.reason}:${e.rel.confidence}`)).toEqual( + ['local-call:0.85'], + ); + }); + }); + + // ─── attribution across functions ─────────────────────────────────────── + describe('two pipe functions in one package', () => { + it('gives the decoy first function only its own pipe edge', () => { + expect(callsFrom('decoy_first', 'pkgmain/R/decoy_first.R')).toEqual(['uniq_helper']); + }); + + it('does not source later pipes from the decoy first function', () => { + expect( + sourceKeys('pkgmain/R/decoy_first.R', 'stage_one').concat( + sourceKeys('pkgmain/R/decoy_first.R', 'stage_three'), + ), + ).toEqual([]); + }); + + it('keeps a plain call and a lambda-stage call in separate sources', () => { + expect(sourceKeys(A, 'uniq_helper')).toEqual(['Function:lambda_user', 'Function:plain_user']); + }); + }); + + // ─── R6 method chain ──────────────────────────────────────────────────── + describe('R6 method chain', () => { + it('sources the chain from the Method and calls exactly the stage and self$post', () => { + expect(callsFrom('run', A)).toEqual(['post', 'stage_one']); + }); + + it('targets a Method for the self$post stage', () => { + expect(callEdges('run', A).find((e) => e.target === 'post')?.targetLabel).toBe('Method'); + }); + + it('targets a Function for the stage_one stage', () => { + expect(callEdges('run', A).find((e) => e.target === 'stage_one')?.targetLabel).toBe( + 'Function', + ); + }); + + it('labels the chain source as Method:run', () => { + expect(sourceKeys(A, 'post')).toEqual(['Method:run']); + }); + }); + + // ─── member stages ────────────────────────────────────────────────────── + describe('member stage on a typed object', () => { + it('resolves obj$score() and the following stage', () => { + expect(callsFrom('typed_user', B)).toEqual(['score', 'stage_one']); + }); + + it('targets a Method for the typed member stage', () => { + expect(callEdges('typed_user', B).find((e) => e.target === 'score')?.targetLabel).toBe( + 'Method', + ); + }); + }); + + describe('member stage on an untyped object', () => { + it('emits no edge for the untyped member stage and resolves the next stage', () => { + expect(callsFrom('member_user', A)).toEqual(['stage_one']); + }); + }); + + // ─── namespaced stages ────────────────────────────────────────────────── + describe('namespaced stage with a unique name in another package', () => { + it('resolves pkgother::ext_fn() and the following stage', () => { + expect(callsFrom('ns_user', A)).toEqual(['ext_fn', 'stage_one']); + }); + + it('resolves the namespaced stage into the other package', () => { + expect(callEdges('ns_user', A).find((e) => e.target === 'ext_fn')?.targetFilePath).toBe( + 'pkgother/R/e.R', + ); + }); + + it('resolves it by the 0.5 global-name-fallback (the qualifier is unused)', () => { + expect( + callEdges('ns_user', A) + .filter((e) => e.target === 'ext_fn') + .map((e) => `${e.rel.reason}:${e.rel.confidence}`), + ).toEqual(['global-name-fallback:0.5']); + }); + }); + + describe('namespaced stage whose name is also defined in the calling file', () => { + it('documents current name-only behaviour (fork #7)', () => { + // `pkgother::amb_stage()` is qualified, yet the edge goes to the LOCAL definition + // (0.85 local-call): the qualifier is discarded. A false edge; fork #7 should flip this. + expect( + callEdges('ns_amb_user', A).map((e) => `${e.target}:${e.targetFilePath}:${e.rel.reason}`), + ).toEqual(['amb_stage:pkgmain/R/a.R:local-call']); + }); + }); + + describe('namespaced stages whose name is defined in two other packages', () => { + it('documents current name-only behaviour (fork #7)', () => { + // `pkgother::amb_only() |> pkgthird::amb_only()`: ambiguous by name alone, so no edge + // even though each qualifier names exactly one definition. Fork #7 should flip this. + expect(callsFrom('ns_amb2_user', B)).toEqual([]); + }); + }); + + // ─── backticked stage ─────────────────────────────────────────────────── + describe('backticked stage with no definition', () => { + it('emits no edge for it and still resolves the following stage', () => { + expect(callsFrom('bt_user', B)).toEqual(['stage_two']); + }); + }); + + // ─── O1: nested function sharing a name with a later top-level function ─ + describe('nested function sharing a name with a top-level function (plan O1)', () => { + const N = 'pkgmain/R/nested.R'; + const targetStartLine = (fn: string): number | undefined => { + const edge = callEdges(fn, N).find((e) => e.target === 'nest_fn'); + return edge ? result.graph.getNode(edge.rel.targetId)?.properties.startLine : undefined; + }; + + it('resolves a pipe stage to the same-named nested function from the enclosing function', () => { + expect(callsFrom('nested_outer', N)).toEqual(['nest_fn', 'stage_two']); + }); + + it('resolves the same name from another function to the top-level definition', () => { + expect(callsFrom('nested_caller', N)).toEqual(['nest_fn']); + }); + + it('binds the enclosing function to the nested definition, not the top-level one', () => { + expect(targetStartLine('nested_outer') ?? Infinity).toBeLessThan( + targetStartLine('nested_caller') ?? -Infinity, + ); + }); + }); +});