Scarlet Industries

Mordant

Lints that find code where the type system is not enforcing the invariants the code depends on.

Mordant is a lint pack for Rust, run through dylint. It looks for places where an invariant lives in a convention or a runtime check instead of in a type. A struct with 3 booleans has 8 states. If the code handles 4 of them, the type permits 4 states nobody wrote.

Mordant does not find every defect. What it reports is real: a lint that cannot prove its claim from the code stays silent, and anything heuristic is off until your configuration turns it on.

Run

Mordant runs against stable Rust projects. The lints build against a pinned nightly, which dylint fetches on its own. Your toolchain does not change.

cargo install cargo-dylint dylint-link

Add the library to your workspace Cargo.toml.

[workspace.metadata.dylint]
libraries = [{ git = "https://github.com/scarletindustries/mordant" }]

Run the lints. --fix applies the machine-applicable rewrites as it goes.

cargo dylint --all
cargo dylint --all --fix

The lints

Each lint below shows the code it flags, what it reports, and the type that replaces it. Every lint is warn by default; a few stay silent until configuration names the shapes they look for.

Lint Flags
stringly_error a public signature returning Result<_, String>
stringified_error the site where a typed error is collapsed into a string
exclusive_options Option fields that are never populated together
parallel_bools bool fields only ever assigned as a pair
nonidentity_key a map keyed on something that is not an identity
bypassed_validator a struct literal that skips the type's validating constructor
pub_invariant_fields a validated type whose field is assignable from outside
guard_flag a bool that several methods test and bail on at entry
wildcard_local_enum a _ arm absorbing every future variant of a local enum
discarded_error .ok(); in statement position
unread_error_variant an error variant whose structure is never read
asymmetric_guard a guard that never reads state its action mutates
stale_safety_comment a SAFETY: comment naming an identifier that no longer exists
unit_mismatch addition or comparison between names that claim different units
stale_panic_message a panic, assert, or expect message naming a gone identifier
lock_order two locks the crate acquires in both orders
forbidden_reach a config-declared ban violated by a concrete call path
unread_none an Option field every reader unwraps and no reader handles
insert_then_unwrap map.get(&k).unwrap() re-fetching what an insert just proved present
overwide_parameter a panicking arm for a variant no existing call site passes
narrowed_return a panicking arm for a variant the callee never constructs
flag_cluster a struct with several independent bool fields
stored_projection a field whose value is decided by a sibling field at every construction

stringly_error

A string error has no variants to match on, so callers cannot tell failures apart without parsing prose.

Flagged
pub fn parse_port(x: u32) -> Result<u32, String> {
    if x > 0 {
        Ok(x)
    } else {
        Err("zero".to_owned())
    }
}
What Mordant reports
warning: public signature returns `Result<_, String>`
  = help: a string error has no variants to match on; define an error enum and return it
Fixed
pub enum PortError {
    Zero,
}

pub fn parse_port(x: u32) -> Result<u32, PortError> {
    if x > 0 {
        Ok(x)
    } else {
        Err(PortError::Zero)
    }
}

Flags &str and Cow<str> in the error position too. Private functions, main, and foreign-trait impls such as FromStr are not flagged: those signatures are not yours. Box<dyn Error> is flagged only when stringly-error-include-box-dyn turns it on.

stringified_error

The destruction site. stringly_error flags the signature that demands a string; this flags the expression that performs the collapse.

Flagged
fn load(r: Result<u32, ParseError>) -> Result<u32, String> {
    r.map_err(|e| e.to_string())
}
What Mordant reports
warning: typed error `ParseError` collapsed into a string
  = help: return the error type, or an error enum with a variant that wraps it
Fixed
enum Wrapped {
    Parse(ParseError),
}

fn load(r: Result<u32, ParseError>) -> Result<u32, Wrapped> {
    r.map_err(Wrapped::Parse)
}

Reached by to_string and by format! alike.

exclusive_options

Several Option fields that together encode one state: every construction in the crate sets at most one of them to Some.

Flagged
struct Outcome {
    ok: Option<u32>,
    err: Option<String>,
}

fn success() -> Outcome {
    Outcome {
        ok: Some(1),
        err: None,
    }
}

