# 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") }