Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/mirth-lab/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ libc = "0.2"
flate2 = "1"
tar = "0.4"
mirth-rewrite = { path = "../mirth-rewrite" }
syn = { version = "2.0.119", features = ["full", "visit"] }
proc-macro2 = { version = "1", features = ["span-locations"] }
quote = "1"
4 changes: 4 additions & 0 deletions crates/mirth-lab/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod tools {
pub mod gate_check;
pub mod grammar_coverage;
pub mod instr_check;
pub mod lint_check;
pub mod miri_diff;
pub mod motivating;
pub mod opt_diff;
Expand Down Expand Up @@ -129,6 +130,8 @@ enum Check {
SurveyUnroll(tools::survey::UnrollArgs),
/// Each closed-bug Ur query still finds the code its bug's fix changed.
VerifyClosed(tools::verify_closed::Args),
/// A lint fires only when its premise holds; allowing it or removing what it flags changes nothing else.
LintCheck(tools::lint_check::Args),
}

fn main() -> ExitCode {
Expand All @@ -144,6 +147,7 @@ fn main() -> ExitCode {
Check::ReproDiff(a) => tools::repro_diff::run(a),
Check::GateCheck(a) => tools::gate_check::run(a),
Check::InstrCheck(a) => tools::instr_check::run(a),
Check::LintCheck(a) => tools::lint_check::run(a),
Check::ReleaseDiff(a) => tools::release_diff::run(a),
Check::Xlink(a) => tools::xlink::run(a),
Check::ScaleCheck(a) => tools::scale_check::run(a),
Expand Down
986 changes: 986 additions & 0 deletions crates/mirth-lab/src/tools/lint_check.rs

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions docs/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,27 @@ Ten new findings (19–28) in [`hunt.md`](hunt.md), none from the checks mirth h
| determinism (15) | `mirth-lab repro-diff` | 6,886 tests × repeat, other directory with `--remap-path-prefix`, `-Zthreads=8`, decoy `-L` library | nothing new: only `-Zthreads` differences, all in the known async fn (#162202) and RPIT (#163878) families |
| feature gates (17) | `mirth-lab gate-check` | 143 unstable attributes × 14 positions; 156 unstable library items with resolvable paths × use, renamed use, glob, impl, value, type | every library spelling gated; finding 31 (an ICE after the gate error for `#[rustc_main]` on non-functions); `#[feature]` outside the crate root only warns (intended) |

### Third batch (2026-10-10)

| check | script | swept | result |
|---|---|---|---|
| lint oracles (16) | `mirth-lab lint-check` | 18,624 tests; the 8,055 that compile without errors (lints capped to warnings where a test denies them), with 16 allow-by-default lints turned on; 4,244 have a lint warning, 211 lints in all; 30,100 compilations | findings 33–36 (`let_underscore_drop`; lifetime-lint fixes; `dead_code` on needed traits and opaque-type definitions; `trivial_numeric_casts` on literals); known #110332 and #163369 reproduced; `unreachable_pub` and `missing_copy_implementations` edge cases noted in [`hunt/lint-check.md`](hunt/lint-check.md) |

`lint-check` acts on each warning four ways, and reports when anything else changes:
- `#![allow(lint)]`;
- deleting what a premise lint flags (all `dead_code` items at once with their impls, each
`unreachable_patterns` arm, `unreachable_code` statements up to the block's tail);
- rewriting to what the premise says is equivalent (`trivial_casts` through a coercion site,
`trivial_numeric_casts` without the cast, `ambiguous_wide_pointer_comparisons` through a
const assertion that the operand is two words, `impl Copy` for
`missing_copy_implementations`);
- applying allow-by-default lints' machine-applicable fixes, which suggest-diff never sees,
alone and then all of a lint's together.

Failures the lint's design or the edit explains are listed per test under `expected` in
`results.jsonl` and not counted: items only exempt dead code uses, re-exports, macro-generated
users, unreachable code that takes part in inference.

## Running the checks

The checks are subcommands of `mirth-lab` (`crates/mirth-lab`; `mirth-lab --help` lists them):
Expand All @@ -380,6 +401,7 @@ R=~/mirth-work/campaign/rustc/bin/rustc T=~/mirth-work/rust/tests/ui
target/release/mirth-lab opt-diff --rustc $R --cranelift "$(rustup +nightly-2026-10-06 which rustc)" --tests $T --work <dir>
target/release/mirth-lab solver-diff --rustc $R --tests $T --work <dir>
target/release/mirth-lab abi-diff --rustc $R --rust ~/mirth-work/rust --work <dir> --seed 3
target/release/mirth-lab lint-check --rustc $R --tests $T --work <dir>
target/release/mirth-lab release-diff --corpus ~/proofhouse-repos/rust --old nightly-2026-07-18 --new nightly-2026-10-06 --work <dir>
```

Expand Down
4 changes: 4 additions & 0 deletions docs/hunt.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks.
| 30 | compiler-internal debug output in user-facing diagnostics: under the default (new) solver, E0308 help suggests `as fn(?0t) -> ?0t`; an E0391 cycle note prints `Binder { value: ConstEvaluatable(AliasConst(… DefId(0:7 ~ …` (blessed in `offset-of/inside-array-length.stderr`) | low, diagnostics; found by the diagnostic-invariants check over 18,374 UI tests (excluding tests that ask for verbose output); the first not in CI because of the solver pin ([`solver-triage.md`](solver-triage.md) item I) |
| 31 | `#[rustc_main]` on a struct, impl, trait or module, on stable: after the expected E0658 and "cannot be used on structs", rustc ICEs ("unexpected sort of node in fn_sig()", `collect.rs`): the item is still taken as the entry point | **looks new**, low (error recovery, internal attribute); regression between 1.91.0 and 1.93.0; found by the feature-gate check; [repro](hunt/tests/rustc-main-on-struct.rs) |
| 32 | new-solver compile-time regression: a chain of N `.map()` calls type-checks in 4.5 s / 520 MB at N=200 on nightly-2026-08-03 and 37–58 s / 2.0–2.9 GB from nightly-2026-08-04, with a new "overflow evaluating the requirement `Map<…<Map<_, …>>: Iterator`" future-compat warning; nightly's default solver is the new one, so default builds regressed from 2.2 s (old solver, July) to 53 s | **looks new**, medium (compile time, realistic code shape); bisected over nightlies to #160254 (the only solver PR in the range); found by the scaling check; [facts](hunt/iter-chain-solver-regression.md) |
| 33 | `let_underscore_drop` (allow-by-default): fires on `let _ = x;` with `x` a place, which neither moves nor drops it, and its "drop" fix moves the drop (output changes); its two machine-applicable fixes break builds: binding keeps borrowed temporaries alive (E0716), `drop(…)` loses the `let`'s type annotation (E0283) and expression attributes (`#[coroutine]`), and inside a macro rewrites the macro body (`drop()`, `drop($expr;`) | **looks new**, low (allow-by-default; `cargo fix` skips alternative suggestions, #104910); 1.98.0 and nightly; 26 UI tests; found by the lint-oracle check; [facts](hunt/lint-check.md) |
| 34 | lifetime-lint fixes that change meaning: `single_use_lifetimes` deletes a `#[may_dangle]` lifetime but not its attribute, which moves the unsafe promise to the next parameter and still compiles; it turns a derive field's `for<'a> fn(T::A<'a>)` into `'_` (E0637); `unused_lifetimes` removes the `for<'a>` that kept `where for<'a> Inherent: Clone` from being checked (E0277) | **looks new**, low (allow-by-default lints; `may_dangle` is unstable); 1.98.0 and nightly; found by the lint-oracle check; [facts](hunt/lint-check.md) |
| 35 | `dead_code` reports needed items as never used: a trait used only in the where-clause or a projection in the self type of an impl whose methods are called (stable since at least 1.80.0); the `#[define_opaque]` function that is an opaque type's only defining use (removing it: "unconstrained opaque type") | **looks new**; trait cases on stable (warn-by-default), opaque case nightly-only; 3 + 24 UI tests (one blesses the warning); found by the lint-oracle check; [facts](hunt/lint-check.md) |
| 36 | `trivial_numeric_casts` calls `5 as i16` an `i16`-to-`i16` cast, but the cast is what makes the literal `i16`: without it the program uses `i32` (prints 4, not 2; a `transmute` size mismatch in a UI test) | **looks new**, low (allow-by-default); 1.98.0 and nightly; found by the lint-oracle check; [repro](hunt/tests/lint-check/trivial-numeric-cast-literal.rs) |

Findings 1 and 2 are single-threaded: an ordinary `cargo build`, an edit, another
`cargo build`, and the metadata differs from a clean build of the edited source. Both come
Expand Down
121 changes: 121 additions & 0 deletions docs/hunt/lint-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Lint oracles: lints whose premise does not hold, and fixes that change the program

Facts for findings 33–36. Found by the lint-oracle check ([`checks.md`](../checks.md), check 16:
`mirth-lab lint-check`). It runs over the standalone UI tests with a column of
allow-by-default lints turned on. For each lint that warns, it acts on the warning: it allows
the lint, removes what the lint calls unused or unreachable, rewrites code to what the lint's
premise says is equivalent, or applies the lint's machine-applicable suggestion. It then checks
that nothing else changed. Reductions are in [`tests/lint-check/`](tests/lint-check). "1.98.0"
means stable 1.98.0; "nightly" means the pinned nightly-2026-10-06.

## 33. `let_underscore_drop`: the premise and both fixes

`let_underscore_drop` (allow-by-default) warns on `let _ = <expr>;` when the value has a
destructor. Its message: "non-binding let on a type that has a destructor". It offers two
machine-applicable fixes: "consider binding to an unused variable to avoid immediately dropping
the value" (`let _unused = …`) and "consider immediately dropping the value"
(`drop(…)`).

| reduction | what happens | 1.98.0 | nightly |
|---|---|---|---|
| [`let-underscore-drop-place.rs`](tests/lint-check/let-underscore-drop-place.rs) | `let _ = x;` with `x` a local: the lint fires, but a place expression is neither moved nor dropped by `let _`. The `drop(x)` fix moves the drop from the end of `main` to that line: the program prints `drop x` before `end of main` instead of after | yes | yes |
| [`let-underscore-drop-temporary.rs`](tests/lint-check/let-underscore-drop-temporary.rs) | `let _ = A.borrow();` with `A` a `const RefCell`: the `_unused` fix keeps the `Ref` past the temporary it borrows: E0716 | yes | yes |
| [`let-underscore-drop-inference.rs`](tests/lint-check/let-underscore-drop-inference.rs) | `let _: Vec<String> = Default::default();`: the `drop(…)` fix removes the type annotation with the `let`: E0283 | yes | yes |
| [`let-underscore-drop-macro.rs`](tests/lint-check/let-underscore-drop-macro.rs) | `let _ = wrap!(String::new());` where the macro expands to `identity($x)`: the `drop(…)` fix replaces `identity($x)` in the macro definition with `drop()` and leaves the `let` alone: E0061 | yes | yes |
| [`let-underscore-drop-coroutine.rs`](tests/lint-check/let-underscore-drop-coroutine.rs) | `let _ = #[coroutine] \|\| yield 42;`: the `drop(…)` fix drops the expression's attribute: "`yield` can only be used in `#[coroutine]` closures" | — (unstable) | yes |

In the UI tests, 26 tests have a fix that does not compile or changes the output. Most fail to
infer a type once `drop(…)` replaces an annotated `let` (E0282, E0283, E0790). Others: borrowed
temporaries kept alive (E0716), `match` arms whose types the binding unified (E0308), three
coroutine tests that lose `#[coroutine]`, macros
(`lifetimes/rvalue-lifetime-drop-timing.rs`: the fix turns the macro body's
`let $pat = $expr;` into `drop($expr;`, a syntax error), and
`async-await/async-fn-send-uses-nonsend.rs`, where binding keeps a non-`Send` value alive
across an `await` ("future cannot be sent between threads safely"). In 5 run-pass tests a fix
compiles and changes the output: the drop-order tests (`destructuring-assignment/drop-order.rs`,
`drop/drop_order.rs`, `drop/issue-2735-2.rs`, `lifetimes/rvalue-lifetime-drop-timing.rs`).

`cargo fix` applies neither fix: rustfix skips a diagnostic with alternative suggestions
(#104910, open). An editor's quick fix, or a tool that applies one suggestion, uses them.
Related, about the language and not the lint: #97305 (`let _ = var` does not move `var`, closed
as intended).

## 34. Lifetime lints: fixes that change meaning

| reduction | lint | what happens | 1.98.0 | nightly |
|---|---|---|---|---|
| [`single-use-lifetimes-may-dangle.rs`](tests/lint-check/single-use-lifetimes-may-dangle.rs) | `single_use_lifetimes` | in `unsafe impl<#[may_dangle] 'a, T> Drop for Pr<'a, T>`, the fix deletes `'a, ` but not its attribute. The result `unsafe impl<#[may_dangle] T> Drop for Pr<'_, T>` compiles: the unsafe `may_dangle` promise moved from `'a` to `T` | — (unstable attribute) | yes |
| [`single-use-lifetimes-derive-hrtb.rs`](tests/lint-check/single-use-lifetimes-derive-hrtb.rs) | `single_use_lifetimes` | `x: for<'a> fn(T::SomeType<'a>)` in a `#[derive(Clone)]` struct: the fix gives `fn(T::SomeType<'_>)`: E0637 "`'_` cannot be used here" | yes | yes |
| [`unused-lifetimes-global-bound.rs`](tests/lint-check/unused-lifetimes-global-bound.rs) | `unused_lifetimes` | `where for<'a> Inherent: Clone`: the binder keeps the bound from being global, so it is not checked at the definition. The fix removes `for<'a> ` and the bound is checked: E0277 | yes | yes |

In the UI tests, `drop/dropck-eyepatch-reorder.rs` has the `may_dangle` case where the
attribute lands on a parameter that rejects it (E0199). Both lints are allow-by-default.
Earlier fixes in the same area, all closed: #117965 and #120148 (`single_use_lifetimes` fixes
that do not compile), #141758 (`unused_lifetimes` and unsafe binders).

## 35. `dead_code`: items reported unused that the program needs

`dead_code` is warn-by-default. "X is never used" invites deleting X; in these cases deleting it
(with everything else the lint reports in the same crate) breaks the program.

| reduction | what happens | 1.80.0 | 1.98.0 | nightly |
|---|---|---|---|---|
| [`dead-code-trait-in-bound.rs`](tests/lint-check/dead-code-trait-in-bound.rs) | "trait `Bar` is never used": `Bar` is in the where-clause of `impl<const N: usize> Foo<N> where [u8; N]: Bar<[(); N]>`, and `main` calls that impl's `foo` | yes | yes | yes |
| [`dead-code-trait-in-projection.rs`](tests/lint-check/dead-code-trait-in-projection.rs) | "trait `Mirror` is never used": `<A as Mirror>::Me` is in the self type of `impl<A> Foo<A, <A as Mirror>::Me>`, whose `m` `main` calls | yes | yes | yes |
| [`dead-code-defining-use.rs`](tests/lint-check/dead-code-defining-use.rs) | "function `assign` is never used" for the `#[define_opaque(Qux)]` function that is the only defining use of a used opaque type; removing it gives "unconstrained opaque type" | — | — (unstable) | yes |

The trait cases are three UI tests: `const-generics/issues/issue-69654-run-pass.rs` (which
blesses the warning: `//~ WARN trait `Bar` is never used`),
`nll/user-annotations/normalize-self-ty.rs` and `mir/issue-101844.rs`. The opaque-type case is
24 UI tests (`type-alias-impl-trait/*`, `lint/improper-ctypes/lint-73249-3.rs` and `-5.rs`, …).
Five more `dead_code` tests fail for reasons the check cannot settle (an item named through a
module path or a `decl_macro`, an `eii` attribute, an associated-const binding of the
incomplete `gca` feature); they are listed in the sweep's results, not counted here. Related
but different: #47569 (a struct used only through an associated constant, open), #110332
(below).

## 36. `trivial_numeric_casts` on an unsuffixed literal

[`trivial-numeric-cast-literal.rs`](tests/lint-check/trivial-numeric-cast-literal.rs):
`let x = 5 as i16;` warns "trivial numeric cast: `i16` as `i16`". The literal is `i16` only
because of the cast: without it, `x` is `i32` and the program prints 4 instead of 2. In
`tests/ui/packed/packed-struct-generic-layout.rs` the cast picks a generic struct's field type
(`S { …, c: 0b10000001_10000001 as i16 }`); without it, a `transmute` between the struct and
an array no longer has matching sizes (E0512). Allow-by-default lint; 1.98.0 and nightly.
Related lint issues, none about literals: #23739 (type aliases, closed), #161339 (FnDef to fn
pointer, open).

## Minor, not numbered

- `unreachable_pub` (allow-by-default): its `pub(crate)`/`pub(super)` fix does not compile when
a `decl_macro` used elsewhere names the item (`hygiene/lexical.rs`), and in
`imports/overwrite-different-ambig-2.rs` the narrower visibility turns the
`ambiguous_glob_imports` warning into E0659. In
`test-attrs/custom-test-frameworks/issue-107454.rs` it fires on a non-`pub` `#[test_case]`
function and its fix (`pub(crate)fn`, no space) leaves the warning. 29 tests where one fix
needs another compile when all of a lint's fixes are applied together, which is what
`cargo fix` does; those are not counted.
- `missing_copy_implementations` (allow-by-default) on a type with a manual `ToOwned` impl: the
`Clone` that `Copy` requires conflicts with the blanket `impl<T: Clone> ToOwned for T`
(`autoref-autoderef/auto-deref-on-cow-regression-91489.rs`).

## Known, not counted

- `dead_code` reports inherent associated types as never used (`associated-inherent-types/*`):
#110332, open.
- `unused_qualifications` removing a qualifier that the unqualified path needs to be
unambiguous (`imports/ambiguous-trait-with-mixed-import-paths.rs`, E0659): #163369, open.

## What the check leaves out, and why

- An item `dead_code` reports may be used by code the lint exempts: an item with
`#[allow(dead_code)]`, an impl of such a type, or an item named with `_`. Deleting it then
breaks those users. That is the lint's design (it reports what only dead code uses), so the
check deletes and accepts errors only inside such items.
- Unreachable code still takes part in type inference. Deleting an unreachable tail
expression, or a statement that fixed a type, can change what the block infers. The check
keeps tails and accepts inference errors (E0282–E0284).
- `missing_copy_implementations` in `staged_api` crates: the added `impl Copy` needs a
stability attribute.
- `#163604` (deprecation and the crate's `rust-version`): `-Zhint-msrv` filters only lints
declared with `@msrv`, and no lint at the pin declares one, so there is nothing to check yet.
16 changes: 16 additions & 0 deletions docs/hunt/tests/lint-check/dead-code-defining-use.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#![feature(type_alias_impl_trait)]

pub trait Baz {}
impl Baz for u32 {}

pub type Qux = impl Baz;

// warning: function `assign` is never used; removing it gives "unconstrained opaque type"
#[define_opaque(Qux)]
fn assign() -> Qux {
3
}

pub fn take(_: Option<Qux>) {}

fn main() {}
17 changes: 17 additions & 0 deletions docs/hunt/tests/lint-check/dead-code-trait-in-bound.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// warning: trait `Bar` is never used. It is used: the impl below applies only where
// `[u8; N]: Bar<..>` holds, and `main` calls its `foo`. Removing `Bar` (and its impl) breaks
// the call.
trait Bar<T> {}
impl<T> Bar<T> for [u8; 7] {}

struct Foo<const N: usize>;
impl<const N: usize> Foo<N>
where
[u8; N]: Bar<[(); N]>,
{
fn foo() {}
}

fn main() {
Foo::<7>::foo();
}
17 changes: 17 additions & 0 deletions docs/hunt/tests/lint-check/dead-code-trait-in-projection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// warning: trait `Mirror` is never used. It is used: `<A as Mirror>::Me` is in the self type
// of the impl that `main` calls into.
trait Mirror {
type Me;
}
impl<T> Mirror for T {
type Me = T;
}

struct Foo<A, B>(A, B);
impl<A> Foo<A, <A as Mirror>::Me> {
fn m(_: A) {}
}

fn main() {
<Foo<u32, u32>>::m(22);
}
6 changes: 6 additions & 0 deletions docs/hunt/tests/lint-check/let-underscore-drop-coroutine.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#![feature(coroutines, stmt_expr_attributes)]
#![warn(let_underscore_drop)]

fn main() {
let _ = #[coroutine] || yield 42;
}
7 changes: 7 additions & 0 deletions docs/hunt/tests/lint-check/let-underscore-drop-inference.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#![warn(let_underscore_drop)]

fn main() {
let _ = Vec::<String>::new().into_iter().collect::<Vec<_>>();
let _: Vec<String> = Default::default();
let _ = String::from("a").chars().rev().collect::<String>();
}
15 changes: 15 additions & 0 deletions docs/hunt/tests/lint-check/let-underscore-drop-macro.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#![warn(let_underscore_drop)]

macro_rules! wrap {
($x:expr) => {
identity($x)
};
}

fn identity<T>(x: T) -> T {
x
}

fn main() {
let _ = wrap!(String::new());
}
14 changes: 14 additions & 0 deletions docs/hunt/tests/lint-check/let-underscore-drop-place.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#![warn(let_underscore_drop)]

struct D(&'static str);
impl Drop for D {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}

fn main() {
let x = D("x");
let _ = x; // a place: `x` is neither moved nor dropped here
println!("end of main");
}
Loading
Loading