fn failure() -> Outcome {
    Outcome {
        ok: None,
        err: Some("boom".to_owned()),
    }
}
What Mordant reports
warning: no construction of `Outcome` sets more than one of `ok`, `err` to `Some` (2 sites checked)
  = help: these fields encode one state; an enum with one variant per field represents it
Fixed
enum Outcome {
    Ok(u32),
    Err(String),
}

fn success() -> Outcome {
    Outcome::Ok(1)
}

fn failure() -> Outcome {
    Outcome::Err("boom".to_owned())
}

The claim is proved, not guessed: the lint fires only on structs private to the crate, with every construction a literal and no later field assignment. One site that sets both fields, a field initialized from a variable, or a single construction site silences it.

parallel_bools

Bool fields that are never assigned separately: every write to one sits in the same block as a write to the other, across at least 2 functions.

Flagged
struct Task {
    running: bool,
    done: bool,
    retries: u32,
}

impl Task {
    fn start(&mut self) {
        self.running = true;
        self.done = false;
    }

    fn finish(&mut self) {
        self.running = false;
        self.done = true;
    }
}
What Mordant reports
warning: the bool fields `done`, `running` of `Task` are only ever assigned together (2 sites in 2 functions)
  = help: together these fields encode one state; an enum makes the unpaired combinations unrepresentable
Fixed
enum State {
    Running,
    Done,
}

struct Task {
    state: State,
    retries: u32,
}

impl Task {
    fn start(&mut self) {
        self.state = State::Running;
    }

    fn finish(&mut self) {
        self.state = State::Done;
    }
}

One lone write to either field anywhere disproves the pairing and silences the lint.

nonidentity_key

A map keyed on something that is not the canonical identity of what it names: a span with no file, a float's bits, a pointer cast to an integer.

Flagged
m.insert(*span, index);
pool.insert(constant.to_bits(), index);
ids.insert(value.as_ptr() as usize, ());
What Mordant reports
warning: map keyed on `Span`, which this project declares is not an identity
  = help: key on the canonical identity of the thing this names

warning: map keyed on `to_bits()` of a float
  = help: for boxed or interned values the bits are a pointer, not the value; key on the canonical identity
Fixed
m.insert((file, *span), index);
pool.insert(constant, index);
ids.insert(value, ());

Which types and forms count is declared per project in dylint.toml; with no configuration this lint is silent, because every one of these is legitimate somewhere. Lookups are flagged as well as inserts. The opt-in composite mode extends the check to tuple and one-level struct keys: (Span, u32) is flagged, and (FileId, Span) is accepted when FileId is declared as a fixing type. The keys are under Configuration.

bypassed_validator

A struct literal that bypasses a constructor whose body rejects some value it then stores, so the check never runs.

Flagged
struct Port {
    n: u16,
}

impl Port {
    fn new(n: u32) -> Result<Port, ()> {
        if n <= u16::MAX as u32 {
            Ok(Port { n: n as u16 })
        } else {
            Err(())
        }
    }
}

fn bypass() -> Port {
    Port { n: 0 }
}
What Mordant reports
warning: `Port` is constructed by literal here, but `Port::new` validates construction
  = help: construct through the validating function, or move this literal into the type's impl
Fixed
struct Port {
    n: u16,
}

impl Port {
    fn new(n: u32) -> Result<Port, ()> {
        if n <= u16::MAX as u32 {
            Ok(Port { n: n as u16 })
        } else {
            Err(())
        }
    }
}

fn build(n: u32) -> Result<Port, ()> {
    Port::new(n)
}

Literals inside the type's own impls stay legal, trait impls such as Default included: trait impls of the type still count as the type's own code.

pub_invariant_fields

The same promise attacked from the other side: a validated type's field visible outside its module lets any holder assign around the constructor's check.

Flagged
pub struct Level {
    pub value: u8,
}

impl Level {
    pub fn new(v: u8) -> Result<Level, ()> {
        if v <= 10 {
            Ok(Level { value: v })
        } else {
            Err(())
        }
    }
}
What Mordant reports
warning: `inner::Level` is validated by `Level::new`, but this field is assignable outside its module
  = help: make the field private; the validated invariant otherwise holds only until the first write
