Rustqual baselines go stale when main moves
- Status: Todo
- Kind: Chore
- Authors: jp
- Date: 2026-10-06
- Label: domain=tooling
- Label: type=follow-up
just qual-ci compares the workspace against counts committed in .config/rustqual/baseline.json and baseline-dry.json. Those counts describe the tree at the moment they were recorded, so they go stale as soon as main moves.
The first real run of the gate demonstrated it. The baselines were recorded on 22 Sep; rebasing the branch onto 6 Oct main put 382 extra findings in the main pass and 116 in the DRY pass, across twelve categories, none of them from the branch — its whole Rust diff was seven comment lines. The gate was right that the numbers had moved and had no way to say whose numbers they were.
Why it matters
The author who hits the failure is whoever rebases next, and the documented response (just qual-baseline) makes them re-record a baseline covering two weeks of someone else's code. Refreshing is the only action that turns the job green, it always works, and it takes one command — so the gate teaches contributors to reach for the thing that silences it. That is the failure mode the gate exists to prevent, arrived at from the other direction.
The refresh is at least visible: the baselines are pretty-printed, so "total_findings": 2404 → 2786 shows up in the pull request diff where a reviewer can see it. That is a review-time catch, not a tooling one.
Options
- Accept it. Refresh on rebase and treat the failure as noise. Free, and normalizes the reflex.
- Re-record from
mainon a schedule. A workflow runsqual-baselineonmainand commits the result, so a branch compares against a baseline that is at most a day old. One extra workflow; the drift window shrinks but does not close, and the commits are noise in the log. - Drop the committed baseline. Analyse
HEADand the merge-base in the same job and compare the two reports. Staleness becomes impossible, because both numbers come from the run. Costs four analyses per job instead of two, and needs the merge-base tree checked out — probably a second worktree.
Option 3 removes the whole class of problem and is the one to aim for. Worth measuring how long one rustqual pass takes over the workspace before committing to it: if a pass is seconds, the extra cost is irrelevant and option 3 wins outright.
Related
Introduced with the gate itself in #1190. The same pull request already replaced rustqual's own --compare --fail-on-regression, which scored a regression as the aggregate quality ratio dropping and so missed new findings entirely; .config/rustqual/regressions.jq now does a per-category comparison. Whatever replaces the committed baseline should keep that filter — the per-category check is orthogonal to where the numbers come from.
Comments
- From: jp
- Date: 2026-10-06T15:03:44Z
Observed: the gate fires on commits the branch does not contain
First real CI run of qual-ci on #1190 failed with srp_module_warnings: 207 -> 208 (+1) while the same recipe passed locally with identical numbers on both sides.
Cause is narrower than "baselines go stale", and worse:
.github/workflows/rust.yml:167 checks out with no ref:, so a pull_request run gets refs/pull/N/merge — the head merged into current main. The branch was rebased onto 150ba7868 and its baselines re-recorded there (e7e3b424c). main then took dbafe5485 (#1242, 16:30) and dfae02a91 (#1207, 16:33) the same afternoon. CI measured branch + dfae02a91; the baseline describes branch + 150ba7868. The +1 is a file crossing a threshold in that delta — #1242 adds net +21 lines to crates/jp_cli/src/cmd/query/tool/prompter.rs, which is otherwise unflagged.
Two consequences beyond staleness:
- Not reproducible locally. No sequence of local commands shows the failure, because the tree CI measured does not exist in any worktree. The author's only lever is to rebase and re-record — which is the reflex this ticket exists to prevent, now with no diagnostic alternative.
- The comparison is invalid in both directions. A committed baseline describes exactly one tree. Compared against a merge tree, it invents regressions and can mask real ones: if the merge happens to delete findings elsewhere, a genuine new finding nets to zero and passes.
So a committed baseline and the merge ref cannot both be right. The measured tree and the baseline have to share a base. That makes option 3 (analyse HEAD and the merge-base in the same job, no committed baseline) the correct target rather than merely the tidiest.
A cheaper interim exists: a per-task checkout_ref through matrix.include, set to github.event.pull_request.head.sha for qual only, leaving test and the other tasks on the merge ref where testing the merge result is the point. Needs one thing verified first — whether actions/checkout treats an empty ref: as unset, which is what the other matrix entries would pass.
- From: jp
- Date: 2026-10-06T21:46:15Z
Decided: option 3, with no committed baseline. Findings are compared one by one by (rule, file, symbol) rather than per-category counts, and the comparison lives in a new qual crate (T-0yh5frr). CI compares the merge ref against HEAD^1 in the same job (T-0yh5npc), so the measured tree and the base always match and the interim checkout_ref workaround isn't needed. This ticket closes when T-0yh5npc lands.