diff --git a/local/failing-builds-classify.R b/local/failing-builds-classify.R new file mode 100644 index 0000000..b6f3430 --- /dev/null +++ b/local/failing-builds-classify.R @@ -0,0 +1,204 @@ +# Pure, side-effect-free helpers for triaging `single_builds` failures: +# normalise a raw `error_text` into a stable fingerprint, and classify it +# against a seed set of known failure signatures (issue #115, steps 1 + 2). +# +# Kept free of DB/IO so it can be sourced by both `failing-builds-report.R` +# and the unit tests in `local/tests/`. + +# --------------------------------------------------------------------------- +# Signature table +# --------------------------------------------------------------------------- +# Each rule maps a recurring compile/link/load error to a suggested fix tier. +# `pattern` is a case-insensitive regex matched against the raw `error_text`. +# `auto` marks whether the fix is a *known lever* safe to auto-propose as a PR +# (env / makevars / an already-curated package-specific patch). Rules that +# would require a brand-new source diff for a previously-unseen package stay +# `auto = FALSE` -> classified, but always routed to human triage, per the +# issue's guardrail against shipping autonomous novel source diffs. +# +# Seeded from the existing registry entries and known recurring failures; add +# a row here as new signatures are confirmed. Order matters: the first match +# wins, so keep more specific patterns above broader ones. +build_signatures <- function() { + list( + list( + id = "tbb-stddef-removed", + label = "removed TBB header tbb/tbb_stddef.h", + pattern = "tbb/tbb_stddef\\.h.*No such file", + tier = "makevars", + confidence = "high", + auto = TRUE, + fix = "add CPPFLAGS += -DTBB_INTERFACE_NEW so the source stops including the removed tbb/tbb_stddef.h header", + example = "StanHeaders / rstan (#114)", + registry = list( + env = NULL, + configure_args = NULL, + makevars = list(CPPFLAGS = "-DTBB_INTERFACE_NEW"), + patch = NULL, + reason = "package includes the removed tbb/tbb_stddef.h; -DTBB_INTERFACE_NEW selects the new oneTBB interface path" + ) + ), + list( + id = "rcppparallel-bundled-tbb", + label = "bundled Intel TBB build fails/hangs (musl / new g++)", + pattern = "USE_TBB[^\\n]*(not supported|unsupported)|RcppParallel[^\\n]*TBB|tbb[^\\n]*(Alpine|musl)", + tier = "patch", + confidence = "high", + auto = TRUE, + fix = "apply the curated RcppParallel/disable-tbb.patch so the bundled TBB build is skipped and the TinyThread backend is used", + example = "RcppParallel", + registry = list( + env = NULL, + configure_args = NULL, + makevars = NULL, + patch = "RcppParallel/disable-tbb.patch", + reason = "bundled Intel TBB build hangs/fails on musl (Alpine) and newer toolchains; patch forces the TinyThread backend" + ) + ), + list( + id = "system-libuv-link-leak", + label = "binary links system libuv (NEEDED libuv.so.1)", + pattern = "libuv\\.so", + tier = "patch", + confidence = "medium", + # A novel per-package source diff is required to force the vendored lib; + # never auto-propose, only surface for a human (guardrail). + auto = FALSE, + fix = "force the vendored/static library instead of the system one via a human-authored source patch (see fs/force-vendored-libuv.patch as precedent)", + example = "fs", + registry = list( + env = NULL, + configure_args = NULL, + makevars = NULL, + patch = "/force-vendored-.patch", + reason = "binary links the system library and fails to dyn.load on consumer machines; force the vendored/static build" + ) + ) + ) +} + +# --------------------------------------------------------------------------- +# Normalisation +# --------------------------------------------------------------------------- +# Collapse build-specific noise (temp paths, version numbers, hex addresses, +# the package name) so the same root cause across packages/platforms/versions +# maps to one fingerprint bucket. +normalise_error <- function(error_text, package = NULL) { + if (length(error_text) == 0L || is.na(error_text) || !nzchar(error_text)) { + return("") + } + x <- as.character(error_text) + # Package-specific token first (before version/number stripping mangles it). + if (!is.null(package) && length(package) == 1L && nzchar(package)) { + # \Q..\E quotes the name literally so metachars (e.g. data.table's dot) + # are matched verbatim rather than as regex. + x <- gsub(paste0("\\b\\Q", package, "\\E\\b"), "", x, perl = TRUE) + } + # R temp dirs/files: /tmp/RtmpAbC123, RtmpXXXX, /tmp/Rtmp.../file123. + x <- gsub("/tmp/[^ \t\n]*", "", x, perl = TRUE) + x <- gsub("\\bRtmp[A-Za-z0-9]+", "Rtmp", x, perl = TRUE) + # Hex addresses and version-like number runs. + x <- gsub("0x[0-9a-fA-F]+", "0x", x, perl = TRUE) + x <- gsub("[0-9]+(\\.[0-9]+)+", "", x, perl = TRUE) + x <- gsub("\\b[0-9]{2,}\\b", "", x, perl = TRUE) + # Whitespace and case. + x <- tolower(x) + x <- gsub("[ \t\r\n]+", " ", x, perl = TRUE) + trimws(x) +} + +# Extract the single most informative line from a multi-line error, then +# normalise it. This is the grouping key; a short salient line groups far +# better than the whole (often huge) transcript. +fingerprint_error <- function(error_text, package = NULL, max_chars = 200L) { + if (length(error_text) == 0L || is.na(error_text) || !nzchar(error_text)) { + return("") + } + lines <- strsplit(as.character(error_text), "\n", fixed = TRUE)[[1L]] + lines <- trimws(lines) + lines <- lines[nzchar(lines)] + if (length(lines) == 0L) { + return("") + } + salient_re <- paste( + "error:", + "fatal error:", + "no such file", + "undefined reference", + "cannot find -l", + "cannot open shared object", + "configuration failed", + "non-zero exit", + "installation of package", + "compilation failed", + sep = "|" + ) + hit <- lines[grepl(salient_re, lines, ignore.case = TRUE)] + chosen <- if (length(hit) > 0L) hit[[1L]] else lines[[length(lines)]] + fp <- normalise_error(chosen, package) + if (nchar(fp) > max_chars) { + fp <- paste0(substr(fp, 1L, max_chars), "...") + } + fp +} + +# --------------------------------------------------------------------------- +# Classification +# --------------------------------------------------------------------------- +# Return the first matching signature (as a list) enriched with `matched`, or +# the unclassified fallback. Never guesses: an unmatched error is routed to a +# human, not assigned a fix. +classify_error <- function(error_text, signatures = build_signatures()) { + txt <- if (length(error_text) == 0L || is.na(error_text)) { + "" + } else { + as.character(error_text) + } + for (sig in signatures) { + if ( + nzchar(txt) && grepl(sig$pattern, txt, ignore.case = TRUE, perl = TRUE) + ) { + sig$matched <- TRUE + return(sig) + } + } + list( + id = "unclassified", + label = "unknown signature", + tier = NA_character_, + confidence = NA_character_, + auto = FALSE, + fix = "no known signature; flag for human triage", + example = NA_character_, + registry = NULL, + matched = FALSE + ) +} + +# Render a suggested registry.json entry (as a pretty JSON string) for a +# classified group, filling package/versions/platforms from the observed +# failures. Only meaningful when the signature carries a `registry` template. +propose_registry_entry <- function( + signature, + package, + platforms, + versions = "*" +) { + if (is.null(signature$registry)) { + return(NULL) + } + tmpl <- signature$registry + entry <- list( + package = package, + versions = versions, + # I() keeps this a JSON array even when a single platform is affected. + platforms = I(as.character(platforms)) + ) + for (k in c("env", "configure_args", "makevars", "patch")) { + if (!is.null(tmpl[[k]])) { + entry[[k]] <- tmpl[[k]] + } + } + entry$reason <- tmpl$reason + jsonlite::toJSON(entry, auto_unbox = TRUE, pretty = TRUE, null = "null") +} diff --git a/local/failing-builds-report.R b/local/failing-builds-report.R new file mode 100644 index 0000000..855f042 --- /dev/null +++ b/local/failing-builds-report.R @@ -0,0 +1,290 @@ +#!/usr/bin/env Rscript + +# Read-only failure triage over the `single_builds` metadata table (issue #115, +# steps 1 + 2): query every recorded build failure, group by a normalised error +# fingerprint, classify each bucket against the known signature set, and print a +# triaged report with a pre-filled `registry.json` suggestion where a *known, +# safe* fix lever applies. Novel source diffs and unknown signatures are routed +# to human triage; this script never writes to the DB or the registry. +# +# Usage: +# PGPASS=... Rscript local/failing-builds-report.R [--platform P] [--arch A] +# [--json out.json] [--min N] +# +# --platform / --arch restrict to one platform/arch (default: all) +# --min N only show fingerprint groups with >= N failing builds +# --json PATH also write the machine-readable report to PATH +# +# Env fallbacks: PLATFORM, ARCH (same effect as the flags). + +options(error = function() { + cat("ERROR:", geterrmessage(), "\n", file = stdout()) + q(status = 1) +}) + +suppressPackageStartupMessages({ + library(DBI, quietly = TRUE) + library(RPostgres, quietly = TRUE) + library(jsonlite, quietly = TRUE) +}) + +# Locate helpers relative to this script so it runs from any CWD. +script_path <- local({ + a <- commandArgs(trailingOnly = FALSE) + f <- sub("^--file=", "", a[grepl("^--file=", a)]) + if (length(f) == 1L && nzchar(f)) normalizePath(f) else NA_character_ +}) +script_dir <- if (is.na(script_path)) "local" else dirname(script_path) +source(file.path(script_dir, "failing-builds-classify.R")) + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- +args <- commandArgs(trailingOnly = TRUE) +opt_val <- function(flag, default = NA_character_) { + i <- match(flag, args) + if (!is.na(i) && i < length(args)) args[[i + 1L]] else default +} +platform <- opt_val("--platform", Sys.getenv("PLATFORM", "")) +arch <- opt_val("--arch", Sys.getenv("ARCH", "")) +json_out <- opt_val("--json") +min_count <- suppressWarnings(as.integer(opt_val("--min", "1"))) +if (is.na(min_count)) { + min_count <- 1L +} + +if (nchar(Sys.getenv("PGPASS")) == 0L) { + stop( + "PGPASS env var is not set; a DB password is required (no read-only role exists)." + ) +} + +# --------------------------------------------------------------------------- +# Query failing builds +# --------------------------------------------------------------------------- +con <- DBI::dbConnect( + RPostgres::Postgres(), + dbname = "build_metadata", + host = "r-binaries.devxy.io", + port = 15432, + user = "rpkgs", + password = Sys.getenv("PGPASS"), + sslmode = "require" +) +on.exit(DBI::dbDisconnect(con), add = TRUE) + +where <- "error_occurred = TRUE AND removed = FALSE" +params <- list() +if (nzchar(platform)) { + where <- paste0(where, " AND platform = $", length(params) + 1L) + params <- c(params, platform) +} +if (nzchar(arch)) { + where <- paste0(where, " AND arch = $", length(params) + 1L) + params <- c(params, arch) +} +failures <- DBI::dbGetQuery( + con, + paste0( + "SELECT name, tag, platform, arch, r_version, timestamp, error_text ", + "FROM single_builds WHERE ", + where + ), + params = if (length(params) > 0L) params else NULL +) + +cat(sprintf( + "Queried single_builds: %d failing builds%s\n", + nrow(failures), + if (nzchar(platform) || nzchar(arch)) { + sprintf( + " (filter: platform=%s arch=%s)", + if (nzchar(platform)) platform else "*", + if (nzchar(arch)) arch else "*" + ) + } else { + "" + } +)) +if (nrow(failures) == 0L) { + cat("No failing builds to triage.\n") + q(status = 0) +} + +# --------------------------------------------------------------------------- +# Packages that already carry a registry entry (so we don't re-propose) +# --------------------------------------------------------------------------- +`%||%` <- function(a, b) if (is.null(a)) b else a +registry_file <- file.path(script_dir, "patches", "registry.json") +registered_pkgs <- character(0L) +if (file.exists(registry_file)) { + reg <- jsonlite::fromJSON(registry_file, simplifyVector = FALSE) + registered_pkgs <- unique(vapply( + reg, + function(e) as.character(e$package %||% ""), + character(1L) + )) +} + +# --------------------------------------------------------------------------- +# Fingerprint + classify every failure, then group +# --------------------------------------------------------------------------- +signatures <- build_signatures() +failures$fingerprint <- vapply( + seq_len(nrow(failures)), + function(i) fingerprint_error(failures$error_text[[i]], failures$name[[i]]), + character(1L) +) +failures$sig_id <- vapply( + seq_len(nrow(failures)), + function(i) classify_error(failures$error_text[[i]], signatures)$id, + character(1L) +) + +# Group by root cause: classified failures collapse by signature id (so the +# same fix candidate is one bucket regardless of surrounding log noise); +# unclassified failures fall back to the fingerprint so distinct unknowns stay +# separate for discovery. +failures$group_key <- ifelse( + failures$sig_id == "unclassified", + failures$fingerprint, + failures$sig_id +) +groups <- split(failures, failures$group_key) +# Order groups by number of affected builds, descending. +groups <- groups[order(-vapply(groups, nrow, integer(1L)))] + +report <- list() +cat(sprintf( + "\n%d distinct failure fingerprint(s); showing groups with >= %d build(s).\n", + length(groups), + min_count +)) +cat(strrep("=", 78L), "\n", sep = "") + +for (g in groups) { + n <- nrow(g) + if (n < min_count) { + next + } + + # Representative classification: most common signature id in the group. + sig_id <- names(sort(table(g$sig_id), decreasing = TRUE))[[1L]] + sig <- Filter(function(s) s$id == sig_id, signatures) + if (length(sig) > 0L) { + sig <- sig[[1L]] + sig$matched <- TRUE + } else { + sig <- classify_error("", signatures) # unclassified fallback (matched = FALSE) + } + + fp_tab <- sort(table(g$fingerprint), decreasing = TRUE) + rep_fp <- names(fp_tab)[[1L]] + fp_variants <- length(fp_tab) + pkgs <- sort(unique(g$name)) + plats <- sort(unique(g$platform)) + arches <- sort(unique(g$arch)) + unregistered <- setdiff(pkgs, registered_pkgs) + + status <- if (!sig$matched) { + "HUMAN TRIAGE (unknown signature)" + } else if (!sig$auto) { + sprintf("HUMAN TRIAGE (classified: %s; novel source diff)", sig$id) + } else { + sprintf("AUTO-PROPOSABLE (%s, %s confidence)", sig$id, sig$confidence) + } + + cat(sprintf( + "\n[%d builds | %d pkgs | %s] %s\n", + n, + length(pkgs), + toString(plats), + status + )) + cat(sprintf( + " fingerprint: %s%s\n", + rep_fp, + if (fp_variants > 1L) { + sprintf(" (+%d fingerprint variant(s))", fp_variants - 1L) + } else { + "" + } + )) + if (sig$matched) { + cat(sprintf(" signature : %s\n", sig$label)) + cat(sprintf(" suggested : [%s] %s\n", sig$tier, sig$fix)) + } + cat(sprintf(" arch : %s\n", toString(arches))) + cat(sprintf( + " packages : %s\n", + paste( + vapply( + pkgs, + function(p) { + if (p %in% registered_pkgs) paste0(p, " (has entry)") else p + }, + character(1L) + ), + collapse = ", " + ) + )) + + proposals <- list() + if (sig$matched && sig$auto && length(unregistered) > 0L) { + cat(" proposed registry entries (validate + trial-build before merge):\n") + for (p in unregistered) { + p_plats <- sort(unique(g$platform[g$name == p])) + entry <- propose_registry_entry(sig, p, p_plats) + proposals[[p]] <- entry + cat(paste0(" ", gsub("\n", "\n ", entry)), "\n", sep = "") + } + } else if (sig$matched && sig$auto && length(unregistered) == 0L) { + cat(" (all affected packages already have a registry entry)\n") + } + + report[[length(report) + 1L]] <- list( + fingerprint = rep_fp, + fingerprint_variants = fp_variants, + build_count = n, + packages = pkgs, + packages_without_entry = unregistered, + platforms = plats, + arches = arches, + signature = sig$id, + matched = sig$matched, + auto_proposable = isTRUE(sig$auto) && sig$matched, + tier = sig$tier, + confidence = sig$confidence, + suggested_fix = sig$fix, + proposed_entries = if (length(proposals) > 0L) { + lapply(proposals, jsonlite::fromJSON) + } else { + NULL + } + ) +} + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +matched <- Filter(function(r) r$matched, report) +auto <- Filter(function(r) r$auto_proposable, report) +cat("\n", strrep("=", 78L), "\n", sep = "") +cat(sprintf( + "Summary: %d groups | %d classified | %d auto-proposable | %d for human triage\n", + length(report), + length(matched), + length(auto), + length(report) - length(matched) +)) + +if (!is.na(json_out)) { + jsonlite::write_json( + report, + json_out, + auto_unbox = TRUE, + pretty = TRUE, + null = "null" + ) + cat(sprintf("Wrote machine-readable report to %s\n", json_out)) +} diff --git a/local/patches/README.md b/local/patches/README.md index af24b2b..b57cc1b 100644 --- a/local/patches/README.md +++ b/local/patches/README.md @@ -8,16 +8,16 @@ The registry is defined in `registry.json` as an array of patch entries. Each en ### Field semantics -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `package` | string | yes | CRAN package name. | -| `versions` | string | yes | `"*"` for any, a constraint such as `">=5.1.0"`, or an exact version `"5.1.11-2"`. Env-tier fixes are typically `"*"`; source diffs are normally exact or lower-bounded because a diff is pinned to the source it was generated against. | -| `platforms` | array of strings | yes | Matched against the running build's platform tokens — distro family (`alpine`, `ubuntu`, `redhat`), codename (`ubuntu-2604`, `alpine-324`), and arch (`amd64`, `arm64`). An entry matches if any listed token matches any build token. `["*"]` matches all platforms. | -| `env` | object | no | Environment variables exported only for this package's isolated build. | -| `configure_args` | array | no | Arguments passed as `--configure-args` to the isolated build. | -| `makevars` | object | no | Key/value pairs written into a package-local Makevars for the isolated build. | -| `patch` | string or null | no | Path (relative to `local/patches/`) to a unified diff applied to the unpacked CRAN source before building. | -| `reason` | string | yes | Human explanation, surfaced in logs and metadata. | +| Field | Type | Required | Description | +| ---------------- | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `package` | string | yes | CRAN package name. | +| `versions` | string | yes | `"*"` for any, a constraint such as `">=5.1.0"`, or an exact version `"5.1.11-2"`. Env-tier fixes are typically `"*"`; source diffs are normally exact or lower-bounded because a diff is pinned to the source it was generated against. | +| `platforms` | array of strings | yes | Matched against the running build's platform tokens — distro family (`alpine`, `ubuntu`, `redhat`), codename (`ubuntu-2604`, `alpine-324`), and arch (`amd64`, `arm64`). An entry matches if any listed token matches any build token. `["*"]` matches all platforms. | +| `env` | object | no | Environment variables exported only for this package's isolated build. | +| `configure_args` | array | no | Arguments passed as `--configure-args` to the isolated build. | +| `makevars` | object | no | Key/value pairs written into a package-local Makevars for the isolated build. | +| `patch` | string or null | no | Path (relative to `local/patches/`) to a unified diff applied to the unpacked CRAN source before building. | +| `reason` | string | yes | Human explanation, surfaced in logs and metadata. | ## Adding an entry @@ -41,3 +41,21 @@ Rscript local/validate-patches.R ``` This validates the schema, referenced patch-file existence, and checks for duplicate entries across platforms and versions. + +## Triaging failures into entries + +`local/failing-builds-report.R` turns recorded build failures into triaged patch suggestions instead of hand-scraping Crow logs (issue #115, steps 1 + 2). +It is read-only: it queries `single_builds WHERE error_occurred`, groups failures by a normalised error fingerprint, classifies each group against the known signature set in `local/failing-builds-classify.R`, and prints a report. + +```bash +# All platforms/arches; needs the DB password. +PGPASS=... Rscript local/failing-builds-report.R +# Restrict scope and also emit a machine-readable report. +PGPASS=... Rscript local/failing-builds-report.R --platform alpine-321 --arch amd64 --json report.json +``` + +Each group is tagged `AUTO-PROPOSABLE` (a known env/makevars lever, or an already-curated package patch, safe to pre-fill as a `registry.json` entry) or `HUMAN TRIAGE` (unknown signature, or a fix that needs a novel source diff). +For auto-proposable groups it prints a ready-to-review registry entry; still run `validate-patches.R` and an isolated trial build before merging. +Novel source diffs and unknown signatures stay human-reviewed by design. + +Add a new signature by appending a rule to `build_signatures()` in `local/failing-builds-classify.R`; the pure helpers are covered by `local/tests/test-failing-builds-classify.R`. diff --git a/local/tests/test-failing-builds-classify.R b/local/tests/test-failing-builds-classify.R new file mode 100644 index 0000000..66cd26b --- /dev/null +++ b/local/tests/test-failing-builds-classify.R @@ -0,0 +1,87 @@ +source(file.path("..", "failing-builds-classify.R")) + +test_that("normalise_error collapses temp paths, versions, and package tokens", { + a <- normalise_error( + "In file /tmp/RtmpAb12/foo.c: RcppParallel 5.1.9 failed at 0xdeadbeef", + package = "RcppParallel" + ) + expect_false(grepl("RtmpAb12", a)) + expect_false(grepl("5\\.1\\.9", a)) + expect_false(grepl("0xdeadbeef", a)) + expect_true(grepl("", a)) + expect_true(grepl("", a)) + + # Same root error across two versions collapses to one fingerprint. + e1 <- "StanHeaders 2.32.1: tbb/tbb_stddef.h: No such file or directory" + e2 <- "StanHeaders 2.33.0: tbb/tbb_stddef.h: No such file or directory" + expect_identical( + fingerprint_error(e1, "StanHeaders"), + fingerprint_error(e2, "StanHeaders") + ) +}) + +test_that("normalise_error is safe on NA/empty input", { + expect_identical(normalise_error(NA_character_), "") + expect_identical(normalise_error(""), "") + expect_identical(fingerprint_error(NA_character_), "") +}) + +test_that("fingerprint_error picks the salient error line, not the last line", { + txt <- paste( + "* installing *source* package 'foo' ...", + "error: bar.h: No such file or directory", + "* removing '/tmp/lib/foo'", + sep = "\n" + ) + fp <- fingerprint_error(txt, "foo") + expect_true(grepl("no such file", fp)) + expect_false(grepl("removing", fp)) +}) + +test_that("classify_error matches the removed TBB header signature (makevars, auto)", { + sig <- classify_error( + "fatal error: tbb/tbb_stddef.h: No such file or directory" + ) + expect_identical(sig$id, "tbb-stddef-removed") + expect_identical(sig$tier, "makevars") + expect_true(sig$auto) + expect_true(sig$matched) +}) + +test_that("classify_error matches RcppParallel bundled-TBB failures", { + sig <- classify_error( + "Error: USE_TBB=Linux is not supported on this toolchain" + ) + expect_identical(sig$id, "rcppparallel-bundled-tbb") + expect_identical(sig$tier, "patch") + expect_true(sig$auto) +}) + +test_that("system libuv link leak is classified but stays human-only (novel diff)", { + sig <- classify_error("cannot open shared object file: libuv.so.1") + expect_identical(sig$id, "system-libuv-link-leak") + expect_true(sig$matched) + expect_false(sig$auto) +}) + +test_that("unknown signatures are never guessed at", { + sig <- classify_error("segfault: memory not mapped at address") + expect_identical(sig$id, "unclassified") + expect_false(sig$matched) + expect_false(sig$auto) + expect_true(is.na(sig$tier)) +}) + +test_that("propose_registry_entry fills a schema-valid entry for a known lever", { + sig <- classify_error("tbb/tbb_stddef.h: No such file") + json <- propose_registry_entry(sig, "StanHeaders", c("alpine", "ubuntu-2604")) + entry <- jsonlite::fromJSON(json, simplifyVector = FALSE) + expect_identical(entry$package, "StanHeaders") + expect_identical(entry$versions, "*") + expect_identical(entry$platforms, list("alpine", "ubuntu-2604")) + expect_identical(entry$makevars$CPPFLAGS, "-DTBB_INTERFACE_NEW") + expect_true(nzchar(entry$reason)) + + # No template -> no proposal (unclassified path). + expect_null(propose_registry_entry(classify_error("weird"), "x", "alpine")) +})