Fixed
pub struct Level {
    value: u8,
}

impl Level {
    pub fn new(v: u8) -> Result<Level, ()> {
        if v <= 10 {
            Ok(Level { value: v })
        } else {
            Err(())
        }
    }

    pub fn get(&self) -> u8 {
        self.value
    }
}

guard_flag

A bool field that 2 or more methods test and bail on at entry. The ordering invariant is enforced at runtime, per method, and only where someone remembered.

Flagged
struct Conn {
    ready: bool,
    sent: u32,
}

impl Conn {
    fn send(&mut self) {
        if !self.ready {
            return;
        }
        self.sent += 1;
    }

    fn flush(&mut self) {
        if !self.ready {
            return;
        }
        self.sent = 0;
    }
}
What Mordant reports
warning: `ready` is tested and bailed on at the start of 2 methods of `Conn`
  = help: the ordering invariant lives at runtime; a separate type for the guarded state enforces it at compile time
Fixed
struct Idle;

struct Ready {
    sent: u32,
}

impl Idle {
    fn connect(self) -> Ready {
        Ready { sent: 0 }
    }
}

impl Ready {
    fn send(&mut self) {
        self.sent += 1;
    }

    fn flush(&mut self) {
        self.sent = 0;
    }
}

A type per state enforces the ordering everywhere at compile time: there is no send to call until connect has returned the type that has one.

wildcard_local_enum

A _ arm over a small crate-local enum. It absorbs every future variant: adding one compiles without a whisper and routes it to the old behavior.

Flagged
enum Op {
    Add,
    Sub,
    Mul,
}

fn cost(o: Op) -> i32 {
    match o {
        Op::Add => 1,
        _ => 0,
    }
}
What Mordant reports
warning: this arm absorbs every future variant of `Op` (3 variants today)
help: list the remaining variants; the compiler then flags every new one added
   |
LL -         _ => 0,
LL +         Op::Sub | Op::Mul => 0,
Fixed
enum Op {
    Add,
    Sub,
    Mul,
}

fn cost(o: Op) -> i32 {
    match o {
        Op::Add => 1,
        Op::Sub | Op::Mul => 0,
    }
}

This is the one lint with a machine-applicable fix: --fix writes exactly that, spelled with the same path prefix the sibling arms use. #[non_exhaustive] does not exempt an enum — it constrains downstream crates only, and this lint reaches a match just when the enum is defined in the crate being compiled. Negative extractors stay legal: _ => None and _ => false ask whether the value is one shape, and future variants are correctly not that shape. Enums above wildcard-local-enum-max-variants, 12 by default, are left alone.

discarded_error

.ok(); in statement position: the Result becomes an Option that is immediately dropped, so the error is unobservable.

Flagged
fn cleanup() {
    remove_socket_file().ok();
}
What Mordant reports
warning: `.ok()` in statement position discards the error unobserved
  = help: handle the error, or state the discard with `let _ = ...`
Fixed
fn cleanup() {
    if let Err(e) = remove_socket_file() {
        eprintln!("could not remove socket file: {e}");
    }
}

let _ = remove_socket_file(); also clears the lint, and is the right spelling when the discard is deliberate — it states the intent and survives review. It leaves the error just as unobserved, so handling it is the stronger repair. .ok(); reads like handling and handles nothing.

unread_error_variant

A crate-private enum variant that is constructed somewhere but never named by a pattern outside the enum's own trait impls, so its structure is never read.

Flagged
enum LoadError {
    NotFound,
    Corrupt(String),
}

fn handle(x: u32) -> u32 {
    match load(x) {
        Ok(n) => n,
        Err(LoadError::NotFound) => 0,
        Err(other) => {
            eprintln!("{other}");
            0
        }
    }
}
What Mordant reports
warning: `LoadError::Corrupt` is constructed here, but no pattern outside `LoadError`'s trait impls ever names it
  = help: the variant's structure is never read; handle it distinctly or collapse it into another variant
Fixed
enum LoadError {
    NotFound,
    Corrupt(String),
}

fn handle(x: u32) -> u32 {
    match load(x) {
        Ok(n) => n,
        Err(LoadError::NotFound) => 0,
        Err(LoadError::Corrupt(why)) => {
            eprintln!("rebuilding index: {why}");
            0
        }
    }
}

