Add a qual crate that diffs rustqual findings by identity
- Status: Todo
- Kind: Feature
- Authors: jp
- Date: 2026-10-06
- Label: domain=tooling
- Label: type=task
A new internal crate, crates/internal/qual, that turns rustqual reports into individual findings, compares two reports, and renders the result. CI, cargo_check and the burn-down loop harness all call it, so the gate and its messages behave the same in every place it runs.
Why
The gate today compares per-category counts in .config/rustqual/baseline.json and baseline-dry.json. Those files record a location only for IOSP violations (violation_details). Every other finding (about 2,000 of the ~3,200 distinct ones) is stored as a count. That is why CI can say srp_module_warnings: 207 -> 208 but not which file. A committed baseline also goes stale as soon as main moves (see T-0yb2pgw).
Scope
- Parse both passes (
config.tomlanddry.toml) into findings keyed by(rule, file, symbol). Line numbers are not part of the key, so editing code above a function causes no churn. Duplicate keys count as a multiset. IOSP appears in both passes, so de-duplicate it. This ticket picks the input format (json,sariforai-json): whichever gives a stable rule ID and symbol most cleanly. - Diff a base report against a head report and return the new findings. Before comparing, map renamed files using
git diff -Mbetween the two revisions. - Base analysis: run rustqual on
git worktree add --detach <base>. Cache the report, keyed by base sha, rustqual version and config hash. A run takes a few seconds, so the cache is a convenience, not a requirement. - Policy, read from
.config/rustqual/(exact file decided here):exempt: rules that never gate. IOSP goes here, because rustqual has no way to turn it off.enforced: rules that have reached zero onmain. Any finding of an enforced rule fails, whether or not it appears in the diff. Rules are only ever added to this list; it never shrinks.
- Render the same findings as plain text, GitHub annotations (
::error file=…,line=…::), a Markdown table for$GITHUB_STEP_SUMMARY, and a compact AI-oriented format. Each finding carries rustqual's message plus the guidance from.config/rustqual/guidance/<RULE>.mdwhen that file exists. - CLI:
qual diff --base <rev> [--format …]exits non-zero on new or enforced findings.qual reportlists every current finding (the burn-down picker needs this).
- Library API: jp-tools depends on the crate the same way it depends on
ticket.
Fail closed
A report that can't be parsed, or a rustqual run that fails, is an error, never a pass. This replaces the canary in qual-ci: unit tests prove that a new finding fails the gate and that a malformed report is an error.
Tests
Keep the core pure: parsing, rename mapping, diff, policy and rendering are tested against fixed fixture reports. Only the thin shell around them spawns rustqual and git.
Out of scope
Wiring into CI, cargo_check or the harness. Those are separate tickets that depend on this one.