modum 0.5.2

Workspace lint tool for Rust naming and API-shape policy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
<div align="center">
  <img alt="modum logo" src="https://raw.githubusercontent.com/eboody/modum/main/modum-logo.svg" width="360">
  <p>Modum checks source-level namespace shape, alias hygiene, module naming, and surface-path heuristics across a Rust workspace.</p>
  <p>
    <a href="https://github.com/eboody/modum/actions/workflows/ci.yml"><img src="https://github.com/eboody/modum/actions/workflows/ci.yml/badge.svg?branch=main&event=push" alt="build status" /></a>
    <a href="https://crates.io/crates/modum"><img src="https://img.shields.io/crates/v/modum.svg?logo=rust" alt="crates.io" /></a>
    <a href="https://docs.rs/modum"><img src="https://docs.rs/modum/badge.svg" alt="docs.rs" /></a>
  </p>
</div>

# modum

It is a lint tool. It reports diagnostics. It does not rewrite code.
It analyzes parsed Rust source files. It does not expand macros, resolve `include!`, or prune `#[cfg]`.

## Why It Exists

`modum` exists to catch two common Rust namespace-shape problems:

- flattened imports that hide useful context at call sites
- redundant leaf names that repeat context the module path already provides

These call-site shapes are legal Rust, but they make signatures and public paths harder to scan:

```rust
pub fn handle(repo: UserRepository) -> Result<StatusCode, Error> {
    todo!()
}
```

These usually read more clearly:

```rust
pub fn handle(repo: user::Repository) -> Result<http::StatusCode, partials::Error> {
    todo!()
}
```

The same pattern shows up in public API paths:

```rust
user::Repository
user::error::InvalidEmail
partials::Error
```

instead of:

```rust
user::UserRepository
user::error::InvalidEmailError
partials::error::Error
```

The central comparison is often this:

```rust
user::Repository
UserRepository
```

In the abstract, `user::Repository` is usually better than `UserRepository`.

Why:

- The domain lives in the path, which is where Rust already gives you structure.
- The leaf can stay generic and composable: `user::Repository`, `user::Service`, `user::Id`.
- It scales better across a crate than baking the domain into every type name.

That is also why `user::Repository` is usually better than `user::UserRepository`: once the path is doing the domain work, the leaf does not need to repeat it.

The main caveat is that this only holds when `user` is a real semantic module. If the parent path is weak or technical, then a longer leaf can still be better. `UserRepository` is often clearer than `storage::Repository`.

So the rule is:

- strong semantic parent: prefer `user::Repository`
- weak or technical parent: prefer the more descriptive leaf

`modum` checks that style across an entire workspace at the parsed-source level.

## Observation Model

`modum` reads Rust source files with `syn` and reports source-level heuristics from the parsed AST.

It does not observe:

- cfg-pruned items
- macro-expanded items
- `include!`-generated items

When semantic-module family inference would depend on those constructs, `modum` skips `api_candidate_semantic_module` and emits `api_candidate_semantic_module_unsupported_construct` instead.

## Mental Model

`modum` follows four rules:

1. Keep namespace context visible at call sites.
2. Prefer a strong semantic parent with a short leaf: `user::Repository` over `UserRepository`.
3. Keep a more descriptive leaf when the parent path is weak or technical.
4. Use modules for domain boundaries, not file organization.

## Quick Usage

```bash
cargo install modum
cargo modum check --root .
cargo modum check --root . --mode warn
cargo modum check --root . --profile core
cargo modum --explain namespace_flat_use
cargo modum check --root . --show advisory
cargo modum check --root . --ignore api_candidate_semantic_module
cargo modum check --root . --write-baseline .modum-baseline.json
cargo modum check --root . --baseline .modum-baseline.json
cargo modum check --root . --exclude examples/high-coverage
cargo modum check --root . --format json
```

`cargo install modum` installs both `modum` and the Cargo subcommand `cargo-modum`, so either of these is valid:

```bash
modum check --root .
cargo modum check --root .
```

### Neovim

`modum` works well with `nvim-lint`. Use `--mode warn` so diagnostics do not fail the editor job, and use `--format json` for stable parsing.