A Display or From impl must match every variant to exist, so it proves nothing; a pattern anywhere else is the crate genuinely reading the structure. An enum that no pattern anywhere consumes, a Display-only error type for instance, stays entirely unflagged: matching is not how that enum is consumed.

asymmetric_guard

A permission-flavored guard that never reads state its action mutates: can_x gates a call that touches a field the predicate never looks at.

Flagged
struct Sched {
    queue: Vec<u32>,
    conns: Vec<u32>,
}

impl Sched {
    fn can_donate(&self) -> bool {
        self.queue.is_empty()
    }

    fn donate(&mut self) {
        if !self.can_donate() {
            return;
        }
        self.detach();
    }

    fn detach(&mut self) {
        self.queue.clear();
        self.conns = Vec::new();
    }
}
What Mordant reports
warning: `detach` is gated by `can_donate`, but touches `conns` which the guard never reads
  = help: a guard blind to part of the state its action manipulates cannot be sound; align what the pair reads
Fixed
struct Sched {
    queue: Vec<u32>,
    conns: Vec<u32>,
}

impl Sched {
    fn can_donate(&self) -> bool {
        self.queue.is_empty() && self.conns.is_empty()
    }

    fn donate(&mut self) {
        if !self.can_donate() {
            return;
        }
        self.detach();
    }

    fn detach(&mut self) {
        self.queue.clear();
        self.conns = Vec::new();
    }
}

The guard and the action have to agree on the fields that matter. A predicate that cannot see part of what it authorizes answers a different question than the one being asked.

stale_safety_comment

A SAFETY: comment whose backticked identifiers no longer exist in the file, this crate, or any crate it links.

Flagged
fn read(p: *const u64) -> u64 {
    // SAFETY: `frames_lock` is held for the duration of this read.
    unsafe { *p }
}
What Mordant reports
warning: this SAFETY comment names `frames_lock`, which appears nowhere in this file's code, this crate, or any crate it links
  = help: the guard this justification described has moved or gone; update the comment or restore the guard
Fixed
fn read(p: *const u64, guard: &std::sync::Mutex<()>) -> u64 {
    let _held = guard.lock().unwrap();
    // SAFETY: `_held` keeps `guard` locked while the pointer is read.
    unsafe { *p }
}

A safety justification that names a guard a refactor has since removed is documentation asserting an invariant nothing provides. Names that still exist in the file, as a definition in the crate, or in a linked crate, stay silent.

unit_mismatch

Addition, subtraction, or comparison between values whose names claim different units: timeout_ms + deadline_ns compiles and is always wrong.

Flagged
struct Timing {
    timeout_ms: u64,
    deadline_ns: u64,
}

fn remaining(t: &Timing) -> u64 {
    t.timeout_ms + t.deadline_ns
}
What Mordant reports
warning: `timeout_ms` claims ms and `deadline_ns` claims ns; arithmetic between them mixes units
  = help: convert one side, or rename whichever name is lying about its unit
Fixed
struct Timing {
    timeout_ms: u64,
    deadline_ns: u64,
}

fn remaining(t: &Timing) -> u64 {
    t.timeout_ms * 1_000_000 + t.deadline_ns
}

Multiplication and division stay silent: they are how units legitimately convert. Operands with no unit suffix, and two names that share a class (ms / millis), are fine.

stale_panic_message

A panic, assert, or expect message whose backticked identifiers no longer exist, so a crash site sends the reader looking for a name that is gone.

Flagged
fn slot(t: &Table, i: usize) -> u32 {
    *t.slots.get(i).expect("guarded upstream by `frame_lock`")
}
What Mordant reports
warning: this message names `frame_lock`, which appears nowhere in this file's code, this crate, or any crate it links
  = help: whoever reads this at a crash site will search for a name that no longer exists; update the message
Fixed
fn slot(t: &Table, i: usize) -> u32 {
    *t.slots
        .get(i)
        .expect("index checked against `slots` length")
}

The same existence check as stale_safety_comment, aimed at the string a backtrace will print. A message with no backticked names, or names that still exist, is left alone.

