feat(local): classify failing binary builds and pre-fill registry suggestions (#116)
All checks were successful
ci/crow/cron/process-updates/9 Pipeline was successful
All checks were successful
ci/crow/cron/process-updates/9 Pipeline was successful
Implements steps 1 + 2 of #115: turn recorded build failures into triaged patch suggestions instead of hand-scraping Crow logs. ## What this adds A **read-only** reporting pipeline over the `single_builds` metadata table. It never writes to the DB or the registry. - `local/failing-builds-classify.R` — pure, DB-free helpers: - `normalise_error()` strips temp paths, version numbers, hex addresses, and the package name so the same root cause collapses to one fingerprint. - `fingerprint_error()` extracts the salient error line and normalises it. - `classify_error()` matches against a seed signature set; unmatched errors are never guessed at. - `propose_registry_entry()` renders a schema-valid `registry.json` entry. - `local/failing-builds-report.R` — entrypoint: queries `single_builds WHERE error_occurred = TRUE AND removed = FALSE`, groups by root cause (signature when classified, fingerprint otherwise), classifies each group, and prints a triaged report. Flags: `--platform`, `--arch`, `--min`, `--json`; `PLATFORM`/`ARCH` env fallbacks. - `local/tests/test-failing-builds-classify.R` — unit tests for the helpers. - `local/patches/README.md` — documents the workflow. ## Seed signatures Each rule carries a fix tier, confidence, and an auto/human-only flag: | Signature | Fix | Disposition | | --- | --- | --- | | `tbb/tbb_stddef.h: No such file` | makevars `-DTBB_INTERFACE_NEW` | auto-proposable | | RcppParallel bundled TBB (musl / new g++) | curated `disable-tbb.patch` | auto-proposable | | system `libuv.so` link leak | force vendored/static lib | **human triage** (novel source diff) | | unmatched | none | **human triage** | ## Guardrails honored - No autonomous novel source diffs: only known env/makevars levers and already-curated package patches are auto-proposable; anything needing a brand-new diff, and any unknown signature, is routed to human triage. - No DB or registry writes; no change to the public `src/contrib` index. - Reuses `single_builds.error_text`; no new failure-capture pipeline. ## Verification - All helper unit tests pass under the Dockerized R 4.5.3 build env. - Pre-commit hooks pass (`air-format`, `validate-patches`, prettier, etc.). - Smoke-tested the full report path with a stubbed DB; generated proposals pass the real `local/validate-patches.R`. Steps 3 (auto-open PRs) and 4 (feedback loop) are intentionally deferred, per the issue's suggestion to validate the signature set first. Closes #115 Reviewed-on: #116
This commit is contained in:
parent
04059f20e3
commit
f11ba7172f
4 changed files with 609 additions and 10 deletions
204
local/failing-builds-classify.R
Normal file
204
local/failing-builds-classify.R
Normal file
|
|
@ -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 = "<package>/force-vendored-<lib>.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"), "<pkg>", x, perl = TRUE)
|
||||
}
|
||||
# R temp dirs/files: /tmp/RtmpAbC123, RtmpXXXX, /tmp/Rtmp.../file123.
|
||||
x <- gsub("/tmp/[^ \t\n]*", "<tmp>", x, perl = TRUE)
|
||||
x <- gsub("\\bRtmp[A-Za-z0-9]+", "Rtmp<x>", x, perl = TRUE)
|
||||
# Hex addresses and version-like number runs.
|
||||
x <- gsub("0x[0-9a-fA-F]+", "0x<addr>", x, perl = TRUE)
|
||||
x <- gsub("[0-9]+(\\.[0-9]+)+", "<v>", x, perl = TRUE)
|
||||
x <- gsub("\\b[0-9]{2,}\\b", "<n>", 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("<empty>")
|
||||
}
|
||||
lines <- strsplit(as.character(error_text), "\n", fixed = TRUE)[[1L]]
|
||||
lines <- trimws(lines)
|
||||
lines <- lines[nzchar(lines)]
|
||||
if (length(lines) == 0L) {
|
||||
return("<empty>")
|
||||
}
|
||||
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")
|
||||
}
|
||||
290
local/failing-builds-report.R
Normal file
290
local/failing-builds-report.R
Normal file
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
87
local/tests/test-failing-builds-classify.R
Normal file
87
local/tests/test-failing-builds-classify.R
Normal file
|
|
@ -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("<pkg>", a))
|
||||
expect_true(grepl("<tmp>", 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_), "<empty>")
|
||||
})
|
||||
|
||||
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"))
|
||||
})
|
||||
Loading…
Reference in a new issue