feat(r): add conservative semantic rename provider

Plain R functions rename through the scope graph; S3, string-spelled,
S4, R6 and NAMESPACE-exported names are refused. Adds fixtures, the
rename-provider-r test and its cross-platform shard entries.
This commit is contained in:
Mick Cooney 2026-10-15 15:42:35 +01:00
parent 56d697256c
commit 3dbf1017ce
45 changed files with 378 additions and 0 deletions

View file

@ -111,6 +111,20 @@ Not modelled:
- `stop()`, `on.exit()`, `tryCatch` handlers and other non-local exits are ordinary calls: no throw edges and no finaliser routing. A `break` / `next` with no enclosing loop falls back to EXIT.
- Function join: a CFG is anchored at the `function_definition` token, while the `Function` node starts at the whole assignment. They share a start line for `f <- function`, `f = function` and `f <- \(x)`, but not for `f <-` followed by `function` on the next line; there the CFG is still emitted and its blocks lie inside the `Function` node's span, but the exact start-line join used by taint summaries does not resolve (inert for R today, since reaching definitions have no facts). Anonymous functions have a CFG but no `Function` node.
## Rename
`languages/r/rename.ts` gives the `rename` tool a deliberately narrow, fail-closed R provider (fixtures under `test/fixtures/rename-integration/r/`). R binds names dynamically, so only what the scope graph proves is renamed.
- **Succeeds:** a plain function (`name <- function(...)`) and the calls the graph resolves to it, in one file or across files. A same-named parameter is claimed as a binding and left alone.
- **Refused (`unsupported_occurrence`, planning status `unsupported`):**
- any target that is not a plain `Function` (S4 classes, R6 classes, members);
- a function whose old or new name is spelled as a string literal anywhere in the indexed R sources (`UseMethod("area")`, `do.call("f", ...)`, `match.fun`, `get`, `setGeneric`, ...), because string lookup is not resolved;
- a new name `generic.class` whose prefix is an existing function, which would register an S3 method;
- a package function under `R/` that `NAMESPACE` does not mark private (`isExported` is not false), because `NAMESPACE` is not an indexed source and its `export()` or `S3method()` entry would go stale.
- **Refused by the shared engine (fail-closed, not provider code):** S3 methods and any dotted function name (`area.circle` reads as an owner-qualified member, so `stale_graph`); S4 class names (`unsupported_occurrence`, no exact source-name span); R6 methods (`stale_graph`).
- **Invalid names (`invalid_name`):** anything that is not a nonreserved R identifier.
- **Not proven:** unqualified calls to a function defined in another file (including the package `R/` layout) fail with `ambiguous_reference`, because R's global-name fallback is not a provable binding. Renaming across files works only where the call is qualified or otherwise resolved.
## Type annotations
R has no inline type annotations, so the "Type Annotations" column of the language support table is not ticked for R. The roxygen2 `@param` extraction above is a comment-based fallback, in the same class as the JSDoc, PHPDoc and YARD fallbacks listed in type-resolution-system.md (Ruby's YARD support is likewise not ticked).

View file

@ -62,6 +62,7 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
'test/integration/rename-provider-objective-c.test.ts': 285,
'test/integration/rename-provider-php.test.ts': 100,
'test/integration/rename-provider-python.test.ts': 375,
'test/integration/rename-provider-r.test.ts': 100,
'test/integration/rename-provider-ruby.test.ts': 100,
'test/integration/rename-provider-rust.test.ts': 75,
'test/integration/rename-provider-swift.test.ts': 300,

View file

@ -170,6 +170,7 @@ const LBUG_NATIVE = [
'test/integration/rename-provider-objective-c.test.ts',
'test/integration/rename-provider-php.test.ts',
'test/integration/rename-provider-python.test.ts',
'test/integration/rename-provider-r.test.ts',
'test/integration/rename-provider-ruby.test.ts',
'test/integration/rename-provider-rust.test.ts',
'test/integration/rename-provider-swift.test.ts',

View file

@ -20,6 +20,7 @@ import { rPostParse } from './r/post-parse.js';
import { rDefinitionProperties, rResolveMemberOwnerNode } from './r/owner-hooks.js';
import { isRNonNamingArgumentCapture } from './r/naming-argument.js';
import { createRCfgVisitor } from '../cfg/visitors/r.js';
import { rRenameProvider } from './r/rename.js';
const R_BUILT_INS: ReadonlySet<string> = new Set([
// Base R
@ -195,4 +196,5 @@ export const rProvider = defineLanguage({
shouldSkipDefinitionCapture: isRNonNamingArgumentCapture,
// Control flow only (no def/use harvest): CFG + CDG, REACHING_DEF reports no-facts.
cfgVisitor: createRCfgVisitor(),
rename: rRenameProvider,
});

View file

@ -0,0 +1,106 @@
import type { NameClaim } from 'gitnexus-shared';
import type { RenameIssue, RenameProvider } from '../../rename-provider.js';
import {
createTreeSitterRenameSource,
innermostRenameScope,
walkNamedNodes,
} from '../rename-tree-sitter.js';
/**
* Conservative semantic rename for R. Plain functions are renamed through the
* scope-resolution graph. R binds names dynamically (S3/S4/R6 dispatch, strings,
* NAMESPACE), so every declaration or name that the graph cannot prove is refused.
*/
const RESERVED = new Set(
'if else repeat while function for next break TRUE FALSE NULL Inf NaN NA NA_integer_ NA_real_ NA_character_ NA_complex_ in'.split(
' ',
),
);
const unsupported = (message: string): RenameIssue => ({
code: 'unsupported_occurrence',
status: 'unsupported',
message,
});
const parseNames = createTreeSitterRenameSource({
isName: (node) => node.type === 'identifier',
skip: (node) => node.type === 'comment',
role: (node) =>
node.parent?.type === 'parameter' && node.parent.childForFieldName('name')?.id === node.id
? 'binding'
: 'name',
});
export const rRenameProvider: RenameProvider = {
validateName(name) {
if (!/^(?:[A-Za-z]|\.(?![0-9]))[A-Za-z0-9._]*$/.test(name) || RESERVED.has(name))
return { code: 'invalid_name', message: 'Choose a valid nonreserved R identifier.' };
},
async parseSource(input) {
const source = await parseNames(input);
// Parameters shadow same-named functions: claim them per scope so the engine
// leaves an unrelated parameter alone.
const claims = new Map<string, NameClaim[]>();
for (const occurrence of source.occurrences) {
if (occurrence.role !== 'binding' || !occurrence.node) continue;
const scope = innermostRenameScope(source.parsed, occurrence.node);
if (!scope) continue;
claims.set(scope, [
...(claims.get(scope) ?? []),
{
name: occurrence.name,
range: occurrence.range,
kind: 'binding',
hoisted: true,
purpose: 'value',
},
]);
}
return {
...source,
parsed: {
...source.parsed,
scopes: source.parsed.scopes.map((scope) => ({
...scope,
nameClaims: [...(scope.nameClaims ?? []), ...(claims.get(scope.id) ?? [])],
})),
},
};
},
checkSafety({ target, newName, files, graph }) {
const name = target.qualifiedName ?? '';
if (target.type !== 'Function')
return unsupported('Only plain R functions can be renamed; classes and members are refused.');
const defined = new Set(
[...files.values()].flatMap((source) => source.parsed.localDefs.map((d) => d.qualifiedName)),
);
if (newName.includes('.') && defined.has(newName.slice(0, newName.indexOf('.'))))
return unsupported('The new name would register an S3 method of an existing function.');
for (const source of files.values()) {
if (!source.tree) continue;
for (const node of walkNamedNodes(source.tree.rootNode)) {
if (node.type === 'string_content' && (node.text === name || node.text === newName))
return unsupported(
'A string literal spells this name; S3, S4, R6 and dynamic lookup resolve it by string.',
);
}
}
// NAMESPACE is not an indexed source. A package function that the NAMESPACE
// exports (the graph keeps isExported unless it is proven private) is refused.
const file = target.filePath;
let exported = false;
graph.forEachNode((node) => {
if (
node.properties.filePath === file &&
node.properties.name === name &&
node.properties.isExported !== false
)
exported = true;
});
if (exported && /(^|\/)R\//.test(file))
return unsupported(
'A package function may have a NAMESPACE export entry that is not renamed.',
);
},
};

View file

@ -0,0 +1,10 @@
assist <- function(v) v + 1
step <- function(v) {
assist(assist(v))
}
# helper is described here
main <- function() {
step(1)
}

View file

@ -0,0 +1,10 @@
helper <- function(v) v + 1
step <- function(v) {
helper(helper(v))
}
# helper is described here
main <- function() {
step(1)
}

View file

@ -0,0 +1,15 @@
{
"language": "r",
"description": "Rename a plain function called twice in one expression; a comment mentioning the name is untouched.",
"categories": ["call-graph", "same-file", "comments"],
"target": { "name": "helper", "kind": "Function", "file": "chain.R", "startLine": 0 },
"newName": "assist",
"expect": { "status": "success", "edits": 3 },
"edges": [
{
"type": "CALLS",
"from": { "name": "step", "kind": "Function", "file": "chain.R", "startLine": 2 },
"to": { "name": "helper", "kind": "Function", "file": "chain.R", "startLine": 0 }
}
]
}

View file

@ -0,0 +1,3 @@
apply_twice <- function(compute, v) {
compute(compute(v))
}

View file

@ -0,0 +1,7 @@
calculate <- function(x) {
x * 2
}
run <- function(y) {
calculate(y) + 1
}

View file

@ -0,0 +1,3 @@
apply_twice <- function(compute, v) {
compute(compute(v))
}

View file

@ -0,0 +1,7 @@
compute <- function(x) {
x * 2
}
run <- function(y) {
compute(y) + 1
}

View file

@ -0,0 +1,15 @@
{
"language": "r",
"description": "Rename a plain function called in its own file while a same-named parameter in a second file is left alone.",
"categories": ["call-graph", "cross-file", "parameter-shadowing"],
"target": { "name": "compute", "kind": "Function", "file": "lib.R", "startLine": 0 },
"newName": "calculate",
"expect": { "status": "success", "edits": 2 },
"edges": [
{
"type": "CALLS",
"from": { "name": "run", "kind": "Function", "file": "lib.R", "startLine": 4 },
"to": { "name": "compute", "kind": "Function", "file": "lib.R", "startLine": 0 }
}
]
}

View file

@ -0,0 +1,3 @@
tick <- function(x) x + 1
run <- function() tick(1)

View file

@ -0,0 +1,3 @@
tick <- function(x) x + 1
run <- function() tick(1)

View file

@ -0,0 +1,9 @@
{
"language": "r",
"description": "Refuse a name starting with a digit as the new function name.",
"categories": ["identifier-rules", "negative", "unchanged-on-error"],
"target": { "name": "tick", "kind": "Function", "file": "lib.R", "startLine": 0 },
"newName": "2fast",
"expect": { "status": "error", "code": "invalid_name", "planningStatus": "blocked" },
"edges": []
}

View file

@ -0,0 +1,3 @@
tick <- function(x) x + 1
run <- function() tick(1)

View file

@ -0,0 +1,3 @@
tick <- function(x) x + 1
run <- function() tick(1)

View file

@ -0,0 +1,9 @@
{
"language": "r",
"description": "Refuse a reserved word as the new function name.",
"categories": ["identifier-rules", "negative", "unchanged-on-error"],
"target": { "name": "tick", "kind": "Function", "file": "lib.R", "startLine": 0 },
"newName": "repeat",
"expect": { "status": "error", "code": "invalid_name", "planningStatus": "blocked" },
"edges": []
}

View file

@ -0,0 +1,3 @@
Package: pk
Version: 0.1.0
Title: Fixture

View file

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

View file

@ -0,0 +1,5 @@
compute <- function(x) {
x * 2
}
use_it <- function() compute(2)

View file

@ -0,0 +1,3 @@
Package: pk
Version: 0.1.0
Title: Fixture

View file

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

View file

@ -0,0 +1,5 @@
compute <- function(x) {
x * 2
}
use_it <- function() compute(2)

View file

@ -0,0 +1,9 @@
{
"language": "r",
"description": "Refuse a package function that NAMESPACE exports, because the export entry would go stale.",
"categories": ["namespace", "package-export", "negative", "unchanged-on-error"],
"target": { "name": "compute", "kind": "Function", "file": "R/lib.R", "startLine": 0 },
"newName": "calculate",
"expect": { "status": "error", "code": "unsupported_occurrence", "planningStatus": "unsupported" },
"edges": []
}

View file

@ -0,0 +1,11 @@
Account <- R6::R6Class("Account",
public = list(
balance = 0,
deposit = function(n) {
self$balance <- self$balance + n
invisible(self)
}
)
)
acc <- Account$new()

View file

@ -0,0 +1,11 @@
Account <- R6::R6Class("Account",
public = list(
balance = 0,
deposit = function(n) {
self$balance <- self$balance + n
invisible(self)
}
)
)
acc <- Account$new()

View file

@ -0,0 +1,9 @@
{
"language": "r",
"description": "Refuse an R6 class whose members and constructor are reached through $new and self.",
"categories": ["r6", "class", "negative", "unchanged-on-error"],
"target": { "name": "Account", "kind": "Class", "file": "account.R", "startLine": 0 },
"newName": "Wallet",
"expect": { "status": "error", "code": "unsupported_occurrence", "planningStatus": "unsupported" },
"edges": []
}

View file

@ -0,0 +1,5 @@
area <- function(x) UseMethod("area")
area.circle <- function(x) 3.14 * x$r^2
total <- function(s) area(s)

View file

@ -0,0 +1,5 @@
area <- function(x) UseMethod("area")
area.circle <- function(x) 3.14 * x$r^2
total <- function(s) area(s)

View file

@ -0,0 +1,9 @@
{
"language": "r",
"description": "Refuse an S3 generic whose UseMethod string and area.circle method dispatch by spelling.",
"categories": ["s3", "string-dispatch", "negative", "unchanged-on-error"],
"target": { "name": "area", "kind": "Function", "file": "shapes.R", "startLine": 0 },
"newName": "surface",
"expect": { "status": "error", "code": "unsupported_occurrence", "planningStatus": "unsupported" },
"edges": []
}

View file

@ -0,0 +1,3 @@
area <- function(x) UseMethod("area")
area.circle <- function(x) 3.14 * x$r^2

View file

@ -0,0 +1,3 @@
area <- function(x) UseMethod("area")
area.circle <- function(x) 3.14 * x$r^2

View file

@ -0,0 +1,23 @@
{
"language": "r",
"description": "Refuse an S3 method: its dotted name is read as an owner-qualified member, so the graph identity is not a unique source declaration.",
"categories": [
"s3",
"dotted-name",
"negative",
"unchanged-on-error"
],
"target": {
"name": "area.circle",
"kind": "Function",
"file": "shapes.R",
"startLine": 2
},
"newName": "round_area",
"expect": {
"status": "error",
"code": "stale_graph",
"planningStatus": "blocked"
},
"edges": []
}

View file

@ -0,0 +1,3 @@
area <- function(x) 1
circle_area <- function(x) 3.14 * x$r^2

View file

@ -0,0 +1,3 @@
area <- function(x) 1
circle_area <- function(x) 3.14 * x$r^2

View file

@ -0,0 +1,9 @@
{
"language": "r",
"description": "Refuse a new dotted name whose prefix is an existing function, which would silently register an S3 method of it.",
"categories": ["s3", "name-collision", "negative", "unchanged-on-error"],
"target": { "name": "circle_area", "kind": "Function", "file": "shapes.R", "startLine": 2 },
"newName": "area.circle",
"expect": { "status": "error", "code": "unsupported_occurrence", "planningStatus": "unsupported" },
"edges": []
}

View file

@ -0,0 +1,3 @@
setClass("Person", representation(name = "character"))
make <- function() new("Person", name = "a")

View file

@ -0,0 +1,3 @@
setClass("Person", representation(name = "character"))
make <- function() new("Person", name = "a")

View file

@ -0,0 +1,9 @@
{
"language": "r",
"description": "Refuse an S4 class whose name is spelled only in string literals.",
"categories": ["s4", "class", "negative", "unchanged-on-error"],
"target": { "name": "Person", "kind": "Class", "file": "person.R", "startLine": 0 },
"newName": "Human",
"expect": { "status": "error", "code": "unsupported_occurrence", "planningStatus": "unsupported" },
"edges": []
}

View file

@ -0,0 +1,3 @@
worker <- function(x) x + 1
run <- function() do.call("worker", list(1))

View file

@ -0,0 +1,3 @@
worker <- function(x) x + 1
run <- function() do.call("worker", list(1))

View file

@ -0,0 +1,9 @@
{
"language": "r",
"description": "Refuse a function that a string literal names for do.call dynamic lookup.",
"categories": ["string-dispatch", "negative", "unchanged-on-error"],
"target": { "name": "worker", "kind": "Function", "file": "dyn.R", "startLine": 0 },
"newName": "job",
"expect": { "status": "error", "code": "unsupported_occurrence", "planningStatus": "unsupported" },
"edges": []
}

View file

@ -0,0 +1,3 @@
import { defineRenameFixtureTests } from '../helpers/rename-integration-cases.js';
defineRenameFixtureTests('r');