lock_order

Two locks the crate acquires in both orders, with both locations named: the shape of a deadlock waiting for the right interleaving.

Flagged
struct Pair {
    a: Mutex<u32>,
    b: Mutex<u32>,
}

impl Pair {
    fn ab(&self) -> u32 {
        let ga = self.a.lock().unwrap();
        let gb = self.b.lock().unwrap();
        *ga + *gb
    }

    fn ba(&self) -> u32 {
        let gb = self.b.lock().unwrap();
        let ga = self.a.lock().unwrap();
        *ga + *gb
    }
}
What Mordant reports
warning: `a` is locked before `b` here, but `b` before `a` in `ba`
  = help: both orders existing is the shape of a deadlock; pick one order and hold to it everywhere
Fixed
struct Pair {
    a: Mutex<u32>,
    b: Mutex<u32>,
}

impl Pair {
    fn ab(&self) -> u32 {
        let ga = self.a.lock().unwrap();
        let gb = self.b.lock().unwrap();
        *ga + *gb
    }

    fn ba(&self) -> u32 {
        let ga = self.a.lock().unwrap();
        let gb = self.b.lock().unwrap();
        *ga + *gb
    }
}

Conservative on purpose: only .lock(), .read() and .write() on a Mutex or RwLock, only guards bound by let in the same block, and only second acquisitions later in that block with no drop(guard) in between. One consistent order is fine.

forbidden_reach

A config-declared ban — from this function, never reach that definition — violated by a concrete call path, printed as a witness chain.

Flagged
fn hot_path(n: u32) -> u32 {
    helper(n)
}

fn helper(n: u32) -> u32 {
    let mut v = Vec::new();
    v.push(n);
    v[0]
}
What Mordant reports
warning: `hot_path` reaches `std::vec::Vec::push`, which this project bans from it: hot_path -> helper -> std::vec::Vec::push
  = help: one finding per banned definition reached; every arrow is a real call in this crate, so break the chain or amend the rule
Fixed
fn hot_path(n: u32) -> u32 {
    n + 1
}

fn helper(n: u32) -> u32 {
    let mut v = Vec::new();
    v.push(n);
    v[0]
}

Silent with no configuration, because every reach is legitimate somewhere. Dynamic dispatch and function pointers are invisible to the walk, so a clean run proves nothing — but a finding is a path that exists. The keys are under Configuration.

unread_none

An Option field every reader unwraps and no reader handles: a state nobody survives, usually a two-phase object wanting two types.

Flagged
struct Conn {
    sock: Option<u32>,
}

impl Conn {
    fn new() -> Conn {
        Conn { sock: None }
    }

    fn ready(&self) -> u32 {
        self.sock.unwrap()
    }

    fn doubled(&self) -> u32 {
        self.sock.unwrap() + 1
    }
}
What Mordant reports
warning: every read of `Conn.sock` assumes `Some` (2 unwraps, 0 sites handle `None`)
  = help: `None` is a state no reader survives; split the phases into types, or store `T` directly
Fixed
struct Conn {
    sock: u32,
}

impl Conn {
    fn new(sock: u32) -> Conn {
        Conn { sock }
    }

    fn ready(&self) -> u32 {
        self.sock
    }

    fn doubled(&self) -> u32 {
        self.sock + 1
    }
}

Two or more unwraps and not one site that handles None. One handled read anywhere silences the field; a single unwrap is not a pattern.

insert_then_unwrap

map.get(&k).unwrap() re-fetching what map.insert(k, ..) just proved present, with nothing in between that could disturb either.

Flagged
fn put(m: &mut HashMap<u32, u32>) -> u32 {
    m.insert(1, 2);
    *m.get(&1).unwrap()
}
What Mordant reports
warning: this unwrap re-fetches `m[1]`, which the insert above just proved present
  = help: keep the inserted value, or use the entry API; the panic path and the second lookup both vanish
Fixed
fn put(m: &mut HashMap<u32, u32>) -> u32 {
    m.insert(1, 2);
    2
}

A call or an assignment to the map or the key in between makes the presence unknowable and silences the lint. A lookup of a different key is fine.

overwide_parameter

