fix(r): a test file's top-level variables stay in that file (#2144)

testthat runs each test file in an environment of its own, so a variable a
test assigns is invisible to the package and to other tests. ggplot2's
`c <- ggplot(…)` in test-facet-grid-.R was the one node named `c`, and took
2,455 of the package's `c(…)` calls (the sweep's top-depended symbol).

isVisibleAcrossFiles rejects an R variable/constant under `tests/` from
another file — except `helper-*.R` / `setup-*.R`, which testthat sources for
every test. (A first cut that limited every R call to function nodes lost
ggplot2's factory-defined `geom_point <- make_constructor(…)`, dplyr's
`summarize <- summarise` and shiny's aliases.)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 09:09:12 +00:00
committed by GitHub
parent 7ac8eefb85
commit 422db1866e
3 changed files with 63 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In R, a variable assigned at the top of a test file is no longer linked from the package or from other tests, because testthat runs each test file in its own environment. On ggplot2, a test's `c <- ggplot(…)` used to collect 2,455 of the package's `c(…)` calls. Variables in `helper-*.R` and `setup-*.R`, which testthat loads for every test, are still shared.
- In Lua and Luau, a `local` variable or function is no longer linked from other files, since it's only visible in the file that declares it. On kong, a spec helper's `local it = it` used to collect every other spec file's `it(…)` (over 4,000 links), and one module's `local ipairs = ipairs` took the whole project's `ipairs(…)`. Global functions are still linked across files.
- PHP calls on `$this`, `self`, `static` and `parent` now reach the method of the class they're written in, a class it extends, or a trait any of them uses, with parent classes and traits found through the file's `namespace` and `use` imports. On Drupal core, `$this->assertEquals()` (PHPUnit's) used to link to an unrelated comparator class 8,832 times, `$this->assertSession()` to a JavaScript-test base class, and `$this->t()` to `Views::t`. They now reach `UiHelperTrait` and `StringTranslationTrait`, or nothing when the method lives in a package outside the repository. A call inside a trait, and a base class calling a method its subclass defines, keep their links. Re-index PHP projects after upgrading.
- In PHP, CFML, Pascal, COBOL and VB.NET, a call written in a different case than its definition now resolves, like `formatprice()` for `FormatPrice` or CFML's `ToMatchWithCase()` for `toMatchWithCase`. These languages ignore case in names, but such calls used to be dropped before resolution.
+57
View File
@@ -0,0 +1,57 @@
/**
* testthat runs each test file in an environment of its own: a variable one
* test assigns is invisible to the package and to other tests. ggplot2's
* `c <- ggplot(…)` in one test took the package's 2,455 `c(…)` calls. A
* `helper-*.R` file is sourced for every test, so its bindings stay shared,
* and a package function defined through a factory
* (`geom_point <- make_constructor(…)`) stays callable.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});
describe('R test-file bindings', () => {
it('stay in their file, except helpers; package bindings stay callable', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-r-tests-'));
roots.push(root);
const files: Record<string, string> = {
DESCRIPTION: 'Package: demo\n',
'R/geom-point.R': `geom_point <- make_constructor(GeomPoint)
table_of <- function() {
c("a", "b")
}
`,
'tests/testthat/helper-s3.R': `quux <- structure(list(), class = "quux")
`,
'tests/testthat/test-scales.R': `c <- ggplot(df, aes(x, y))
test_that("works", {
geom_point()
quux()
})
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
const cg = await CodeGraph.init(root, { index: true });
try {
const targets = (file: string) =>
cg
.getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id), ['calls'])
.map((e) => `${cg.getNode(e.target)!.name}@${cg.getNode(e.target)!.filePath}`)
.sort();
expect(targets('R/geom-point.R')).toEqual([]);
expect(targets('tests/testthat/test-scales.R')).toEqual(['geom_point@R/geom-point.R', 'quux@tests/testthat/helper-s3.R']);
} finally {
cg.close();
}
});
});
+5
View File
@@ -944,6 +944,11 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex
return ref.filePath.startsWith(owner + '/');
}
if (PRIVATE_IS_FILE_LOCAL.has(lang)) return candidate.visibility !== 'private';
// An R test file runs in an environment of its own (testthat): its top-level
// `c <- ggplot(…)` is not what the package's 2,843 `c(…)` calls mean. The
// `helper-*.R` / `setup-*.R` files are sourced for every test, so theirs are shared.
if (lang === 'r' && (candidate.kind === 'variable' || candidate.kind === 'constant') &&
/(?:^|\/)tests?\//.test(candidate.filePath) && !/(?:^|\/)(?:helper|setup)[^/]*\.[rR]$/.test(candidate.filePath)) return false;
// A Lua `local` belongs to its chunk: kong's spec helpers' `local it = it`
// took busted's `it(…)` in every other spec file, 4,166 times.
if ((lang === 'lua' || lang === 'luau') && isLuaLocal(candidate, context)) return false;