```lua
local lint = require("lint")

lint.linters.modum = {
  cmd = "modum",
  stdin = false,
  stream = "stdout",
  args = { "check", "--root", vim.fn.getcwd(), "--mode", "warn", "--format", "json" },
  parser = function(output, bufnr)
    if output == "" then
      return {}
    end

    local decoded = vim.json.decode(output)
    local current_file = vim.api.nvim_buf_get_name(bufnr)
    local diagnostics = {}

    for _, item in ipairs(((decoded or {}).report or {}).diagnostics or {}) do
      if item.file == current_file then
        diagnostics[#diagnostics + 1] = {
          bufnr = bufnr,
          lnum = math.max((item.line or 1) - 1, 0),
          col = 0,
          severity = item.level == "Error"
            and vim.diagnostic.severity.ERROR
            or vim.diagnostic.severity.WARN,
          source = "modum",
          code = item.code,
          message = item.message,
        }
      end
    end

    return diagnostics
  end,
}

lint.linters_by_ft.rust = { "modum" }
```

If you edit multiple crates from one Neovim session, replace `vim.fn.getcwd()` with your workspace root resolver. `modum` is workspace-oriented, so it is usually better to run it on save than on every `InsertLeave`.

If you are developing `modum` itself:

```bash
cargo run -p modum -- check --root .
```

Environment:

```bash
MODUM=off|warn|deny
```

Default mode is `deny`.

## Output

Text output groups diagnostics into `Errors`, `Policy Diagnostics`, and `Advisory Diagnostics`.

Use `--show policy` or `--show advisory` when you want to focus one side of the report without changing exit behavior. The exit code still reflects the full report.

Use `--profile core`, `--profile surface`, or `--profile strict` to choose how opinionated the lint set should be. `strict` is the default.

Use `--ignore <code>` for one-off opt-outs in local runs, and `--write-baseline <path>` plus `--baseline <path>` when you want to ratchet down an existing repo without fixing every warning at once.

Text output includes the diagnostic code profile, and direct rewrite-style fixes show a short `fix:` hint inline.

JSON output keeps the full diagnostic list and includes:

- `profile`: the minimum lint profile that includes the diagnostic
- `policy`: whether the diagnostic counts as a policy violation
- `fix`: optional autofix metadata when the rewrite is a direct path replacement, such as `response::Response` to `Response`

You can explain any code without running analysis:

```bash
modum --explain namespace_flat_use
cargo modum --explain api_candidate_semantic_module
```

## CI Usage

Use `modum` the same way you would use `clippy` or `cargo-deny`: run it as a normal command in CI, not from `build.rs`.

```yaml
- run: cargo install modum
- run: cargo modum check --root .
```

For large repos that are adopting `modum` incrementally:

```yaml
- run: cargo install modum
- run: cargo modum check --root . --baseline .modum-baseline.json
```

## Exit Behavior

- `0`: clean, or warnings allowed via `--mode warn`
- `2`: warning-level policy violations found in `deny` mode
- `1`: hard errors, including parse/configuration failures and error-level policy violations such as `api_organizational_submodule_flatten`

## Configuration

Configure the lints in any workspace with Cargo metadata:

```toml
[workspace.metadata.modum]
profile = "strict"
include = ["src", "crates/*/src"]
exclude = ["examples/high-coverage"]
generic_nouns = ["Id", "Repository", "Service", "Error", "Command", "Request", "Response", "Outcome"]
weak_modules = ["storage", "transport", "infra", "common", "misc", "helpers", "helper", "types", "util", "utils"]
catch_all_modules = ["common", "misc", "helpers", "helper", "types", "util", "utils"]
organizational_modules = ["error", "errors", "request", "response"]
namespace_preserving_modules = ["auth", "command", "components", "email", "error", "http", "page", "partials", "policy", "query", "repo", "store", "storage", "transport", "infra"]
extra_namespace_preserving_modules = ["widgets"]
ignored_namespace_preserving_modules = ["components"]
extra_semantic_string_scalars = ["mime"]
ignored_semantic_string_scalars = ["url"]
extra_semantic_numeric_scalars = ["epoch"]
ignored_semantic_numeric_scalars = ["port"]
extra_key_value_bag_names = ["labels"]
ignored_key_value_bag_names = ["tags"]
ignored_diagnostic_codes = ["api_candidate_semantic_module"]
baseline = ".modum-baseline.json"
```

Use `[package.metadata.modum]` inside a member crate to override workspace defaults for that package. Package settings inherit the workspace defaults first, then apply only the keys you set locally.

`include` and `exclude` are optional scan defaults. CLI `--include` overrides metadata `include`, and CLI `--exclude` adds to metadata `exclude`.

`ignored_diagnostic_codes` is additive across workspace, package, and CLI `--ignore` values. Use it for durable repo-level exceptions.

