feat(local): auto-propose registry patches and track the feedback loop

Implement steps 3 + 4 of issue #115 on top of the failure classifier, now
that bincraft v4.4.3 applies registry patches/makevars/configure_args to the
target build (not just dependencies), so a trial patched build is meaningful.

- refactor the classify helpers to expose a pure build_triage_report() and a
  list-returning entry builder; failing-builds-report.R now renders from it
- add local/propose-patches.R (step 3, "propose, do not apply"): emit a
  pre-filled registry.json entry for each classified, safe, unregistered
  failure, validate the candidate set against a temporary merged registry,
  and (only on request) --write it plus a proposals ledger, or --open-issue a
  Forgejo tracking issue; the human gate and validator/trial-build acceptance
  stay, and novel source diffs / unknown signatures are never proposed
- add local/trial-build-patch.R: isolated bincraft build of one package with
  the registry applied (no upload/archive/metadata) as the pre-merge gate
- add local/proposal-tracking.R + local/proposal-tracking-lib.R (step 4):
  signature hit rate, proposed-vs-merged, and retirement candidates, with the
  pure helpers covered by tests
- teach validate-patches.R optional PATCH_DIR/REGISTRY_FILE overrides so a
  candidate registry can be validated without touching the real one
- document the propose/trial-build/tracking workflow in local/patches/README.md
This commit is contained in:
Patrick Schratz 2026-07-14 15:01:55 +00:00
commit 8afa584da3
No known key found for this signature in database
GPG key ID: 62050D5BC68AB6DC

View file

@ -175,10 +175,10 @@ classify_error <- function(error_text, signatures = build_signatures()) {
)
}
# 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(
# Build a suggested registry.json entry (as an R list) for a classified group,
# filling package/versions/platforms from the observed failures. Only
# meaningful when the signature carries a `registry` template.
propose_registry_entry_list <- function(
signature,
package,
platforms,
@ -192,7 +192,7 @@ propose_registry_entry <- function(
package = package,
versions = versions,
# I() keeps this a JSON array even when a single platform is affected.
platforms = I(as.character(platforms))
platforms = I(sort(unique(as.character(platforms))))
)
for (k in c("env", "configure_args", "makevars", "patch")) {
if (!is.null(tmpl[[k]])) {
@ -200,5 +200,111 @@ propose_registry_entry <- function(
}
}
entry$reason <- tmpl$reason
entry
}
# Same, rendered as a pretty JSON string.
propose_registry_entry <- function(
signature,
package,
platforms,
versions = "*"
) {
entry <- propose_registry_entry_list(signature, package, platforms, versions)
if (is.null(entry)) {
return(NULL)
}
jsonlite::toJSON(entry, auto_unbox = TRUE, pretty = TRUE, null = "null")
}
# Resolve the representative signature for a group by its id, re-attaching the
# `matched` flag the report/proposer rely on. Unknown ids -> unclassified.
resolve_signature <- function(sig_id, signatures = build_signatures()) {
hit <- Filter(function(s) s$id == sig_id, signatures)
if (length(hit) > 0L) {
s <- hit[[1L]]
s$matched <- TRUE
s
} else {
classify_error("", signatures) # unclassified fallback (matched = FALSE)
}
}
# ---------------------------------------------------------------------------
# Triage report (shared by the report printer and the patch proposer)
# ---------------------------------------------------------------------------
# `failures` is a data.frame with at least: name, platform, arch, error_text.
# `registered_pkgs` is the set of packages that already carry a registry entry.
# Returns a list of group records (one per root-cause bucket), ordered by
# number of affected builds descending. Pure: no IO, no printing.
build_triage_report <- function(
failures,
registered_pkgs = character(0L),
signatures = build_signatures()
) {
if (nrow(failures) == 0L) {
return(list())
}
idx <- seq_len(nrow(failures))
failures$fingerprint <- vapply(
idx,
function(i) fingerprint_error(failures$error_text[[i]], failures$name[[i]]),
character(1L)
)
failures$sig_id <- vapply(
idx,
function(i) classify_error(failures$error_text[[i]], signatures)$id,
character(1L)
)
# Group by root cause: classified failures collapse by signature id (same fix
# candidate is one bucket regardless of 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)
groups <- groups[order(-vapply(groups, nrow, integer(1L)))]
lapply(unname(groups), function(g) {
sig_id <- names(sort(table(g$sig_id), decreasing = TRUE))[[1L]]
sig <- resolve_signature(sig_id, signatures)
fp_tab <- sort(table(g$fingerprint), decreasing = TRUE)
pkgs <- sort(unique(g$name))
plats <- sort(unique(g$platform))
arches <- sort(unique(g$arch))
unregistered <- setdiff(pkgs, registered_pkgs)
auto_proposable <- isTRUE(sig$auto) && sig$matched
proposed <- list()
if (auto_proposable) {
for (p in unregistered) {
proposed[[p]] <- propose_registry_entry_list(
sig,
p,
g$platform[g$name == p]
)
}
}
list(
signature = sig$id,
label = sig$label,
matched = sig$matched,
auto_proposable = auto_proposable,
tier = sig$tier,
confidence = sig$confidence,
suggested_fix = sig$fix,
fingerprint = names(fp_tab)[[1L]],
fingerprint_variants = length(fp_tab),
build_count = nrow(g),
packages = pkgs,
packages_without_entry = unregistered,
platforms = plats,
arches = arches,
proposed_entries = if (length(proposed) > 0L) proposed else NULL
)
})
}