From 79f63c51b0c156fb6c73ad84b1a99647e7f71415 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 16:04:46 +0000 Subject: [PATCH] refactor(migrations): split the registry into per-topic files Replace the single tools/migrations.R registry with per-topic files under tools/migrations/, so parallel migration PRs do not conflict on one file. - tools/generate-migrations.R loads every tools/migrations/*.R file (a legacy tools/migrations.R is still honoured if present), merges the entries, and rejects duplicate declarations across files. - The existing entries move as-is: as_adjacency_matrix and as_biadjacency_matrix to tools/migrations/conversion.R, the test fixture to tools/migrations/fixture.R. The spliced ARG_HANDLE blocks are byte-identical. - The entry schema documentation moves from the deleted tools/migrations.R header to tools/migrations/README.md. - Two generator fixes with regression tests: a single over-long item rendered as `list(, item)` because paste0() turns character(0) into ",", and deparse() emitting `x/y` where air formats `x / y`, which failed CI's idempotency check (spaces are reinserted token-aware, so slashes inside string literals stay untouched). - New unit tests for the registry loader in tests/testthat/test-generate-migrations.R. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016M32izVHZPfxAqemAe4BrX --- .../workflows/custom/after-install/action.yml | 5 +- tests/testthat/helper-migrations.R | 14 +- tests/testthat/test-generate-migrations.R | 158 ++++++++++++++++++ tests/testthat/test-migration-fixture.R | 4 +- tools/README.md | 22 ++- tools/generate-migrations.R | 84 +++++++++- tools/migrations.R | 118 ------------- tools/migrations/README.md | 68 ++++++++ tools/migrations/conversion.R | 39 +++++ tools/migrations/fixture.R | 25 +++ 10 files changed, 394 insertions(+), 143 deletions(-) create mode 100644 tests/testthat/test-generate-migrations.R delete mode 100644 tools/migrations.R create mode 100644 tools/migrations/README.md create mode 100644 tools/migrations/conversion.R create mode 100644 tools/migrations/fixture.R diff --git a/.github/workflows/custom/after-install/action.yml b/.github/workflows/custom/after-install/action.yml index a0c839c4ac8..3b1ac3266f3 100644 --- a/.github/workflows/custom/after-install/action.yml +++ b/.github/workflows/custom/after-install/action.yml @@ -9,7 +9,8 @@ runs: echo -e 'CPPFLAGS = -I/opt/homebrew/include\nLDFLAGS = -L/opt/homebrew/lib' | tee ~/.R/Makevars shell: bash - # Regenerate the in-place ARG_HANDLE blocks from tools/migrations.R and fail + # Regenerate the in-place ARG_HANDLE blocks from the registry files under + # tools/migrations/ and fail # if they have drifted. Runs from the source checkout (R is available after # install; tools/ is excluded from the built package) so a contributor who # forgot to regenerate gets a hard failure. Local dev refreshes them @@ -18,7 +19,7 @@ runs: run: | Rscript tools/generate-migrations.R if ! git diff --exit-code -- R; then - echo "::error::Generated ARG_HANDLE blocks are out of sync with tools/migrations.R." + echo "::error::Generated ARG_HANDLE blocks are out of sync with the registry (tools/migrations/)." echo "Run 'Rscript tools/generate-migrations.R' and commit the result." exit 1 fi diff --git a/tests/testthat/helper-migrations.R b/tests/testthat/helper-migrations.R index 4771697c580..eb3c7f01f65 100644 --- a/tests/testthat/helper-migrations.R +++ b/tests/testthat/helper-migrations.R @@ -1,6 +1,7 @@ # Keep the in-place ARG_HANDLE blocks in sync with the registry during -# development: regenerate them before the tests run so editing tools/migrations.R -# and re-running the tests refreshes the spliced code. +# development: regenerate them before the tests run so editing any registry +# file under tools/migrations/ and re-running the tests refreshes the +# spliced code. # # tools/ is excluded from the built package (.Rbuildignore), so this is a no-op # under `R CMD check` of the tarball; it only fires from a source checkout @@ -8,14 +9,19 @@ # actually changes, so a clean tree stays clean. CI additionally guards against # committed drift (see .github/workflows/custom/before-install). local({ - registry <- testthat::test_path("..", "..", "tools", "migrations.R") generator <- testthat::test_path("..", "..", "tools", "generate-migrations.R") - if (!file.exists(registry) || !file.exists(generator)) { + if (!file.exists(generator)) { return(invisible()) } src_dir <- testthat::test_path("..", "..", "R") gen_env <- new.env() sys.source(generator, envir = gen_env) + registry <- gen_env$migration_registry_files( + testthat::test_path("..", "..") + ) + if (length(registry) == 0) { + return(invisible()) + } suppressMessages(gen_env$generate_migrations(registry, src_dir)) }) diff --git a/tests/testthat/test-generate-migrations.R b/tests/testthat/test-generate-migrations.R new file mode 100644 index 00000000000..3798e3970e1 --- /dev/null +++ b/tests/testthat/test-generate-migrations.R @@ -0,0 +1,158 @@ +# Unit tests for the registry loader in tools/generate-migrations.R. +# The generator is plain base R and only exists in a source checkout +# (tools/ is .Rbuildignore'd), so these tests are skipped in a built package. + +local_generator <- function(env = parent.frame()) { + generator <- testthat::test_path("..", "..", "tools", "generate-migrations.R") + skip_if_not(file.exists(generator), "tools/generate-migrations.R not found") + gen_env <- new.env() + sys.source(generator, envir = gen_env) + gen_env +} + +write_registry <- function(dir, name, code) { + path <- file.path(dir, name) + writeLines(code, path) + path +} + +test_that("migration_registry_files() finds the legacy file and topic files", { + gen <- local_generator() + root <- withr::local_tempdir() + dir.create(file.path(root, "tools", "migrations"), recursive = TRUE) + + expect_identical(gen$migration_registry_files(root), character(0)) + + legacy <- write_registry( + file.path(root, "tools"), + "migrations.R", + "migrations <- list()" + ) + b <- write_registry( + file.path(root, "tools", "migrations"), + "b-topic.R", + "migrations <- list()" + ) + a <- write_registry( + file.path(root, "tools", "migrations"), + "a-topic.R", + "migrations <- list()" + ) + + # legacy first, then topic files sorted by path + expect_identical(gen$migration_registry_files(root), c(legacy, a, b)) +}) + +test_that("load_migrations() merges entries across registry files", { + gen <- local_generator() + dir <- withr::local_tempdir() + + one <- write_registry( + dir, + "one.R", + c( + "migrations <- list(", + " fn_one = list(", + " old = function(x, a) {},", + " new = function(x, ..., a = 1) {},", + " when = '3.0.0'", + " )", + ")" + ) + ) + two <- write_registry( + dir, + "two.R", + c( + "migrations <- list(", + " fn_two = list(", + " old = function(y, b) {},", + " new = function(y, ..., b = 2) {},", + " when = '3.0.0'", + " )", + ")" + ) + ) + + merged <- gen$load_migrations(c(one, two)) + expect_named(merged, c("fn_one", "fn_two")) + expect_identical(merged$fn_two$head, "y") +}) + +test_that("load_migrations() rejects duplicate entries across files", { + gen <- local_generator() + dir <- withr::local_tempdir() + + entry <- c( + "migrations <- list(", + " fn_dup = list(", + " old = function(x, a) {},", + " new = function(x, ..., a = 1) {},", + " when = '3.0.0'", + " )", + ")" + ) + one <- write_registry(dir, "one.R", entry) + two <- write_registry(dir, "two.R", entry) + + expect_error( + gen$load_migrations(c(one, two)), + "Duplicate migration entry across registry files: fn_dup" + ) +}) + +test_that("load_migrations() rejects files without a migrations list", { + gen <- local_generator() + dir <- withr::local_tempdir() + + bad <- write_registry(dir, "bad.R", "not_migrations <- list()") + expect_error( + gen$load_migrations(bad), + "must define a `migrations` list" + ) +}) + +test_that("load_migrations() tolerates empty registries", { + gen <- local_generator() + dir <- withr::local_tempdir() + + empty <- write_registry(dir, "empty.R", "migrations <- list()") + expect_identical(gen$load_migrations(empty), list()) + expect_identical(gen$load_migrations(character(0)), list()) +}) + +test_that("render_call_arg() wraps a single long item without a stray comma", { + gen <- local_generator() + long_item <- paste0( + "impl = c(\"auto\", \"copy_and_delete\", \"create_from_scratch\")" + ) + out <- gen$render_call_arg("defaults", "list", long_item, "list()") + expect_identical( + out, + c( + " defaults = list(", + paste0(" ", long_item), + " )," + ) + ) + # and no element is a bare comma + expect_false(any(grepl("^\\s*,\\s*$", out))) +}) + +test_that("default expressions keep air's spacing around binary `/`", { + gen <- local_generator() + entry <- list( + old = function(graph, agebins, base) {}, + new = function( + graph, + ..., + agebins = n / 7100, + base = "http://a/b" + ) {}, + when = "3.0.0" + ) + norm <- gen$normalise_migration("fn_slash", entry) + expect_identical(norm$defaults$agebins, "n / 7100") + # slashes inside string literals stay untouched + expect_identical(norm$defaults$base, "\"http://a/b\"") +}) diff --git a/tests/testthat/test-migration-fixture.R b/tests/testthat/test-migration-fixture.R index 01ec4c75295..23f01b634b6 100644 --- a/tests/testthat/test-migration-fixture.R +++ b/tests/testthat/test-migration-fixture.R @@ -160,7 +160,9 @@ test_that("the generated block is in sync with the registry", { gen_env <- new.env() sys.source(generator, envir = gen_env) - registry <- testthat::test_path("..", "..", "tools", "migrations.R") + registry <- gen_env$migration_registry_files( + testthat::test_path("..", "..") + ) migrations <- gen_env$load_migrations(registry) by_fn <- stats::setNames( migrations, diff --git a/tools/README.md b/tools/README.md index 3769e9c30f0..a3d41cff7bc 100644 --- a/tools/README.md +++ b/tools/README.md @@ -156,15 +156,19 @@ soft-deprecation. The block is **generated in place**, not written by hand: -- **`tools/migrations.R`** is the single source of truth. Each entry declares the - function's `old` and `new` signatures as literal R functions; renames and - defaults are read straight off their formals (a bare-symbol default in `old`, - e.g. `c = c_renamed`, means a rename). The schema is documented at the top of - that file. -- **`tools/generate-migrations.R`** reads the registry and rewrites the code - between the `# BEGIN GENERATED ARG_HANDLE: ` / `# END GENERATED ARG_HANDLE` - markers inside each function. Output is idempotent (running twice produces no - diff) and laid out exactly as `air` formats it, so the host files stay clean. +- **`tools/migrations/.R`** are the source of truth: one registry file + per topic, mirroring the R source files, so parallel migration efforts do + not conflict on a single file. Each entry declares the function's `old` and + `new` signatures as literal R functions; renames and defaults are read + straight off their formals (a bare-symbol default in `old`, e.g. + `c = c_renamed`, means a rename). A function may be declared in only one + registry file; the generator stops with an error on duplicates. The schema + is documented in [`tools/migrations/README.md`](migrations/README.md). +- **`tools/generate-migrations.R`** reads all registry files and rewrites the + code between the `# BEGIN GENERATED ARG_HANDLE: ` / `# END GENERATED + ARG_HANDLE` markers inside each function. Output is idempotent (running + twice produces no diff) and laid out exactly as `air` formats it, so the + host files stay clean. Regenerate after editing the registry: diff --git a/tools/generate-migrations.R b/tools/generate-migrations.R index ef215205d57..0f424df42c0 100644 --- a/tools/generate-migrations.R +++ b/tools/generate-migrations.R @@ -2,8 +2,10 @@ # # Generator for in-place argument-migration code. # -# Reads the declarative registry in tools/migrations.R and splices recovery code -# directly into each migrated function body, between the markers +# Reads the declarative registry entries from the per-topic files in +# tools/migrations/ (a legacy single-file tools/migrations.R is still honoured +# if present, easing older branches over the transition) and splices recovery +# code directly into each migrated function body, between the markers # # # BEGIN GENERATED ARG_HANDLE: # # END GENERATED ARG_HANDLE @@ -31,30 +33,67 @@ migration_paths <- function() { # the package root. root <- getwd() list( - registry = file.path(root, "tools", "migrations.R"), + registry = migration_registry_files(root), out_dir = file.path(root, "R") ) } +# All registry sources: the legacy single file plus every file in +# tools/migrations/, one per topic. Sorted for a deterministic load order. +migration_registry_files <- function(root = getwd()) { + legacy <- file.path(root, "tools", "migrations.R") + topical <- sort(list.files( + file.path(root, "tools", "migrations"), + pattern = "\\.[rR]$", + full.names = TRUE + )) + c(if (file.exists(legacy)) legacy, topical) +} + # ---- registry loading + normalisation -------------------------------------- load_migrations <- function(registry_path) { + loaded <- lapply(registry_path, load_migration_file) + migrations <- do.call(c, c(loaded, list(list()))) + fns <- names(migrations) + dup <- unique(fns[duplicated(fns)]) + if (length(dup)) { + stop( + "Duplicate migration entr", + if (length(dup) == 1L) "y" else "ies", + " across registry files: ", + paste(dup, collapse = ", "), + call. = FALSE + ) + } + Map(normalise_migration, fns, migrations) +} + +load_migration_file <- function(path) { env <- new.env(parent = baseenv()) - sys.source(registry_path, envir = env, keep.source = FALSE) + sys.source(path, envir = env, keep.source = FALSE) if (!exists("migrations", envir = env, inherits = FALSE)) { stop( "`", - registry_path, + path, "` must define a `migrations` list.", call. = FALSE ) } migrations <- get("migrations", envir = env) + if (length(migrations) == 0L) { + return(list()) + } fns <- names(migrations) if (is.null(fns) || any(!nzchar(fns))) { - stop("`migrations` must be a list named by function.", call. = FALSE) + stop( + "`", + path, + "`: `migrations` must be a list named by function.", + call. = FALSE + ) } - Map(normalise_migration, fns, migrations) + migrations } # Deparse a formal's default, force-safely. A formal with no default holds R's @@ -62,7 +101,33 @@ load_migrations <- function(registry_path) { # `is.symbol()`); `deparse()` tolerates it and yields "". So we deparse first to # detect missingness, and only inspect the value once we know it is present. default_expr <- function(fmls, nm) { - paste(deparse(fmls[[nm]], width.cutoff = 500L), collapse = " ") + space_binary_slash( + paste(deparse(fmls[[nm]], width.cutoff = 500L), collapse = " ") + ) +} + +# air formats binary `/` with surrounding spaces, but deparse() emits `x/y`, +# which would make the spliced blocks fail the idempotency check against the +# air-formatted sources. Reinsert the spaces token-aware, so slashes inside +# string literals (e.g. URLs) stay untouched. +space_binary_slash <- function(text) { + if (!grepl("/", text, fixed = TRUE)) { + return(text) + } + pd <- tryCatch( + utils::getParseData(parse(text = text, keep.source = TRUE)), + error = function(e) NULL + ) + if (is.null(pd)) { + return(text) + } + cols <- pd$col1[pd$terminal & pd$token == "'/'"] + if (length(cols) == 0L) { + return(text) + } + chars <- strsplit(text, "", fixed = TRUE)[[1]] + chars[cols] <- " / " + gsub("[ ]+/[ ]+", " / ", paste(chars, collapse = "")) } # Turn one registry entry (with `old`/`new` as function objects) into the flat @@ -247,7 +312,8 @@ render_call_arg <- function(name, ctor, items, empty, trailing = ",") { n <- length(items) c( paste0(arg_indent, name, " = ", ctor, "("), - paste0(item_indent, items[-n], ","), + # paste0() would turn character(0) into "," for a single item + if (n > 1L) paste0(item_indent, items[-n], ","), paste0(item_indent, items[[n]]), paste0(arg_indent, ")", trailing) ) diff --git a/tools/migrations.R b/tools/migrations.R deleted file mode 100644 index e18c7283b99..00000000000 --- a/tools/migrations.R +++ /dev/null @@ -1,118 +0,0 @@ -# Migration registry: the single source of truth for argument-signature -# migrations that insert `...` and soft-deprecate the old positional form. -# -# This file is read by tools/generate-migrations.R, which turns each entry into -# an internal `handle_args_()` helper at R/handle-args-.R. Do NOT edit -# the generated R/handle-args-*.R files by hand -- edit this registry and -# regenerate: -# -# Rscript tools/generate-migrations.R -# -# (A testthat helper also regenerates them automatically when this file is newer -# than the generated helpers; see tests/testthat/helper-migrations.R.) -# -# --------------------------------------------------------------------------- -# Entry schema -# -# `migrations` is a list named by function. Each entry declares the function's -# signature *before* and *after* the migration as literal R function objects -- -# renames and defaults are read straight off their formals: -# -# = list( -# old = function() {}, -# new = function() {}, -# when = "" -# ) -# -# old The pre-migration signature. Only its formal *names* and *order* are -# read -- old default *values* are ignored. A formal whose default is a -# **bare symbol** is the one exception: it declares a rename to that -# name, e.g. `c = c_renamed` means the old `c` argument is the new -# `c_renamed`. Formals without a symbol default keep their name. -# -# new The post-migration signature. Must contain exactly one `...`. The -# non-`...` formals are the new-API arguments, in order; their defaults -# become the function's defaults and the values the conflict check -# compares against. -# -# when The version string passed to `lifecycle::deprecate_soft(when = )`. -# -# Changing a default as part of a migration is fine: the new default lives in -# `new` and is what the recovery uses; the old default is never consulted. The -# only caveat is the bare-symbol convention above -- an old argument whose -# genuine default is itself a bare symbol would be misread as a rename (rare; -# wrap it, e.g. `(sym)`, or reintroduce an explicit rename field if it ever -# bites). -# -# --------------------------------------------------------------------------- -# How recovery maps old calls onto the new signature -# -# * Arguments *before* `...` in `new` ("head" args) bind positionally as -# before; they must keep their relative order and names. -# * Old positional slots *beyond* the head are recovered from `...`: an -# unnamed value is mapped by position, a (possibly abbreviated) name by -# partial match. Renamed-away old names and abbreviations of the new names -# are both recovered, all under one soft-deprecation. -# -# To reorder or drop an argument, place it *after* `...` in `new`. It then -# becomes keyword-only and is recovered by (partial) name rather than by -# position, side-stepping the position math entirely. -# --------------------------------------------------------------------------- - -migrations <- list( - # --- real migrations ----------------------------------------------------- - # `attr` (a character edge-attribute name) is retired in favour of the more - # capable `weights` argument. The old `attr` slot is renamed to `weights` - # (the bare-symbol default below) and recovered positionally; named `attr =` - # calls keep working via the surviving `attr = deprecated()` formal, handled - # by resolve_edge_weights() in R/conversion.R. - as_adjacency_matrix = list( - old = function(graph, type, attr = weights, edges, names, sparse) {}, - new = function( - graph, - type = c("both", "upper", "lower"), - ..., - weights = NULL, - names = TRUE, - sparse = igraph_opt("sparsematrices"), - edges = deprecated(), - attr = deprecated() - ) {}, - when = "3.0.0" - ), - - as_biadjacency_matrix = list( - old = function(graph, types, attr = weights, names, sparse) {}, - new = function( - graph, - types = NULL, - ..., - weights = NULL, - names = TRUE, - sparse = FALSE, - attr = deprecated() - ) {}, - when = "3.0.0" - ), - - # --- test fixture -------------------------------------------------------- - # Exercises the generator end-to-end without migrating a real function. The - # arg names are chosen to cover every recovery path: two renames (`weight -> - # weights`, `kind -> type`), a surviving arg (`directed`), unique - # abbreviations (`kin`, `dir`) and an ambiguous one (`weig` matches both - # `weight` and `weights`). Head args (`graph`, `n`) stay before `...` and are - # matched by base R, including abbreviations like `gr =`. Consumed by - # tests/testthat/test-migration-fixture.R. - migration_fixture = list( - old = function(graph, n, weight = weights, kind = type, directed) {}, - new = function( - graph, - n, - ..., - weights = NULL, - type = "out", - directed = FALSE - ) {}, - when = "3.0.0" - ) -) diff --git a/tools/migrations/README.md b/tools/migrations/README.md new file mode 100644 index 00000000000..cba13e02c66 --- /dev/null +++ b/tools/migrations/README.md @@ -0,0 +1,68 @@ +# Per-topic migration registries + +Each `.R` file in this directory declares argument-signature +migrations for one group of related functions, +mirroring the R source files (e.g. `conversion.R`, `games.R`). +The files are read by `tools/generate-migrations.R`, +which splices an ARG_HANDLE block into each migrated function; +see [Argument-migration blocks](../README.md#argument-migration-blocks) +for the mechanics. + +A function may be declared in only one registry file; +the generator stops with an error on duplicates. + +Regenerate the spliced blocks after editing any registry file: + +```sh +Rscript tools/generate-migrations.R +``` + +## Entry schema + +Every registry file defines a `migrations` list named by function. +Each entry declares the function's signature *before* and *after* +the migration as literal R function objects -- +renames and defaults are read straight off their formals: + +```r +migrations <- list( + = list( + old = function() {}, + new = function() {}, + when = "" + ) +) +``` + +- `old` --- The pre-migration signature. Only its formal *names* and *order* + are read -- old default *values* are ignored. A formal whose default is a + **bare symbol** is the one exception: it declares a rename to that name, + e.g. `c = c_renamed` means the old `c` argument is the new `c_renamed`. + Formals without a symbol default keep their name. + +- `new` --- The post-migration signature. Must contain exactly one `...`. The + non-`...` formals are the new-API arguments, in order; their defaults + become the function's defaults and the values the conflict check compares + against. + +- `when` --- The version string passed to `lifecycle::deprecate_soft(when = )`. + +Changing a default as part of a migration is fine: the new default lives in +`new` and is what the recovery uses; the old default is never consulted. The +only caveat is the bare-symbol convention above -- an old argument whose +genuine default is itself a bare symbol would be misread as a rename (rare; +wrap it, e.g. `(sym)`, or reintroduce an explicit rename field if it ever +bites). + +## How recovery maps old calls onto the new signature + +- Arguments *before* `...` in `new` ("head" args) bind positionally as + before; they must keep their relative order and names. +- Old positional slots *beyond* the head are recovered from `...`: an + unnamed value is mapped by position, a (possibly abbreviated) name by + partial match. Renamed-away old names and abbreviations of the new names + are both recovered, all under one soft-deprecation. + +To reorder or drop an argument, place it *after* `...` in `new`. It then +becomes keyword-only and is recovered by (partial) name rather than by +position, side-stepping the position math entirely. diff --git a/tools/migrations/conversion.R b/tools/migrations/conversion.R new file mode 100644 index 00000000000..1aaffbe4d9a --- /dev/null +++ b/tools/migrations/conversion.R @@ -0,0 +1,39 @@ +# Argument-signature migrations: conversion +# Schema: see tools/migrations/README.md. Regenerate with: +# Rscript tools/generate-migrations.R + +migrations <- list( + # `attr` (a character edge-attribute name) is retired in favour of the more + # capable `weights` argument. The old `attr` slot is renamed to `weights` + # (the bare-symbol default below) and recovered positionally; named `attr =` + # calls keep working via the surviving `attr = deprecated()` formal, handled + # by resolve_edge_weights() in R/conversion.R. + as_adjacency_matrix = list( + old = function(graph, type, attr = weights, edges, names, sparse) {}, + new = function( + graph, + type = c("both", "upper", "lower"), + ..., + weights = NULL, + names = TRUE, + sparse = igraph_opt("sparsematrices"), + edges = deprecated(), + attr = deprecated() + ) {}, + when = "3.0.0" + ), + + as_biadjacency_matrix = list( + old = function(graph, types, attr = weights, names, sparse) {}, + new = function( + graph, + types = NULL, + ..., + weights = NULL, + names = TRUE, + sparse = FALSE, + attr = deprecated() + ) {}, + when = "3.0.0" + ) +) diff --git a/tools/migrations/fixture.R b/tools/migrations/fixture.R new file mode 100644 index 00000000000..f64b50e0a06 --- /dev/null +++ b/tools/migrations/fixture.R @@ -0,0 +1,25 @@ +# Argument-signature migrations: test fixture +# Schema: see tools/migrations/README.md. Regenerate with: +# Rscript tools/generate-migrations.R + +migrations <- list( + # Exercises the generator end-to-end without migrating a real function. The + # arg names are chosen to cover every recovery path: two renames (`weight -> + # weights`, `kind -> type`), a surviving arg (`directed`), unique + # abbreviations (`kin`, `dir`) and an ambiguous one (`weig` matches both + # `weight` and `weights`). Head args (`graph`, `n`) stay before `...` and are + # matched by base R, including abbreviations like `gr =`. Consumed by + # tests/testthat/test-migration-fixture.R. + migration_fixture = list( + old = function(graph, n, weight = weights, kind = type, directed) {}, + new = function( + graph, + n, + ..., + weights = NULL, + type = "out", + directed = FALSE + ) {}, + when = "3.0.0" + ) +)