`baseline` is a repo-root-relative JSON file of existing coded diagnostics. Matching baseline entries are filtered out after normal analysis. A metadata baseline is optional until the file exists; an explicit CLI `--baseline <path>` requires the file to exist.

Profile guide:

- `core`: internal namespace readability, including private type naming, type-alias hygiene, internal module-boundary rules, and glob/prelude pressure when imports flatten preserved namespaces
- `surface`: `core` plus caller-facing path shaping and typed boundary nudges for public and shared crate-visible surfaces, including semantic scalar boundaries and `anyhow` leakage
- `strict`: `surface` plus the heavier advisory heuristics, including semantic-module family suggestions, raw string error surfaces, raw ids, raw key-value bags, bool clusters, manual flag sets, and API-shape taste rules

Profile precedence:

- CLI `--profile` overrides package and workspace metadata
- `[package.metadata.modum] profile = "..."` overrides workspace metadata for that crate
- `[workspace.metadata.modum] profile = "..."` sets the workspace default
- if no profile is set anywhere, `strict` is used

Tuning guide:

- `generic_nouns`: generic leaves like `Repository`, `Error`, or `Request`
- `namespace_preserving_modules`: modules that should stay visible at call sites, such as `http`, `email`, `partials`, or `components`
- `extra_namespace_preserving_modules` / `ignored_namespace_preserving_modules`: additive tuning for preserve-module pressure when defaults are close but UI or domain modules like `widgets`, `components`, `page`, or `partials` need adjustment
- `organizational_modules`: modules that should not leak into the public API surface, such as `error`, `request`, or `response`
- `extra_semantic_string_scalars` / `ignored_semantic_string_scalars`: token families for string-like boundary names such as `email`, `url`, `path`, or your own repo-specific additions like `mime`
- `extra_semantic_numeric_scalars` / `ignored_semantic_numeric_scalars`: token families for numeric boundary names such as `duration`, `timestamp`, `ttl`, or repo-specific numeric concepts
- `extra_key_value_bag_names` / `ignored_key_value_bag_names`: token families for string bag names such as `metadata`, `headers`, `params`, or repo-specific names like `labels`
- `ignored_diagnostic_codes`: exact diagnostic codes to suppress, such as `api_candidate_semantic_module`
- `baseline`: repo-root-relative path for a generated baseline file such as `.modum-baseline.json`

These tuning keys work on lowercase name tokens, not full paths.

Adoption workflow:

- start with `--profile core` or `--mode warn`
- use `ignored_diagnostic_codes` for durable repo-specific exceptions
- use `ignored_namespace_preserving_modules = ["components", "page", "partials"]` when a UI aggregator repo intentionally flattens those modules and you do not want to replace the full preserve-module default set
- generate a baseline with `modum check --write-baseline .modum-baseline.json`
- apply it in CI with `modum check --baseline .modum-baseline.json` or `metadata.modum.baseline = ".modum-baseline.json"`

## Lint Categories

### Import Style

These warn when imports or re-exports flatten a namespace that should stay visible.

- `namespace_flat_use`
  Warning for flattened imports of generic nouns when there is an actionable namespace-visible call-site form that adds net context, such as `storage::Repository` or `http::StatusCode`. It skips cases where the only preserved form would still be redundant, such as `error::Error` or `response::Response`.
- `namespace_flat_use_preserve_module`
  Warning for flattened imports from configured namespace-preserving modules when the preserved call-site form still adds net context.
- `namespace_flat_use_redundant_leaf_context`
  Warning for flattened imports or actionable rename-heavy aliases whose leaf repeats parent context. For plain imports, this only fires when the shorter leaf would be an actionable generic noun such as `Repository`, `Error`, or `Id`. For rename aliases, this only fires when the qualified form would still preserve real context, such as `http::StatusCode` or `page::Event`.
- `namespace_redundant_qualified_generic`
  Warning for qualified call-site paths whose module only repeats a generic category already named by the leaf, such as `response::Response` or `error::Error`.
- `namespace_prelude_glob_import`
  Warning for `use ...::prelude::*` imports that hide the real source modules and flatten call-site context.
- `namespace_glob_preserve_module`
  Warning for glob imports from configured namespace-preserving modules such as `http::*`, when the import erases context the module name should carry at call sites.
- `namespace_parent_surface`
- `namespace_flat_pub_use`
- `namespace_flat_pub_use_preserve_module`
- `namespace_flat_pub_use_redundant_leaf_context`

Examples:

- `use storage::Repository;`
- `use http::Client;`
- `use user::UserRepository;`
- `response::Response`
- `use crate::error::Error;` inside a crate whose root surface already exposes `Error`
- `pub use auth::{login, logout};`
- `use http::prelude::*;`
- `use http::*;`