A panicking arm for a variant no existing call site passes: the parameter type is wider than the function's domain, and narrowing it turns the panic into a compile error.

Flagged
enum Shape {
    Circle(u32),
    Square(u32),
    Line,
}

fn area(s: Shape) -> u32 {
    match s {
        Shape::Circle(r) => 3 * r * r,
        Shape::Square(w) => w * w,
        Shape::Line => unreachable!("lines have no area"),
    }
}

fn draw() -> u32 {
    area(Shape::Circle(2)) + area(Shape::Square(3))
}
What Mordant reports
warning: all 2 call sites of `area` pass `Circle`, `Square`; this arm panics on `Line`, which no existing caller sends
  = help: the parameter is wider than the function's domain; narrow the type and the panic becomes a compile error for future callers
Fixed
enum Area {
    Circle(u32),
    Square(u32),
}

fn area(s: Area) -> u32 {
    match s {
        Area::Circle(r) => 3 * r * r,
        Area::Square(w) => w * w,
    }
}

fn draw() -> u32 {
    area(Area::Circle(2)) + area(Area::Square(3))
}

Constructor literals only — anything else makes the call-site set unknowable and the lint silent. One site that passes the panicking variant is enough.

narrowed_return

A panicking arm for a variant the callee provably never constructs: the return type promises more than the function delivers.

Flagged
enum Token {
    Word(u32),
    Space,
    Eof,
}

fn next_token(n: u32) -> Token {
    if n == 0 {
        Token::Space
    } else {
        Token::Word(n)
    }
}

fn count(n: u32) -> u32 {
    match next_token(n) {
        Token::Word(w) => w,
        Token::Space => 0,
        Token::Eof => unreachable!("the tokenizer never yields Eof here"),
    }
}
What Mordant reports
warning: `next_token` only ever returns `Space`, `Word`; this arm panics on `Eof`, which it never constructs
  = help: the return type promises more than the function delivers; narrow it and this arm becomes unnecessary at compile time
Fixed
enum Token {
    Word(u32),
    Space,
}

fn next_token(n: u32) -> Token {
    if n == 0 {
        Token::Space
    } else {
        Token::Word(n)
    }
}

fn count(n: u32) -> u32 {
    match next_token(n) {
        Token::Word(w) => w,
        Token::Space => 0,
    }
}

Every return of the producer has to be a constructor literal. One return that is not, or one construction of the panicking variant, silences it.

flag_cluster

A named-field struct carrying several independent bool fields. n bools is 2^n representable states; if fewer are legal, an enum names the ones that are.

Flagged
struct Refusals {
    too_big: bool,
    exhausted: bool,
    fragmented: bool,
    slots_leaked: u64,
}
What Mordant reports
warning: the 3 bool fields `too_big`, `exhausted`, `fragmented` of `Refusals` are 8 representable states
  = help: if fewer are legal, name them in an enum; if the layout is fixed elsewhere, give it a repr
Fixed
enum Refusal {
    TooBig,
    Exhausted,
    Fragmented,
}

struct Refusals {
    kind: Refusal,
    slots_leaked: u64,
}

Silent on any struct with an explicit repr — its layout is dictated from outside Rust, so all 2^n states may genuinely be reachable. The threshold is flag-cluster-min-bools, 3 by default. Unlike parallel_bools, this one does not wait to see how the fields are assigned.

stored_projection

Two fields of one type whose constant values agree one-for-one across every place the type is built: one is a stored projection of the other.

Flagged
enum Ceiling {
    Words,
    Types,
    Blocks,
}

struct Exceeded {
    ceiling: Ceiling,
    limit: u32,
    wanted: u32,
}

fn words(wanted: u32) -> Exceeded {
    Exceeded {
        ceiling: Ceiling::Words,
        limit: MAX_WORDS,
        wanted,
    }
}

fn types(wanted: u32) -> Exceeded {
    Exceeded {
        ceiling: Ceiling::Types,
        limit: MAX_TYPES,
        wanted,
    }
}

fn blocks(wanted: u32) -> Exceeded {
    Exceeded {
        ceiling: Ceiling::Blocks,
        limit: MAX_BLOCKS,
        wanted,
    }
}
What Mordant reports
warning: `ceiling` and `limit` of `Exceeded` agree one-for-one across all 3 places it is constructed, so one is a stored projection of the other
  = help: give the deciding field a method returning the other and drop the stored copy, so a pairing the constructors never make cannot be written
