From ddafcae0caac92650ad0dae5305352d99e581e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABlle=20Salmon?= Date: Fri, 23 Oct 2026 14:43:12 +0200 Subject: [PATCH 1/5] chore: start work on flir rules --- tools/flir-rule.yml.mustache | 66 +++++++++++++++++ tools/generate-flir-rules.R | 135 +++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tools/flir-rule.yml.mustache create mode 100644 tools/generate-flir-rules.R diff --git a/tools/flir-rule.yml.mustache b/tools/flir-rule.yml.mustache new file mode 100644 index 0000000000..9c9f0d970e --- /dev/null +++ b/tools/flir-rule.yml.mustache @@ -0,0 +1,66 @@ +{{#rules}} +{{^is_first_rule}} +--- +{{/is_first_rule}} +id: igraph-{{when}}-{{fn}}-{{old_name}} +language: r +severity: warning +rule: + kind: argument + has: { field: value, pattern: $V } + any: +{{#names_to_rewrite_regex}} + - # passed by name + has: { field: name, kind: identifier, regex: '{{{names_to_rewrite_regex}}}' } + inside: + kind: arguments + inside: + kind: call + has: { field: function, regex: '{{{function_regex}}}' } +{{/names_to_rewrite_regex}} + - # passed by position + not: { has: { field: name, kind: identifier } } + nthChild: { position: {{position}}, ofRule: { kind: argument, not: { has: { field: name, kind: identifier } } } } + inside: + kind: arguments + not: + any: + - has: { kind: comma, follows: { kind: comma } } + - has: { kind: comma, follows: { regex: '^\($' } } + - has: { kind: comma, precedes: { regex: '^\)$' } } + - has: { kind: argument, has: { kind: dots } } + - has: { kind: argument, has: { field: name, regex: '{{{names_blocking_position_regex}}}' } } + inside: + kind: call + has: { field: function, regex: '{{{function_regex}}}' } + not: + inside: + kind: binary_operator + field: rhs + has: { field: operator, regex: '^(\|>|%>%)$' } + not: { has: { field: rhs, kind: call, has: { kind: arguments, has: { kind: argument, has: { field: value, kind: identifier, regex: '^[.]$' } } } } } +{{#position_after_pipe}} + - # passed by position after a pipe + not: { has: { field: name, kind: identifier } } + nthChild: { position: {{position_after_pipe}}, ofRule: { kind: argument, not: { has: { field: name, kind: identifier } } } } + inside: + kind: arguments + not: + any: + - has: { kind: comma, follows: { kind: comma } } + - has: { kind: comma, follows: { regex: '^\($' } } + - has: { kind: comma, precedes: { regex: '^\)$' } } + - has: { kind: argument, has: { kind: dots } } + - has: { kind: argument, has: { field: value, kind: identifier, regex: '^[._]$' } } + - has: { kind: argument, has: { field: name, regex: '{{{names_blocking_position_regex}}}' } } + inside: + kind: call + has: { field: function, regex: '{{{function_regex}}}' } + inside: + kind: binary_operator + field: rhs + has: { field: operator, regex: '^(\|>|%>%)$' } +{{/position_after_pipe}} +fix: {{new_name}} = ~~V~~ +message: igraph {{when}} requires `{{new_name}}` of `{{fn}}()` to be passed by its full name. +{{/rules}} diff --git a/tools/generate-flir-rules.R b/tools/generate-flir-rules.R new file mode 100644 index 0000000000..8231b5c06b --- /dev/null +++ b/tools/generate-flir-rules.R @@ -0,0 +1,135 @@ +#!/usr/bin/env Rscript +# +# Generate flir rules from the argument-migration registry (tools/migrations/). +# +# For each registry entry, writes one file `migrate--.yml` holding one +# rule per old positional slot that moved behind `...`. Each rule matches the +# argument R would have bound to that old formal and rewrites it to +# ` = `, in three cases: +# +# - passed by its old name, or by an abbreviation unique under `old`; +# - passed as the k-th unnamed argument of a call that is not piped; +# - passed as the (k-1)-th unnamed argument of a call on the right of `|>`, or +# of `%>%` without a `.` placeholder. +# +# The positional cases only fire when the binding is decidable from syntax: no +# named argument that could take one of the old formals 1..k, no empty slot, no +# `...`. Every other call is left to the runtime recovery. See +# https://github.com/igraph/rigraph/issues/2933. +# +# Usage: +# Rscript tools/generate-flir-rules.R [out_dir] [fn ...] +# +# `out_dir` defaults to inst/flir/rules. By default, rules are written for every +# function in the registry; list function names after `out_dir` to write rules +# for those functions only, e.g. +# Rscript tools/generate-flir-rules.R out/ sample_gnp degree +# +# The YAML lives in tools/flir-rule.yml.mustache, rendered with whisker. + +source(file.path("tools", "generate-migrations.R")) + +# The names R binds to the formal `name` among `formals`: the full name, plus +# every abbreviation of it no other formal claims. `pmatch()` is called on each +# abbreviation alone: on a vector it would match them jointly, as in a call, and +# the exact match would take `name` away from the shorter ones. +names_that_bind_to <- function(name, formals) { + abbreviations <- substring(name, 1, seq_len(nchar(name))) + matched <- vapply(abbreviations, pmatch, integer(1), formals) + abbreviations[matched %in% match(name, formals)] +} + +# Every abbreviation of every name in `names`, the full names included. +all_abbreviations_of <- function(names) { + unique(unlist(lapply(names, function(name) { + substring(name, 1, seq_len(nchar(name))) + }))) +} + +# A regex matching any of the names `x` in full, dots escaped. +regex_exactly_matching_any_of <- function(x) { + paste0("^(", paste(gsub(".", "[.]", x, fixed = TRUE), collapse = "|"), ")$") +} + +# A regex matching the function part of a call to `fn`, bare or `igraph::`. +regex_matching_call_to <- function(fn) { + paste0("^(igraph:::?)?", gsub(".", "[.]", fn, fixed = TRUE), "$") +} + +# The values the template fills in for the rule moving `old_name` to +# `new_name`. +template_values_for_rule <- function(entry, old_name, new_name) { + position <- match(old_name, entry$old) + # Writing the new name over itself would change nothing yet lint forever. + names_to_rewrite <- setdiff(names_that_bind_to(old_name, entry$old), new_name) + # A named argument that may bind to one of the old formals up to `position` + # shifts the unnamed ones, so the positional branches must not fire. + names_blocking_position <- all_abbreviations_of(entry$old[seq_len(position)]) + list( + when = entry$when, + fn = entry$fn, + function_regex = regex_matching_call_to(entry$fn), + old_name = old_name, + new_name = new_name, + names_to_rewrite_regex = if (length(names_to_rewrite)) { + regex_exactly_matching_any_of(names_to_rewrite) + } else { + FALSE + }, + names_blocking_position_regex = regex_exactly_matching_any_of( + names_blocking_position + ), + position = position, + # On the right of a pipe the first formal is filled by the left-hand side. + position_after_pipe = if (position > 1L) position - 1L else FALSE + ) +} + +# The rule file for one registry entry: one rule per old formal that moved +# behind `...`. +render_rule_file <- function(entry, template) { + rules <- Map( + function(old_name, new_name) { + template_values_for_rule(entry, old_name, new_name) + }, + entry$recover_old, + entry$recover_new + ) + for (i in seq_along(rules)) { + rules[[i]]$is_first_rule <- i == 1L + } + whisker::whisker.render(template, list(rules = unname(rules))) +} + +generate_flir_rules <- function(registry_path, out_dir, fns = NULL) { + migrations <- load_migrations(registry_path) + if (length(fns)) { + unknown <- setdiff(fns, names(migrations)) + if (length(unknown)) { + stop("Not in the registry: ", paste(unknown, collapse = ", "), call. = FALSE) + } + migrations <- migrations[fns] + } + template <- paste( + readLines(file.path("tools", "flir-rule.yml.mustache")), + collapse = "\n" + ) + dir.create(out_dir, recursive = TRUE, showWarnings = FALSE) + for (entry in migrations) { + if (!length(entry$recover_old)) { + next + } + path <- file.path( + out_dir, + sprintf("migrate-%s-%s.yml", entry$when, entry$fn) + ) + writeLines(render_rule_file(entry, template), path, sep = "") + message("wrote ", path) + } +} + +if (sys.nframe() == 0L && !interactive()) { + args <- commandArgs(trailingOnly = TRUE) + out_dir <- if (length(args)) args[[1]] else file.path("inst", "flir", "rules") + generate_flir_rules(migration_registry_files(), out_dir, args[-1]) +} From c43d1428b1f5b770b8ad94d1a57255c4681a8d00 Mon Sep 17 00:00:00 2001 From: maelle Date: Fri, 23 Oct 2026 12:52:59 +0000 Subject: [PATCH 2/5] chore: Auto-update from GitHub Actions Run: https://github.com/igraph/rigraph/actions/runs/39955525764 --- tools/generate-flir-rules.R | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/generate-flir-rules.R b/tools/generate-flir-rules.R index 8231b5c06b..d4aad8bcf7 100644 --- a/tools/generate-flir-rules.R +++ b/tools/generate-flir-rules.R @@ -106,7 +106,11 @@ generate_flir_rules <- function(registry_path, out_dir, fns = NULL) { if (length(fns)) { unknown <- setdiff(fns, names(migrations)) if (length(unknown)) { - stop("Not in the registry: ", paste(unknown, collapse = ", "), call. = FALSE) + stop( + "Not in the registry: ", + paste(unknown, collapse = ", "), + call. = FALSE + ) } migrations <- migrations[fns] } From c3b7521012c104d87e6177edddc6c6f43ec0a2f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABlle=20Salmon?= Date: Fri, 23 Oct 2026 14:53:12 +0200 Subject: [PATCH 3/5] custom delimiters --- tools/flir-rule.yml.mustache | 39 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/tools/flir-rule.yml.mustache b/tools/flir-rule.yml.mustache index 9c9f0d970e..27453f4ac9 100644 --- a/tools/flir-rule.yml.mustache +++ b/tools/flir-rule.yml.mustache @@ -1,26 +1,27 @@ -{{#rules}} -{{^is_first_rule}} +{{=<< >>=}} +<<#rules>> +<<^is_first_rule>> --- -{{/is_first_rule}} -id: igraph-{{when}}-{{fn}}-{{old_name}} +<> +id: igraph-<>-<>-<> language: r severity: warning rule: kind: argument has: { field: value, pattern: $V } any: -{{#names_to_rewrite_regex}} +<<#names_to_rewrite_regex>> - # passed by name - has: { field: name, kind: identifier, regex: '{{{names_to_rewrite_regex}}}' } + has: { field: name, kind: identifier, regex: '<<& names_to_rewrite_regex>>' } inside: kind: arguments inside: kind: call - has: { field: function, regex: '{{{function_regex}}}' } -{{/names_to_rewrite_regex}} + has: { field: function, regex: '<<& function_regex>>' } +<> - # passed by position not: { has: { field: name, kind: identifier } } - nthChild: { position: {{position}}, ofRule: { kind: argument, not: { has: { field: name, kind: identifier } } } } + nthChild: { position: <>, ofRule: { kind: argument, not: { has: { field: name, kind: identifier } } } } inside: kind: arguments not: @@ -29,20 +30,20 @@ rule: - has: { kind: comma, follows: { regex: '^\($' } } - has: { kind: comma, precedes: { regex: '^\)$' } } - has: { kind: argument, has: { kind: dots } } - - has: { kind: argument, has: { field: name, regex: '{{{names_blocking_position_regex}}}' } } + - has: { kind: argument, has: { field: name, regex: '<<& names_blocking_position_regex>>' } } inside: kind: call - has: { field: function, regex: '{{{function_regex}}}' } + has: { field: function, regex: '<<& function_regex>>' } not: inside: kind: binary_operator field: rhs has: { field: operator, regex: '^(\|>|%>%)$' } not: { has: { field: rhs, kind: call, has: { kind: arguments, has: { kind: argument, has: { field: value, kind: identifier, regex: '^[.]$' } } } } } -{{#position_after_pipe}} +<<#position_after_pipe>> - # passed by position after a pipe not: { has: { field: name, kind: identifier } } - nthChild: { position: {{position_after_pipe}}, ofRule: { kind: argument, not: { has: { field: name, kind: identifier } } } } + nthChild: { position: <>, ofRule: { kind: argument, not: { has: { field: name, kind: identifier } } } } inside: kind: arguments not: @@ -52,15 +53,15 @@ rule: - has: { kind: comma, precedes: { regex: '^\)$' } } - has: { kind: argument, has: { kind: dots } } - has: { kind: argument, has: { field: value, kind: identifier, regex: '^[._]$' } } - - has: { kind: argument, has: { field: name, regex: '{{{names_blocking_position_regex}}}' } } + - has: { kind: argument, has: { field: name, regex: '<<& names_blocking_position_regex>>' } } inside: kind: call - has: { field: function, regex: '{{{function_regex}}}' } + has: { field: function, regex: '<<& function_regex>>' } inside: kind: binary_operator field: rhs has: { field: operator, regex: '^(\|>|%>%)$' } -{{/position_after_pipe}} -fix: {{new_name}} = ~~V~~ -message: igraph {{when}} requires `{{new_name}}` of `{{fn}}()` to be passed by its full name. -{{/rules}} +<> +fix: <> = ~~V~~ +message: igraph <> requires `<>` of `<>()` to be passed by its full name. +<> From f838362d2960942ca298b1a65594a29f76a8c7aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABlle=20Salmon?= Date: Fri, 23 Oct 2026 14:59:40 +0200 Subject: [PATCH 4/5] better names --- tools/flir-rule.yml.mustache | 16 ++++++++-------- tools/generate-flir-rules.R | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tools/flir-rule.yml.mustache b/tools/flir-rule.yml.mustache index 27453f4ac9..bc208539b0 100644 --- a/tools/flir-rule.yml.mustache +++ b/tools/flir-rule.yml.mustache @@ -10,15 +10,15 @@ rule: kind: argument has: { field: value, pattern: $V } any: -<<#names_to_rewrite_regex>> +<<#regex_matching_old_name_spellings>> - # passed by name - has: { field: name, kind: identifier, regex: '<<& names_to_rewrite_regex>>' } + has: { field: name, kind: identifier, regex: '<<& regex_matching_old_name_spellings>>' } inside: kind: arguments inside: kind: call - has: { field: function, regex: '<<& function_regex>>' } -<> + has: { field: function, regex: '<<& regex_matching_function_name>>' } +<> - # passed by position not: { has: { field: name, kind: identifier } } nthChild: { position: <>, ofRule: { kind: argument, not: { has: { field: name, kind: identifier } } } } @@ -30,10 +30,10 @@ rule: - has: { kind: comma, follows: { regex: '^\($' } } - has: { kind: comma, precedes: { regex: '^\)$' } } - has: { kind: argument, has: { kind: dots } } - - has: { kind: argument, has: { field: name, regex: '<<& names_blocking_position_regex>>' } } + - has: { kind: argument, has: { field: name, regex: '<<& regex_matching_names_blocking_position>>' } } inside: kind: call - has: { field: function, regex: '<<& function_regex>>' } + has: { field: function, regex: '<<& regex_matching_function_name>>' } not: inside: kind: binary_operator @@ -53,10 +53,10 @@ rule: - has: { kind: comma, precedes: { regex: '^\)$' } } - has: { kind: argument, has: { kind: dots } } - has: { kind: argument, has: { field: value, kind: identifier, regex: '^[._]$' } } - - has: { kind: argument, has: { field: name, regex: '<<& names_blocking_position_regex>>' } } + - has: { kind: argument, has: { field: name, regex: '<<& regex_matching_names_blocking_position>>' } } inside: kind: call - has: { field: function, regex: '<<& function_regex>>' } + has: { field: function, regex: '<<& regex_matching_function_name>>' } inside: kind: binary_operator field: rhs diff --git a/tools/generate-flir-rules.R b/tools/generate-flir-rules.R index d4aad8bcf7..155baff8d1 100644 --- a/tools/generate-flir-rules.R +++ b/tools/generate-flir-rules.R @@ -61,22 +61,22 @@ regex_matching_call_to <- function(fn) { template_values_for_rule <- function(entry, old_name, new_name) { position <- match(old_name, entry$old) # Writing the new name over itself would change nothing yet lint forever. - names_to_rewrite <- setdiff(names_that_bind_to(old_name, entry$old), new_name) + old_name_spellings <- setdiff(names_that_bind_to(old_name, entry$old), new_name) # A named argument that may bind to one of the old formals up to `position` # shifts the unnamed ones, so the positional branches must not fire. names_blocking_position <- all_abbreviations_of(entry$old[seq_len(position)]) list( when = entry$when, fn = entry$fn, - function_regex = regex_matching_call_to(entry$fn), + regex_matching_function_name = regex_matching_call_to(entry$fn), old_name = old_name, new_name = new_name, - names_to_rewrite_regex = if (length(names_to_rewrite)) { - regex_exactly_matching_any_of(names_to_rewrite) + regex_matching_old_name_spellings = if (length(old_name_spellings)) { + regex_exactly_matching_any_of(old_name_spellings) } else { FALSE }, - names_blocking_position_regex = regex_exactly_matching_any_of( + regex_matching_names_blocking_position = regex_exactly_matching_any_of( names_blocking_position ), position = position, From c80b77b59f9049285c64aaf545be215e6467debe Mon Sep 17 00:00:00 2001 From: maelle Date: Fri, 23 Oct 2026 14:54:31 +0000 Subject: [PATCH 5/5] chore: Auto-update from GitHub Actions Run: https://github.com/igraph/rigraph/actions/runs/39957587362 --- tools/generate-flir-rules.R | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/generate-flir-rules.R b/tools/generate-flir-rules.R index 155baff8d1..ac7bcf848c 100644 --- a/tools/generate-flir-rules.R +++ b/tools/generate-flir-rules.R @@ -61,7 +61,10 @@ regex_matching_call_to <- function(fn) { template_values_for_rule <- function(entry, old_name, new_name) { position <- match(old_name, entry$old) # Writing the new name over itself would change nothing yet lint forever. - old_name_spellings <- setdiff(names_that_bind_to(old_name, entry$old), new_name) + old_name_spellings <- setdiff( + names_that_bind_to(old_name, entry$old), + new_name + ) # A named argument that may bind to one of the old formals up to `position` # shifts the unnamed ones, so the positional branches must not fire. names_blocking_position <- all_abbreviations_of(entry$old[seq_len(position)])