Canonical parent-surface re-exports are allowed. `pub use error::{Error, Result};` is valid when that is how a module intentionally exposes `module::Error` and `module::Result`. The same applies to broader UI surfaces such as exposing both `components::Button` and `partials::Button`.

A semantic child module namespace can also stay flat when it is already doing the call-site naming work. For example, `use components::tab_set;` with call sites like `tab_set::ContentProps` should not be forced into `components::tab_set::ContentProps`.

### Public API Paths

These warn when public leaves are too generic for a weak parent, when the path repeats context it already has, or when a flat family suggests a semantic module surface.

For these surface-shape rules, shared crate-visible surfaces such as `pub(crate)` items and re-exports are treated the same way as fully public ones.

- `api_missing_parent_surface_export`
  Warning for public child modules that should also surface a readable parent alias, such as `components::Button` over `components::button::Button`, or `outcome::Toxicity` over `outcome::toxicity::Outcome`.
- `api_weak_module_generic_leaf`
- `api_redundant_leaf_context`
  Warning for public leaves that repeat semantic module context already carried by the path, such as `user::UserRepository`, or that bake a sibling semantic module into a flat public leaf when `user::Repository` already exists.
- `api_candidate_semantic_module`
  Advisory warning for public item families that suggest a semantic module surface, either through a shared head across at least three siblings like `UserRepository`, `UserService`, and `UserId`, or through a shared generic tail like `CompletedOutcome`, `RejectedOutcome`, and `toxicity::Outcome`. It works on parsed source only and does not see macro expansion or cfg-pruned items.
- `api_candidate_semantic_module_unsupported_construct`
  Advisory warning for scopes where semantic-module family inference was skipped because the parsed source includes unsupported observation gaps such as `#[cfg]`, `macro_rules!`, other item macros, or `include!`.
- `api_manual_enum_string_helper`
  Advisory warning for public enum string surfaces that are spelled manually, including bespoke non-const methods such as `label()` or `as_str()`, free helpers such as `scenario_label(&Scenario)`, and manual `Display` impls that only map variants to string literals.
- `api_ad_hoc_parse_helper`
  Advisory warning for public enum parse helpers, including free `parse_*` functions and inherent methods such as `Mode::parse(&str) -> Result<Self, _>`, when `FromStr` or `TryFrom<&str>` would be a better standard boundary.
- `api_parallel_enum_metadata_helper`
  Advisory warning for public enums that expose several parallel metadata helpers like `label()`, `code()`, and `source_term()` over repeated `match self` blocks, when a typed descriptor surface would model that metadata more cleanly.
- `api_strum_serialize_all_candidate`
  Warning for per-variant `strum` string attributes that could be replaced by one enum-level `serialize_all` rule without changing the external strings.
- `api_builder_candidate`
  Warning for public constructors or workflow entrypoints that take several positional weak parameters and would read better as a builder or typed options struct. It skips functions already marked with a builder surface.
- `api_repeated_parameter_cluster`
  Warning for repeated public constructor or workflow signatures that reuse the same ordered named parameter cluster across entrypoints, when a shared options type or `bon` builder would avoid duplicating the call shape.
- `api_optional_parameter_builder`
  Warning for builder-shaped public entrypoints that take positional `Option<_>` parameters and would read better as a `bon` builder, so callers can omit unset values instead of passing `None`.
- `api_defaulted_optional_parameter`
  Warning for builder-shaped public entrypoints that immediately default positional `Option<_>` parameters, when a `bon` builder would let callers omit those values entirely.
- `callsite_maybe_some`
  Advisory warning for `maybe_*` method calls that pass `Some(...)` directly, which usually defeats the point of having paired `x(...)` and `maybe_x(...)` builder setters.
- `api_standalone_builder_surface`
  Advisory warning for families of public `with_*` or `set_*` free functions that collectively behave like a builder surface for one type.
- `api_boolean_protocol_decision`
  Warning for public `bool` parameters or fields that encode a domain or protocol decision rather than a runtime toggle.
- `api_forwarding_compat_wrapper`
  Warning for explicit conversion helpers such as `to_*` or `into_*` methods that only forward to an existing `From` conversion already present in the crate.
- `api_stringly_protocol_collection`
  Advisory warning for public const or static collections that enumerate protocol, state, transition, artifact, gate, or step values as raw strings instead of typed enums or descriptor maps.
