build-cran-binaries/local/tests/test-failing-builds-classify.R
pat-s f11ba7172f
All checks were successful
ci/crow/cron/process-updates/9 Pipeline was successful
feat(local): classify failing binary builds and pre-fill registry suggestions (#116)
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
2026-07-14 14:45:25 +00:00

87 lines
3.1 KiB
R

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