fix(migrations): recover legacy arguments against the old signature - #2812
Conversation
|
Another idea for fixing: inlining and unrolling. Generated code doesn't really need static helper functions, can do everything from first principles. Work incoming. |
718a640 to
c186c6b
Compare
|
This is how benchmark results would change (along with a 95% confidence interval in relative change) if c186c6b is merged into main:
|
|
Can it really be as simple as this? My favorite so far. A bit longer, but wins by a margin when debugging. Need to iterate a bit, let me know what you think. |
c186c6b to
4440b1c
Compare
|
This is how benchmark results would change (along with a 95% confidence interval in relative change) if 4440b1c is merged into main:
|
4440b1c to
632c4b5
Compare
|
This is how benchmark results would change (along with a 95% confidence interval in relative change) if 632c4b5 is merged into main:
|
|
One more change (new snapshot tests), then ready for review. |
…2646) Supersedes #2808 and #2810. `migrate_recover_args()` re-implemented base R's argument matching over a `list(...)` and seven parallel configuration vectors. It is replaced here by matching `...` against the old signature itself. Each generated block carries `.old_signature()`, a local function whose formals are the pre-migration tail arguments, plus a trailing `...` of its own. Base R then does the recovery -- positionally, by partial name, and leaving an empty argument slot missing while it still consumes its position. That last part is the #2646 fix: a trailing comma (`f(x, mode = "out", )`) or a skipped positional (magrittr's `x %>% f(., , directed = FALSE)`) used to be forced by `list(...)` and error with "argument is missing, with no default"; now `...` is never materialised as a list at all. Everything is unrolled, one line per argument: `if (!base::missing(<old>)) base::list(<new> = <old>)` picks the value up, and `if (!base::missing(<new>)) "<new>"` reports it as already supplied. Asking `missing()` in the host frame answers "was this passed" directly, so the block needs neither the defaults nor a comparison against them. The guards and messages are inline too, so R/migrate-args.R goes away entirely and `lifecycle::deprecate_soft()` keeps resolving `user_env` to the user's frame on its own. Every call in a generated block is namespace-qualified. The block is spliced into someone else's function body, where a formal may be named `names` or `c` -- and a *missing* formal is worse than a shadowing one, since R forces the promise while looking for a function to call and reports `argument "names" is missing`. Qualifying unconditionally keeps every block the same shape instead of making it depend on the signature. Behaviour is unchanged, down to the message text -- the diff to the existing snapshots is empty: * `.old_signature()`'s own `...` catches unknown and surplus arguments, which keep the messages the matcher produced instead of R's "unused argument". * Abbreviations that were ambiguous between an old and a new name are rejected up front, since `.old_signature()` only knows the old names and would otherwise resolve some of them silently -- `at` for `as_adjacency_matrix(attr =)`, renamed to `weights`, while a deprecated `attr` formal remains. One case is now stricter: an argument passed explicitly at its own default value is a conflict with a positional recovering into it, where comparing against the default could not tell the two apart. `migration_fixture_shadow()` is a third fixture for the two hazards a block faces in a real function body, both taken from `as_adjacency_matrix()`: formals named `names` and `c` that shadow the block's own calls, and a name renamed away while a deprecated formal keeps it. Snapshots now cover the empty-slot messages, where the "Detected call" line is the readable form of the position bookkeeping, and every path through the new fixture. The blocks open with `# fmt: skip`, so `air` leaves the layout to the generator: that buys back some of the vertical space the unrolling costs and drops the code that had to predict how `air` would wrap. Co-Authored-By: David Schoch <david@schochastics.net> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DSnzx8x7uh1MjSPNHqjkWC
632c4b5 to
5f23064
Compare
|
This is how benchmark results would change (along with a 95% confidence interval in relative change) if 5f23064 is merged into main:
|
|
Ready for review. Revdeps running: https://github.com/igraph/rigraph/actions/runs/31091591505 |
|
This is how benchmark results would change (along with a 95% confidence interval in relative change) if 59a192e is merged into main:
|
|
This is how benchmark results would change (along with a 95% confidence interval in relative change) if 7b3402d is merged into main:
|
Eight packages tracked in #2646 had sections in the report that the revdep2 runs dropped. Splitting them by package makes it visible which is which. Kept, restored from the last report that carried them: * `SEMgraph` and `scistreer` were never checked. Both halves stopped at `checking package dependencies` in three and two seconds -- `RBGL`, `Rgraphviz`, `graphite`, `AnnotationDbi` for one, `ggtree` for the other -- agreed, and were scored `ok`. * `Boptbd` and `ggm` were checked and are still broken, identically under both versions, which is `ok` for this workflow and not for a reader. * `lagdynamics` and `migraph` are not in the revdep set at all at `depth: 1`: both reach igraph through another package (`tna` and `manynet`), so they are level 2. Dropped, because the runs checked them and found them clean: * `NetSci`, 35 s, no errors either side. * `tna`, 215 s, no errors either side. Both were fixed by #2812. `* : <UNKNOWN>` in the `ggm` and `SEMgraph` sections becomes the CRAN mirror link the collector emits now. Their content is otherwise the historical text, unedited. The collector would have deleted four of the six again on the next run, so `keeps_committed()` gains the `ok`-but-broken case: a run that reproduces breakage on both sides has not shown that a committed section is stale. It declines to delete, and adds nothing -- `problems.md` is the newly-broken list by design. `newly_broken` is excluded deliberately: archeofrag went 1E to 2E in run 31930350338 and that section is this run's to rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D1xpHRV7yVfgtJg4vp9P7z
A sweep of the pre-revdep2 report -- 32 problems and 7 failures, the curated record #2646 was written from -- found ten of its packages with no section left. Six were restored in the previous commit; three more here. * `c3net` was checked and is still broken, identically under both versions (1E, 21 s), which is `ok` for this workflow and not for a reader. Same shape as `Boptbd` and `ggm`. * `ggpicrust2` likewise, 1E on both sides in 85 s. * `MiscMetabar` was never checked: both halves stopped in four seconds and agreed. Dropped, and not restored: * `modelbpp` was checked clean in 73 s (fixed by #2812), as were `blavaan` (800 s) and `multinma` (1253 s). * `deepSTRAPP` and `invasible` are no longer reverse dependencies at all -- neither lists igraph in any dependency field on CRAN today. `invasible`'s section was empty besides its heading in any case. The revdep2-era reports name about 130 further packages that have no section now, but those come from run 31879790285 (78 problems, 76 of them the baseline-versus-`parse_check` parser artefact) and run 31893156685 (56 `donttest` timeout failures). Both are known-bad runs and their findings were not real, so those names are correctly absent. Twelve of them are nonetheless genuinely broken or unchecked; they were never in the curated record, so nothing is added for them here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D1xpHRV7yVfgtJg4vp9P7z
Supersedes #2808 and #2810 — one commit, based on
main, nothing stacked. Prepared with Claude Code.Fixes the four
argument is missing, with no defaultrevdep failures from #2646, by removing the runtime argument matcher rather than patching it.The bug
A call may leave an argument slot empty — a trailing comma (
f(x, mode = "out", )) or a skipped positional, which is what magrittr writes forx %>% f(., , directed = FALSE). Under the old signatures those slots matched a formal by position and left it missing, so the formal's default applied.Now that the optional arguments sit behind
...(#2757–#2778), the empty slot lands in..., where the generated block'slist(...)forces it:Four revdeps are affected: tna (31 test failures, plus examples and vignettes), lagdynamics, modelbpp, NetSci.
The fix
migrate_recover_args()re-implemented base R's argument matching overlist(...)and seven parallel configuration vectors. But that matching is something R already does, and did do before the migration — so the block declares the old signature and lets R match against it:#2646 falls out of it.
...is never materialised as a list, so an empty slot passes straight through into.old_signature(), leaves its formal missing, and still consumes its position — precisely the pre-migration behaviour.f(g, 5, , "in", TRUE)lands on the second and third recoverable arguments without anything having to arrange for it, andrlang::pairlist2()(which #2810 needed) is not involved.Everything is unrolled, one line per argument:
if (!base::missing(<old>))picks the value up, andif (!base::missing(<new>))reports it as already supplied. Askingmissing()in the host frame answers "was this passed" directly, so the block needs neither the defaults nor a comparison against them.No runtime layer is left. The guards and messages are inline too, so
R/migrate-args.Ris deleted;lifecycle::deprecate_soft()still sits in the host frame and resolvesuser_envto the user on its own.Every call is namespace-qualified. The block is spliced into someone else's function body, where a formal may be named
namesorc— and a missing formal is worse than a shadowing one, since R forces the promise while looking for a function to call and reportsargument "names" is missing. Qualifying unconditionally keeps every block the same shape instead of making it depend on the signature. Only operators and...length()are left bare — a formal cannot shadow those. The measured cost ofbase::on the hot...length()guard is ~80 ns per call.Behaviour is unchanged, down to the message text
The diff to the existing snapshots is empty; the new entries are additions. Two things were needed for that:
.old_signature()takes a trailing...of its own, so an unknown or surplus argument lands there instead of tripping base R's "unused argument"; it is reported with the message the matcher produced. The tags come fromsubstitute(...()), which forces nothing — a surplus slot may itself be empty..old_signature()only knows the old names, so it would silently resolve some abbreviations the matcher rejected —atforas_adjacency_matrix(attr =), which the migration renamed toweightswhile a deprecatedattrformal remains. The generator enumerates those tags (48 functions) and the block tests the dot tags against them first.One case is deliberately stricter: an argument passed explicitly at its own default value, together with a positional recovering into it, is now a conflict.
migration_fixture(g, 5, weights = NULL, 1:3)suppliesweightstwice and errors, where comparing against the default could not tell the two apart and let the positional win silently.Tests
A third fixture,
migration_fixture_shadow(graph, ..., weights, names, c, attr), covers the two hazards a block faces in a real function body — both taken fromas_adjacency_matrix(), which had no direct coverage: formals namednamesandcthat shadow the block's own calls, andattrrenamed toweightswhile a deprecatedattrformal keeps the name. Every path through it is asserted deterministically and snapshotted: recovery with the shadowing formals missing (the dangerous case), recovery of a shadowing formal by abbreviation and by position,attr =binding the retained formal, and the ambiguous, unknown, surplus and supplied-twice errors.The empty-slot behaviour is snapshotted alongside its deterministic tests, because the
Detected callline is the readable form of the position bookkeeping:One more edge is pinned: a slot past the last recoverable one is surplus whether or not it is empty, so
migration_fixture("g", 5, 1, 2, 3, )errors — the old signature had nothing to match a sixth argument against either.Cost
The generated blocks grow by ~2000 lines across all 227 — 21 lines per block before against ~30, and 60 for the widest (
layout_with_fr(), 17 recoverable arguments). The blocks open with# fmt: skipsoairleaves the layout to the generator: that buys some of it back, and it removed the code that had to predict howairwould wrap.Verification
testthat::test_local()green apart from two failures that need the environment rather than the code:test-foreign.R:69downloads fromgithub.com/igraph/graphsdb(no network here) andtest-other.R:20starts acallrsubprocess that needs igraph installed rather than loaded viapkgload.graph_from_data_frame(d, , )(all slots empty — defaults kept, silent), behave as they do on CRAN 2.3.1.air format ..