- `api_stringly_model_scaffold`
  Advisory warning for public structs that carry several semantic descriptor fields as raw strings, such as `state_path`, `kind_label`, and `next_machine`, when those concepts would read better as typed enums, newtypes, or a focused descriptor type.
- `api_redundant_category_suffix`

Examples:

- `UserRepository`, `UserService`, `UserId`
- `CompletedOutcome`, `RejectedOutcome`, `toxicity::Outcome`
- `UserRepository` when `user::Repository` already exists
- `partials::button::Button` when the intended surface should also expose `partials::Button`
- `outcome::toxicity::Outcome` when the intended surface should also expose `outcome::Toxicity`
- `storage::Repository`
- `user::UserRepository`
- `user::error::InvalidEmailError`

Private organizational child modules are allowed to flatten their family items back to the parent surface. For example, `mod auth_shell; pub use auth_shell::{AuthShell, AuthShellVariant};` is treated as a valid parent-surface export shape.

### Boundary Modeling

These advisories push caller-facing types and signatures away from raw strings, raw integers, raw key-value bags, manual flag bits, and catch-all error surfaces.

- `api_anyhow_error_surface`
  Advisory warning for public or shared surfaces that expose `anyhow::Error` or `anyhow::Result` instead of a crate-owned typed error boundary.
- `api_string_error_surface`
  Advisory warning for public or shared surfaces that return `Result<_, String>` or store error text in raw string fields.
- `api_manual_error_surface`
  Advisory warning for public error types that manually expose both `Display` and `Error`, when the boundary may want a smaller focused error surface instead of more formatting boilerplate.
- `api_semantic_string_scalar`
  Advisory warning for caller-facing names like `email`, `url`, `path`, `locale`, or `currency` when they stay raw `String` or `&str`.
- `api_semantic_numeric_scalar`
  Advisory warning for caller-facing names like `duration`, `timestamp`, or `port` when they stay raw primitive integers.
- `api_raw_key_value_bag`
  Advisory warning for caller-facing `HashMap<String, String>`, `BTreeMap<String, String>`, or `Vec<(String, String)>` bags such as `metadata`, `headers`, `params`, or `tags`.
- `api_boolean_flag_cluster`
  Advisory warning for public structs or entrypoints that carry several booleans which jointly shape behavior.
- `api_integer_protocol_parameter`
  Advisory warning for protocol-like names such as `status`, `kind`, `mode`, or `phase` when they stay raw integers.
- `api_raw_id_surface`
  Advisory warning for raw id aliases, fields, parameters, or returns such as `UserId = String` or `request_id: u64`.
- `api_manual_flag_set`
  Advisory warning for parallel public `FLAG_*` integer constants, raw `flags` and `permissions` bit-mask boundaries, or repeated named bitmask checks and assembly in caller-facing code that suggest a typed flags surface.

### Module Boundaries

These catch weak or redundant public module structure.

- `api_catch_all_module`
- `api_repeated_module_segment`

Examples:

- `helpers`
- `error::error`

### Structural Errors

This rule is an error, not a warning.

- `api_organizational_submodule_flatten`

Example:

- `partials::error::Error` should usually be `partials::Error`
- `response::Response` should usually be `Response`

## What It Does Not Check

Some naming-guide rules stay advisory because they are too semantic to lint reliably without compiler-grade context. `api_candidate_semantic_module` is also source-level only; if a scope relies on `#[cfg]`, item macros, or `include!`, `modum` emits `api_candidate_semantic_module_unsupported_construct` instead of pretending the inferred family is complete.

Examples:

- choosing the best public path among several plausible domain decompositions
- deciding when an internal long name plus `pub use ... as ...` is the right tradeoff
- deciding whether a new module level adds real meaning or only mirrors the file tree in edge cases

## Scope

Default discovery:

- package root: scans `<root>/src`
- workspace root: scans each member crate's `src`

Override discovery with `--include`:

```bash
modum check --root . --include crates/api/src --include crates/domain/src
```

## False Positives And False Negatives

The broader import-style lints only inspect module-scope `use` items. They do not scan local block imports inside functions or tight test scopes, because those scopes often benefit from flatter imports.

To reduce false negatives:

- extend `namespace_preserving_modules` for domain modules like `user`, `billing`, or `tenant`
- use `extra_namespace_preserving_modules` or `ignored_namespace_preserving_modules` when the default preserve-module set is close but not quite right for your repo
- keep `generic_nouns` aligned with the generic leaves your API actually uses
- keep `organizational_modules` configured so `partials::error::Error`-style paths stay blocked