Fixed
enum Ceiling {
    Words,
    Types,
    Blocks,
}

impl Ceiling {
    fn limit(self) -> u32 {
        match self {
            Ceiling::Words => MAX_WORDS,
            Ceiling::Types => MAX_TYPES,
            Ceiling::Blocks => MAX_BLOCKS,
        }
    }
}

struct Exceeded {
    ceiling: Ceiling,
    wanted: u32,
}

fn words(wanted: u32) -> Exceeded {
    Exceeded {
        ceiling: Ceiling::Words,
        wanted,
    }
}

fn types(wanted: u32) -> Exceeded {
    Exceeded {
        ceiling: Ceiling::Types,
        wanted,
    }
}

fn blocks(wanted: u32) -> Exceeded {
    Exceeded {
        ceiling: Ceiling::Blocks,
        wanted,
    }
}

Fires only when one of the two is a variant of an enum this crate defined at every site. Silent on any type with an explicit repr, on foreign types, and below stored-projection-min-sites construction sites, 2 by default.

Configuration

Configure per project in dylint.toml at the workspace root, under [mordant].

Key Default Function
baseline unset ratchet file name; enables baseline mode
nonidentity-key-types empty type paths that are never a valid map key in this project
nonidentity-key-forms empty opt-in expression forms: "to-bits", "ptr-cast"
nonidentity-key-methods empty method paths that never produce a valid key
nonidentity-key-composite false also flag tuple and struct keys that carry a denied type
nonidentity-key-fixes empty types whose presence in a composite key restores identity
wildcard-local-enum-max-variants 12 the lint stays silent above this many variants
exclusive-options-min-fields 2 minimum Option fields before a struct is considered
stringly-error-include-box-dyn false also flag Box<dyn Error> as a stringly error type
flag-cluster-min-bools 3 bool fields at which flag_cluster fires
stored-projection-min-sites 2 construction sites at which stored_projection reads a correspondence
forbidden-reach empty array of from / never reachability bans; silent with none
[mordant]
nonidentity-key-types = ["my_crate::span::Span"]
nonidentity-key-forms = ["ptr-cast"]
nonidentity-key-methods = ["my_crate::value::Value::to_bits"]
wildcard-local-enum-max-variants = 12

# Opt-in: flag composite keys (tuples, structs one level deep) that carry a
# denied type unless one of the fixing types sits beside it. With these two
# lines, (Span, u32) is flagged and (FileId, Span) is accepted.
nonidentity-key-composite = true
nonidentity-key-fixes = ["my_crate::span::FileId"]

# Reachability bans. A finding prints the concrete call chain; dynamic
# dispatch is invisible to the walk, so a clean run proves nothing, but every
# finding is a path that exists.
[[mordant.forbidden-reach]]
from = "sched::pick"
never = ["std::vec::Vec::push", "core::panicking"]

Baseline

A baseline accepts the findings you already have, so Mordant gates CI on an existing codebase from the first day. Point the configuration at a file.

[mordant]
baseline = "mordant-baseline.toml"

Generate or regenerate it. The write run emits nothing and rewrites the file instead.

MORDANT_BASELINE_WRITE=1 cargo dylint --all

The file records a count per lint and file, in a section per crate. A run suppresses that many findings and reports only the overflow, so new problems surface while the recorded ones stay recorded.

[my_crate]
"stringly_error:src/config.rs" = 3
"guard_flag:src/conn.rs" = 1

Counts, not line numbers: lines drift with every edit, and a per-file count is the only key that survives normal development. Moving a finding between files consumes allowance in one file and overflows in the other, which is the ratchet working as intended. When you fix a finding, regenerate and commit the file; the count falls and stays down. Parallel rustc processes write concurrently; a file lock serializes the read-modify- write, and each crate owns its own section.

Name

Stroud dyed wool scarlet. A mordant is the compound that binds the dye to the fiber so it holds. Mordant is built by Scarlet Industries.

License

MIT or Apache-2.0, at your option.