{"id":51552820,"url":"https://github.com/akesson/workspace-lint","last_synced_at":"2026-07-10T01:01:37.182Z","repository":{"id":341858999,"uuid":"1170773040","full_name":"akesson/workspace-lint","owner":"akesson","description":"Linting on a workspace level.","archived":false,"fork":false,"pushed_at":"2026-07-02T16:32:22.000Z","size":1392,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-02T18:19:34.537Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/akesson.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE-APACHE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-02T14:00:46.000Z","updated_at":"2026-07-02T16:32:28.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/akesson/workspace-lint","commit_stats":null,"previous_names":["human-solutions/workspace-lint","akesson/workspace-lint"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/akesson/workspace-lint","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akesson%2Fworkspace-lint","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akesson%2Fworkspace-lint/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akesson%2Fworkspace-lint/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akesson%2Fworkspace-lint/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/akesson","download_url":"https://codeload.github.com/akesson/workspace-lint/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akesson%2Fworkspace-lint/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35316840,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-09T02:00:07.329Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-10T01:01:36.207Z","updated_at":"2026-07-10T01:01:37.174Z","avatar_url":"https://github.com/akesson.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# workspace-lint\n\n[![CI](https://github.com/akesson/workspace-lint/actions/workflows/ci.yml/badge.svg)](https://github.com/akesson/workspace-lint/actions/workflows/ci.yml)\n[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](crates/workspace-lint-marker/Cargo.toml)\n[![MSRV](https://img.shields.io/badge/rustc-1.88%2B-blue)](https://github.com/rust-lang/rust/releases)\n[![CRAP clean](https://img.shields.io/badge/CRAP-0%20over%20threshold-brightgreen)](https://github.com/minikin/cargo-crap)\n\nA Rust CLI that enforces workspace quality standards via configurable lint checks.\n\nEmits clippy-style human output, rustc-compatible JSON, or GitHub Actions\nworkflow commands so editor and CI integrations work without glue.\n\nTwo tiers, one binary. The **structural lints** (file/crate size, module\ntree, dependency hygiene, …) are build-free: `cargo metadata`\nplus parsed manifests and sources, no compilation. The **semantic lints**\n(`unused-deps`, `unused-pub`, `architecture`) are judged on a\n**rustc-fidelity engine**: the workspace is compiled (a cached `cargo\ncheck`), a compiler-plugin extractor emits each crate's resolved reference\ngraph, and the lints query the assembled workspace-global view — the\ncompiler's own answer, seeing through macro expansions, re-export chains,\n`#[cfg]` variants, trait dispatch, and type inference. The trade is\nexplicit: **the workspace must compile** for the semantic tier, and a\npinned nightly toolchain must be installed (the tool prints the exact\ninstall commands if it isn't — and offers to run them on an interactive\nterminal). `--fast-only` runs just the build-free tier\n— no toolchain, no compile. See [The semantic engine](#the-semantic-engine).\n\n## Installation\n\n```sh\ncargo install --path crates/workspace-lint\n```\n\nFor per-site silencing, also add the zero-dep marker crate to consumer\nworkspaces:\n\n```toml\n[dependencies]\nworkspace_lint = { package = \"workspace-lint-marker\", version = \"0.1\" }\n```\n\n## Quick start\n\nCreate `.workspace-lint.toml` in your workspace root:\n\n```toml\n# Structural lints (centralized-deps, orphan-file, feature-drift, unused-deps,\n# unused-pub) are on by default at `warn`. The `[lints]` table is where you\n# loosen (`allow`) or escalate (`deny`) — and where policy lints get enabled.\n[lints]\ncentralized-deps = \"deny\"   # escalate to a CI-failing error\nunused-pub       = \"allow\"  # turn one off\n\n# Policy lints have no meaning without parameters, so their config table is the\n# opt-in:\n[[file-size.rules]]\nglob = \"**/*.rs\"\nmax-code-lines = 500\n```\n\nThen run:\n\n```sh\nworkspace-lint\n```\n\nExit code `0` means no `deny`-level finding survived. Exit code `1` means at\nleast one did.\n\n## Checks\n\nEach check below has a one-paragraph summary here and full documentation in\nits own `DOC.md` (linked, and shown by `workspace-lint explain \u003clint\u003e` or\n`workspace-lint check \u003clint\u003e --help`).\n\n### centralized-deps\n\nVerifies that every workspace crate declares its dependencies with\n`workspace = true` instead of pinning a version directly, so the whole\nworkspace shares one source of truth. A structural lint — on by default at\n`warn`. `--fix` rewrites inline deps and seeds missing ones into\n`[workspace.dependencies]`.\n\n```toml\n[lints]\ncentralized-deps = \"deny\"   # or \"allow\" to turn off\n```\n\nFull docs: [`centralized_deps/DOC.md`](crates/wl-lints/src/centralized_deps/DOC.md) · `workspace-lint explain centralized-deps`\n\n### file-size\n\nEnforces a maximum number of *code* lines per file (blank lines and comments\nexcluded). For `.rs` files the count is *shipped* source only, so test code\nis excluded and the budget matches `crate-size`. A policy lint — add a rule\nto enable it.\n\n```toml\n[[file-size.rules]]\nglob = \"**/*.rs\"\nmax-code-lines = 500\n```\n\nFull docs: [`file_size/DOC.md`](crates/wl-lints/src/file_size/DOC.md) · `workspace-lint explain file-size`\n\n### crate-size\n\nEnforces a maximum number of *shipped* code lines per crate directory — the\nmaintained product code, not the test mass that often dwarfs it. A policy\nlint — add a rule to enable it.\n\n```toml\n[[crate-size.rules]]\nglob = \"crates/*\"\nmax-code-lines = 5000\ninclude = [\"*.rs\"]\n```\n\nFull docs: [`crate_size/DOC.md`](crates/wl-lints/src/crate_size/DOC.md) · `workspace-lint explain crate-size`\n\n### duplicate-code\n\nName-invariant (Type-2) duplicate detection: flags groups of structurally\nidentical code regions — whole functions/methods, nested blocks, and runs of\nconsecutive statements — **even when local variable names and literal values\ndiffer**. Each region is normalized (local bindings α-renamed to positional\nplaceholders in first-occurrence order, literals abstracted per kind) and\nhashed; identical structures bucket together, so the pass is near-linear and\nfinds cross-crate copy-paste for free. Statement runs are what keep a clone\nin one piece: a span copied mid-body into two otherwise-different functions\nis reported once, maximally — not as fragments of whichever nested blocks\nhappen to match. Renames must be *consistent*: swapping two variables\nchanges the structure and correctly breaks the match. Called functions,\ntypes, and field/method names are kept verbatim — two blocks that call\ndifferent functions are never clones.\n\nIt goes further than flagging: it **classifies what refactoring each group\ncalls for** (keep-one-and-redirect, delete-the-dead-copy, default-trait-\nmethod, method-on-type, extract-a-component) and reshapes the help\naccordingly. Because that consults the rustc call graph, **`duplicate-code`\nis a semantic lint** — it needs a compiling workspace and `--fast-only`\nskips it (the `--stats` readout is the build-free exception). Advisory only:\n`--fix` never rewrites duplicates. Two noise filters and an extraction-cost\ngate keep it quiet on convergent boilerplate and data tables; see the docs.\n\nThe `[duplicate-code]` table (all fields optional) enables it — or try it on\nany workspace with zero config via the ad-hoc form:\n\n```sh\nworkspace-lint check duplicate-code                     # defaults, zero config\nworkspace-lint check duplicate-code --cross-crate-only  # cross-crate copies only\nworkspace-lint check duplicate-code --stats             # threshold-tuning readout\n```\n\nAdopting on a legacy tree with hundreds of clones? Point `baseline` at a\nchecked-in file and run `workspace-lint --baseline-write`: only *new* or grown\nduplication then fails CI (the SonarQube new-code-gate model), keyed on a\nportable content fingerprint so entries survive renames and file moves. See the\nbaseline-ratchet section of the docs.\n\nFull docs: [`duplicate_code/DOC.md`](crates/wl-lints/src/duplicate_code/DOC.md) · `workspace-lint explain duplicate-code`\n\n### cli-crate-version\n\nVerifies that a locally installed CLI tool's version matches the version of a\ncrate in the workspace. A policy lint — add a rule to enable it.\n\n```toml\n[[cli-crate-version.rules]]\ncommand = [\"wasm-bindgen\", \"--version\"]\npattern = \"wasm-bindgen (\\\\S+)\"\ncrate = \"wasm-bindgen\"\n```\n\nFull docs: [`cli_crate_version/DOC.md`](crates/wl-lints/src/cli_crate_version/DOC.md) · `workspace-lint explain cli-crate-version`\n\n### unused-deps\n\nFlags dependencies declared in `Cargo.toml` that nothing references, judged\non the rustc engine's resolved reference graph (facade-aware, rename-aware,\ncrediting doc-fence and feature usage). A semantic lint — on by default at\n`warn`; `--fast-only` skips it. Dev-dependencies are judged only when a\ntest-compiling entry is in the\n[`[engine]` config matrix](#the-semantic-engine); the default matrix has one.\n\n```toml\n[unused-deps]\nignore = [\"prost\", \"tonic\"]\n```\n\nFull docs: [`unused_deps/DOC.md`](crates/wl-lints/src/unused_deps/DOC.md) · `workspace-lint explain unused-deps`\n\n### unused-pub\n\nDetects `pub` items that are never used across the workspace, judged on the\nrustc engine's resolved reference graph — it needs **no** SCIP index and **no**\n`rust-analyzer` subprocess, so it runs the same locally and in CI. Items\nre-exported via `pub use`\nare always skipped (narrowing them would break the re-export). So are types that\nappear in the *public signature* of a more-visible item (a `pub fn` return type,\na `pub` field, a trait-impl associated type, …) — tightening those would not\ncompile (E0446 / `private_interfaces`). That exemption also covers a type a\nbuilder macro promotes into its generated public `build()` signature, recognized\nfrom the attribute: `typed_builder`'s `#[builder(build_method(into = T))]` and\n`derive_builder`'s `#[builder(build_fn(error = \"T\"))]`.\n\n**Publish-aware.** The lint can't see consumers outside your workspace, so it\ntreats a crate's public API as off-limits **only when the crate declares it has\nexternal consumers — `publish = true`** (or a registry list) in its `Cargo.toml`.\nEvery other crate — `publish = false`, or, by default, *no* `publish` field — is\ntreated as **workspace-internal**: its `pub` items are checked, and anything not\nused by another workspace crate is flagged for narrowing to `pub(crate)`. This is\nthe whole point of the lint at the workspace level — over-exposed internal APIs\nget caught. So: **mark genuinely-published crates with `publish = true`** (you\nlikely want this anyway), and leave internal crates as they are.\n\nIf a crate accumulates several findings, the lint emits a one-line hint\nsuggesting `publish = true` — in case the flood means the crate really is\npublished. Set `assume-all-public = true` to opt out entirely and treat every\ncrate as having an external API (the pre-publish-aware behavior).\n\nTwo findings:\n- **used only inside the crate** → suggests narrowing to `pub(crate)`.\n- **unused anywhere** → suggests `pub(crate)` (or deletion, under\n  `--fix-auto-delete`).\n\n```toml\n[unused-pub]\nexclude-crates = [\"api\"]\nallowlist = [\"*Error\", \"main\"]\nkinds = [\"function\", \"struct\"]\nexclude-paths = [\"generated/**\"]\nsuppress-intra-crate = false\nassume-all-public = false\npublish-hint-threshold = 3\n```\n\n`--fix` narrows an item used only inside its crate to `pub(crate)`.\n`--fix-auto-delete` (CLI-only, gated on a clean git tree) instead\nwhole-item-deletes items unused everywhere, cascading through the entire\ndead chain in one pass and trimming any `use` left dangling — with a\ncfg-shadow veto, a `-D warnings` guard, re-run-to-converge, and a\nclippy-unmask guard. Options can be scoped per crate via\n[`[crates.\u003cname\u003e.unused-pub]`](#per-crate-configuration). The docs cover the\ndeletion cascade in full.\n\nFull docs: [`unused_pub/DOC.md`](crates/wl-lints/src/unused_pub/DOC.md) · `workspace-lint explain unused-pub`\n\n### architecture\n\nEnforces workspace layering: which crates may import which canonical paths.\nEach rule names a set of importing crates (`from`) and forbidden import targets\n(`deny`), with an optional per-target `exceptions` escape hatch. Enabled\nwhenever at least one rule is present.\n\n```toml\n[[architecture.rules]]\nname = \"domain stays pure\"          # optional, shown in the diagnostic\nfrom = [\"domain-*\"]                 # importing-crate name globs (required)\ndeny = [\"*::infra::*\", \"sqlx::*\"]   # forbidden canonical-path globs (required)\nexceptions = [\"infra::Id\"]          # canonical paths allowed despite `deny`\nseverity = \"deny\"                   # \"warn\" (default) or \"deny\"\nreason = \"domain must not depend on infrastructure\"  # note: line\nsuggest = \"move the shared type into a `core` crate\" # help: line\n```\n\nPatterns use `::` as the separator (`*` = one segment, `**` = zero or more).\n`use` bindings, glob imports, and bare fully-qualified call sites are all\ninspected against the compiler's resolved canonical paths, so re-export\nchains and macro-generated references are judged too. Only production code is\nchecked. `architecture` is TOML-only — no `check` subcommand — but\n`workspace-lint explain architecture` still works.\n\nFull docs: [`architecture/DOC.md`](crates/wl-lints/src/architecture/DOC.md) · `workspace-lint explain architecture`\n\n### orphan-file\n\nSource files under `src/` that no declared `[engine]` config compiles. A\nsemantic lint — on by default at `warn`, skipped under `--fast-only`. rustc\nnever opens a file no `mod` chain reaches, so `dead_code` can't see it and\nclippy says nothing; reachability here is rustc's own record of which files it\nopened, unioned across the config matrix. A file the source *names* but no\nconfig compiles (a platform-gated module, a `#[cfg(test)] mod tests;` under a\nmatrix without `cargo test`) is reported as a coverage gap, never as something\nto delete. Escalate or silence via `[lints] orphan-file`.\n\nFull docs: [`orphan_file/DOC.md`](crates/wl-lints/src/orphan_file/DOC.md) · `workspace-lint explain orphan-file`\n\n### feature-drift\n\nDetects drift between a crate's `[features]` table and its\n`#[cfg(feature = \"...\")]` usage. A structural lint — on by default at `warn`.\nFlags features declared in `[features]` but never gated in source, and\n`#[cfg(feature = \"...\")]` references to features that aren't declared.\n`default` is exempt (cargo handles it specially). Escalate or silence via\n`[lints] feature-drift`.\n\nFull docs: [`feature_drift/DOC.md`](crates/wl-lints/src/feature_drift/DOC.md) · `workspace-lint explain feature-drift`\n\n\u003e **Note:** `pub`-visibility tightening (`pub` → `pub(crate)` for items used\n\u003e only inside their own crate) is part of `unused-pub`, which is resolver-backed\n\u003e and covers that ground plus unused-everywhere items. The former standalone\n\u003e `visibility` lint was folded into it — migrate `[checks] visibility = true`\n\u003e to `[lints] unused-pub = \"warn\"`.\n\n### Always-on lints\n\nThese lints take no configuration and run on every invocation (silence with\n`[lints] \u003cname\u003e = \"allow\"`). Each has full docs via\n`workspace-lint explain \u003clint\u003e`:\n\n- **stale-expect** — fires when an `expect!` / `expect(...)` directive silences\n  nothing because the underlying lint stopped firing (see\n  [Silencing diagnostics](#silencing-diagnostics)). Only lints that actually\n  ran are judged. `--fix` deletes a stale directive line for you (unless it\n  also names a still-live or unjudged lint).\n  [docs](crates/workspace-lint/src/docs/stale-expect.md)\n- **config** — a structural problem in the config file itself: an unknown\n  section or key (with a \"did you mean …?\" hint), or a policy lint enabled in\n  `[lints]` with no rules table (so it would never fire).\n  [docs](crates/workspace-lint/src/docs/config.md)\n- **unknown-lint** — a lint name that doesn't exist, referenced either in\n  `[lints]` or in an `expect!`/`allow!` directive — caught instead of silently\n  doing nothing.\n  [docs](crates/workspace-lint/src/docs/unknown-lint.md)\n\nAll default to `warn`; escalate or silence them through `[lints]` like any\nother lint. `config` and `unknown-lint` have one exception: a blanket\n`[lints] default = \"allow\"` will **not** silence them (only an explicit\n`[lints] config = \"allow\"` does), so a typo'd config can't hide itself.\n\n## Commands\n\n### Run all checks\n\n```sh\nworkspace-lint\n```\n\nRuns all enabled checks. Any configured `[expand]` rules apply only under\n`--fix` (on a clean git tree), since they rewrite files — a plain run never\nmutates.\n\n#### Exit codes\n\n| Code | Meaning |\n|------|---------|\n| `0`  | Clean — no `deny`-level findings survived. |\n| `1`  | Lint findings: at least one `deny`-level diagnostic. |\n| `2`  | Operational error — unusable config, a failed subprocess, an IO error, or a dirty tree under `--fix`. |\n\nCode `1` means the *linted code* has findings; code `2` means the *tool itself*\ncouldn't run. Keep them distinct in CI so a broken config doesn't look like a\nlint failure (and vice versa).\n\n### Run a single check\n\n```sh\nworkspace-lint check centralized-deps\nworkspace-lint check file-size --glob \"**/*.rs\" --max-code-lines 500\nworkspace-lint check duplicate-code --cross-crate-only\nworkspace-lint check unused-deps --ignore prost --ignore tonic\n```\n\n`workspace-lint check \u003clint\u003e --help` prints that lint's full documentation\nafter its options (`-h` stays terse).\n\n### Explain a lint\n\n```sh\nworkspace-lint explain unused-pub\nworkspace-lint explain architecture   # works even for TOML-only lints\n```\n\nPrints a lint's full documentation to stdout — the same text `check \u003clint\u003e\n--help` shows, and the source of each lint's `DOC.md`. Accepts the short name\n(`unused-pub`) or the full id (`workspace-lint::unused-pub`), and covers every\nlint, including the pipeline meta lints `config` / `stale-expect` /\n`unknown-lint`.\n\n### Initialize a config\n\n```sh\nworkspace-lint init\n```\n\nWrites a commented starter `.workspace-lint.toml` in the current directory. It\nenables the batteries-included structural lints, escalates `centralized-deps` to\n`deny`, declares the engine matrix at `[\"cargo build\", \"cargo test\"]` (so\ntest-gated usage and dev-dependencies are judged — drop `\"cargo test\"` for a\nsingle, faster pass), and includes ready-to-uncomment blocks for every policy\nlint.\n\n`init` must be run at the **workspace root**: the root `Cargo.toml` must declare\na `[workspace]` table and the current directory must be that root (not a member\nsubdir) — a lone `[package]` is rejected. It refuses to overwrite an existing\nconfig (a standalone file or `[workspace.metadata.workspace-lint]`); pass\n`--force` to replace an existing `.workspace-lint.toml`.\n\n### Expand markers\n\n```sh\nworkspace-lint expand --command \"mise tasks\" --glob \"CLAUDE.md\" --marker \"MISE_TASKS\" --auto-stage\n```\n\nRuns a command and injects its output between `\u003c!-- MARKER_START --\u003e` / `\u003c!-- MARKER_END --\u003e` comment pairs in matched files. With `--auto-stage`, modified files are `git add`ed automatically. Because it rewrites files, the subcommand requires a clean git working tree (override with `--allow-dirty`). Configured `[expand]` rules are also applied as part of a `--fix` run.\n\n## Configuration\n\nConfiguration lives in **one** of two places (not both):\n\n1. **Standalone file**: `.workspace-lint.toml` in the workspace root\n2. **Cargo.toml metadata**: under `[workspace.metadata.workspace-lint]`\n\n```toml\n# In Cargo.toml:\n[workspace.metadata.workspace-lint.lints]\ncentralized-deps = \"deny\"\n\n[[workspace.metadata.workspace-lint.file-size.rules]]\nglob = \"**/*.rs\"\nmax-code-lines = 500\n```\n\nThe config has these kinds of sections: **lints** (the `[lints]` table plus a\nper-lint params table like `[file-size]`), the per-crate **`[crates.\u003cname\u003e]`**\ntier (see [Per-crate configuration](#per-crate-configuration)), the **`expand`**\ntask (see [Expand markers](#expand-markers)), and **`macros`** resolver metadata\n(see [External macro annotations](#external-macro-annotations)). Unknown sections\nand keys are reported by the [`config`](#always-on-lints) lint.\n\n### Migrating from the older format\n\nThe `[checks]` table and a standalone severity table are gone — everything lives\nin `[lints]` now:\n\n| Old | New |\n|-----|-----|\n| `[checks]`\u003cbr\u003e`centralized-deps = true` | `[lints]`\u003cbr\u003e`centralized-deps = \"warn\"` (or just rely on the on-by-default `warn`) |\n| `[lints]`\u003cbr\u003e`file-size = \"deny\"` (severity only) | unchanged — `[lints]` now also enables |\n| `[checks]`\u003cbr\u003e`visibility = true` | `[lints]`\u003cbr\u003e`unused-pub = \"warn\"` (the `visibility` lint was folded in) |\n| `kinds = [\"method\"]` | removed — `method`/`field`/`variant` were never real kinds |\n\n### External macro annotations (obsolete)\n\nThe semantic engine compiles the workspace with rustc, so references made\ninside macro expansions are ordinary reference-graph edges — no annotation\nneeded. The old `[[macros.external]]` config section now draws a `config`\nfinding telling you to delete it, and the `expansion_uses!` /\n`# workspace-lint: expansion-uses(...)` source annotations are no longer read.\n\n### Naming quirks the matcher smooths over\n\nMacro-expansion contracts (a derive that references its runtime crate, an\nattribute that names a module in a string) need no special handling: the\nengine sees the expanded code, so those references are ordinary edges.\nWhat remains heuristic is pure *naming*: a dep whose hyphen-stripped name\nmatches a referenced lib target is credited (`md-5` declares the crate\nwhose lib is `md5`), so a rename-by-convention never reads as unused. This\nonly ever *suppresses* a finding — never creates one. For everything else\nthere is the `[unused-deps] ignore` knob.\n\n## The semantic engine\n\nThe semantic lints (`unused-deps`, `unused-pub`, `architecture`) are judged\non the compiler's own resolution, in two phases:\n\n1. **Extract.** The binary embeds the source of a compiler-plugin extractor\n   (a [Dylint](https://github.com/trailofbits/dylint) lint that never\n   warns), materializes it to a per-version cache, builds it once per\n   toolchain, and drives one `cargo check` per configured entry over your\n   workspace with the plugin loaded. Each compiled crate writes an IR\n   fragment — its definitions and resolved reference edges — under\n   `target/workspace-lint/ir/\u003cconfig\u003e/`. Cargo's own caching applies:\n   unchanged crates aren't recompiled and their fragments stay valid, so\n   warm runs cost roughly a no-op `cargo check`.\n2. **Assemble.** The stable side joins the fragments into a\n   workspace-global reference graph (cross-crate identities via\n   `DefPathHash`; results unioned across the config matrix) that the lints\n   query.\n\n### The config matrix\n\nEach entry is a **real cargo command the project runs** — the declared\nmatrix *is* the support matrix, in the vocabulary you already use:\n\n```toml\n[engine]\n# One extraction pass per entry; verdicts union across them. The first\n# entry is primary. Absent table = [\"cargo build\", \"cargo test\"].\nconfigs = [\n    \"cargo build\",\n    \"cargo nextest run\",\n    \"cargo build --target wasm32-unknown-unknown -p app\",\n]\n```\n\nSupported verbs: `build` / `check` / `clippy` (the plain lib+bins\nuniverse), `test` / `nextest run` (the test universe), `bench`. Supported\nflags: `--target`, `-p`/`--package`, `--features`/`-F` / `--all-features` /\n`--no-default-features`, `--workspace`/`--all`, and `--tests`/`--benches`;\nuniverse-neutral flags (`--locked`, `--quiet`, `--jobs`, …) are accepted and\nignored. The engine reproduces each\ncommand's compilation universe with a `cargo check`-fidelity pass — sound\nbecause check compiles the same units under the same cfgs; only codegen\ndiffers (`cargo nextest run` and `cargo test` normalize to one pass). The\nparser is deliberately strict: anything it doesn't positively recognize is\na config error with guidance, never silently dropped — a swallowed flag\nwould silently change which code the lints judge.\n\n`#[cfg]`-gated code exists only under the config that compiles it, so the\nmatrix is what keeps test-gated usage from reading as dead: an item used\nonly from `#[cfg(test)]` code is *used* under `cargo test` and the union\nclears it. A test entry is also what makes dev-dependencies judgeable at\nall — which is why the default matrix includes one. The output names which\nconfigs ran — code compiled under configs outside the matrix can still\ncause false positives, and the diagnostics say so.\n\n### Toolchain requirement\n\nThe extractor builds against a **pinned nightly** (it links `rustc`\ninternals; the pin ships inside the binary and moves only with tool\nreleases). The first semantic run checks for it and fails with the exact\ncommands if anything is missing:\n\n```sh\nrustup toolchain install \u003cpin\u003e --profile minimal \\\n    --component rustc-dev --component llvm-tools-preview\ncargo install dylint-link --locked\n```\n\nOn an interactive terminal the tool offers to run each of those for you\n(`proceed? [y/N]`) and continues the run once they succeed — provisioning\na new machine is one keypress. Without a terminal (CI, pipes) it never\nprompts and fails with the commands above.\n\nYour own code never compiles on that nightly — it is the *extractor's*\nbuild toolchain; your workspace compiles on whatever toolchain cargo would\nnormally pick.\n\n### Failure semantics\n\nIf the workspace doesn't compile under a configured entry, the fast-tier\nlints still report, then the run fails naming the config, with cargo's\ndiagnostics replayed verbatim. The full tier never silently degrades to a\nweaker analysis — the explicit degradation path is `--fast-only`.\n\n### Hooks and CI\n\n- **pre-commit**: `workspace-lint --fast-only` — build-free, sub-second.\n- **pre-push / CI**: `workspace-lint` — the full tier; warm runs are\n  cheap, and CI caches the extractor build per toolchain pin.\n\n### Performance\n\nWarm runs are cheap, but the cost **scales with `[engine] configs`**: each entry\nis a full `cargo check` freshness pass over the whole workspace on the pinned\nnightly, so N entries cost roughly N× the warm floor — not a fixed overhead. That\ncheck runs in the engine's own `target/dylint` build universe, separate from the\n`target/` your everyday `cargo` uses, so the two never share artifacts (the first\nfull run after a normal build is cold). `--fast-only` skips the tier, and this\ncost, entirely.\n\nA workspace with a **non-deterministic `build.rs`** (e.g. Dioxus / `manganis`\nasset codegen) can need **one extra warm run to settle**: the build script\nre-fires inside the engine's freshness universe until its generated outputs\nstabilize, so the first warm run after a change re-checks more than the steady\nstate (observed 45s → 23s → 6s across three consecutive runs). That is the build\nscript converging, not a workspace-lint regression — later runs are fast.\n\nOn a very large workspace the assemble phase holds every crate's IR fragment in\nmemory at once, so peak RSS grows with the workspace (multiple GB on\nthousand-file trees).\n\n## Output formats\n\n`--message-format` picks the renderer (default `human`):\n\n**`human`** (clippy-style, written to stderr):\n\n```\nwarning: file exceeds 500 code lines (612)\n --\u003e crates/web-api/src/handler.rs:1:1\n  |\n  = help: split #[cfg(test)] modules into separate test files\n  = help: extract related structs, enums, or trait impls into their own modules\n  = note: configured by [[file-size.rules]] glob = \"**/*.rs\"\nhelp: if intentional, silence with:\n  |\n1 + workspace_lint::expect!(file_size);\n  |\n  = note: `#[warn(workspace_lint::file_size)]` on by default\n\nworkspace-lint: generated 1 warning\n```\n\n**`json`** (rustc-compatible per-line, written to stdout). Set rust-analyzer's\n`check.overrideCommand` to `[\"workspace-lint\", \"--message-format=json\", ...]`\nand IDE squiggles + \"Apply suggestion\" code actions work without further glue.\n\n**`github`** (Actions workflow command, written to stdout):\n\n```\n::warning file=crates/web-api/src/handler.rs,line=1,col=1,title=workspace-lint%3A%3Afile-size::file exceeds 500 code lines (612)\n```\n\n## Lint levels\n\n`[lints]` is the one place a lint is enabled and leveled. Each value is\n`allow`, `warn`, or `deny`, and the reserved `default` key sets the baseline\nfor every lint:\n\n```toml\n[lints]\ndefault          = \"warn\"   # baseline for every lint (optional; built-in = \"warn\")\nfile-size        = \"deny\"   # per-lint override beats `default`\nunused-pub       = \"allow\"  # turn one off\n```\n\n**Precedence:** a per-lint entry beats `default`, which beats the built-in\nbaseline (`warn`). Use the kebab-case short name (no `workspace-lint::` prefix);\nan unknown name is reported as [`unknown-lint`](#always-on-lints), not silently\nignored.\n\n**What runs:** a lint runs when its effective level isn't `allow`, with one\nextra condition for the *policy* lints (`file-size`, `crate-size`,\n`cli-crate-version`, `architecture`, `duplicate-code`) — they're meaningless\nwithout parameters (or, for `duplicate-code`, noisy enough to demand deliberate\nopt-in), so they additionally require their config table to be present. The *structural*\nlints (`centralized-deps`, `orphan-file`, `feature-drift`, `unused-deps`,\n`unused-pub`) need no table and are therefore on by default. So:\n\n- `default = \"allow\"` makes the whole tool opt-in — nothing runs until you set\n  a lint to `warn`/`deny`.\n- `default = \"deny\"` makes every enabled lint CI-failing.\n- Leaving `default` unset keeps the batteries-included `warn` baseline.\n\n**Exit code:** 1 iff at least one `deny`-level diagnostic survives suppression;\n`allow`-ed diagnostics are dropped entirely before the tally.\n\n### Per-crate configuration\n\nLint config cascades through three tiers, narrowest winning: the global\n`[lints]` table → a per-crate `[crates.\u003cname\u003e]` block → in-code `expect!` /\n`allow!` directives. The per-crate tier lives in the **same** root config (keyed\nby Cargo package name), so the whole policy stays in one file.\n\n```toml\n[lints]                       # tier 1 — global\ndefault   = \"warn\"\nfile-size = \"deny\"\n\n[crates.legacy.lints]         # tier 2 — per-crate levels (mirrors [lints])\nfile-size = \"allow\"           # stop denying file-size in `legacy` only\ndefault   = \"allow\"           # …or opt the whole crate out wholesale\n\n[crates.api.lints]\nunused-pub = \"deny\"           # turn a globally-loosened lint back on, here\n\n[crates.api.unused-pub]       # tier 2 — per-crate params\nallowlist = [\"*Builder\"]\n\n[crates.worker.unused-deps]\nignore = [\"prost\", \"tonic\"]\n```\n\n**Per-crate levels — every lint.** A per-crate `[crates.\u003cname\u003e.lints]` entry\noverrides the global level for that crate; a per-crate `default` sets the\ncrate's baseline (and, set to `allow`, opts the whole crate out). Keys with no\nper-crate entry fall through to the global tier.\n\n**Per-crate params — `unused-deps` and `unused-pub` only.** These are the lints\nwhose params are workspace-flat (an `ignore` list, an allowlist, …) with no glob\nescape hatch. `file-size` and `crate-size` already scope per-crate\nthrough their globs, so a `[crates.\u003cname\u003e.file-size]` (or crate-size) block is\na [`config`](#always-on-lints) error that redirects you to a glob rule — one\nobvious way, not two. Any other per-crate params block (`cli-crate-version`,\n`architecture`, …) is a `config` error too. A present\n`[crates.\u003cname\u003e.unused-deps]` / `unused-pub` section **wholesale-replaces** the\nglobal section for that crate (predictable: the crate's config is exactly what's\nwritten).\n\n**Validation.** A `[crates.\u003cname\u003e]` whose `\u003cname\u003e` isn't a workspace member is a\n`config` error with a \"did you mean …?\" hint — centralized per-crate config\ncan't silently rot against a renamed or removed crate.\n\n## Silencing diagnostics\n\nSilence directives are author-written — every diagnostic prints the exact text\nto paste, in one of two forms picked by file kind. The suggested\ndirective uses `expect!` (and its `expect(…)` comment form): it silences a\ndiagnostic but emits a `workspace-lint::stale-expect` warning if the underlying\nlint stops firing, so silences don't quietly rot.\n\nRust files accept both a macro form (`workspace_lint::expect!(unused_pub);`) and\na **line-comment** form (`// workspace-lint: expect(unused-pub)` written above\nthe item) — the latter needs no `workspace-lint-marker` dependency.\n\n**Rust files** — declarative macro from `workspace-lint-marker`:\n\n```rust\nworkspace_lint::expect!(file_size);                // silence; warn if stale\nworkspace_lint::expect!(file_size, unused_pub);    // comma-separated list\nworkspace_lint::allow!(file_size);                 // silence permanently — no stale warning\n```\n\n**`Cargo.toml`, Markdown, anything non-Rust** — comment directive:\n\n```toml\n# workspace-lint: expect(centralized-deps)\n[dependencies]\nserde = \"1.0.200\"\n\n# workspace-lint: allow(unused-deps)   # permanent: lint can't reach this scope\n```\n\nReach for `allow!` (and `# workspace-lint: allow(...)`) only when you want\na permanent silence — e.g. a file the lint genuinely can't reach (an\n`unused-pub` item inside `exclude-crates`), or a constraint that will\nnever relax. `expect` is preferred everywhere else.\n\n## Updating expected outputs\n\nTwo test-data sources have an auto-bless workflow:\n\n```sh\n# Inline diagnostic snapshots in src/messages.rs\ncargo insta accept\n\n# Whole-tree --fix fixtures under tests/fixtures/fix__*/\nWORKSPACE_LINT_BLESS=1 cargo test --test fix_fixtures\n```\n\nRun either after a deliberate change to the rendered output, review the\ndiff, commit. The fix-fixture driver wholesale-replaces `expected/` so\nremoved files in the post-fix tree propagate correctly.\n\n## CLI flags\n\n- `workspace-lint` — run all configured checks.\n- `workspace-lint check \u003crule\u003e [opts]` — run a single check\n  (`check \u003crule\u003e --help` shows that lint's full documentation).\n- `workspace-lint explain \u003clint\u003e` — print a lint's full documentation.\n- `workspace-lint --message-format \u003chuman|json|github\u003e` — pick the renderer.\n- `workspace-lint --fast-only` — run only the build-free lints: no\n  compile, no pinned-toolchain requirement. The semantic lints are\n  *skipped* (a `check \u003csemantic-lint\u003e --fast-only` is a hard error rather\n  than a silent no-op). The right mode for fast pre-commit hooks.\n- `workspace-lint --fix` — apply every diagnostic's `MachineApplicable`\n  structural rewrite in place. **Requires a clean git working tree**\n  (override with `--allow-dirty`) so the whole change is reviewable as one\n  `git diff`. Available structural fixes:\n    - **centralized-deps** rewrites `serde = \"1\"` (or table forms) to\n      `serde = { workspace = true }`, preserving `features`, `optional`,\n      and `default-features` when present.\n    - **unused-deps** deletes the dep line from `[dependencies]` /\n      `[dev-dependencies]` / `[build-dependencies]`.\n    - **unused-pub** tightens `pub fn`/`pub struct`/… to `pub(crate)` for\n      items used only inside their own crate.\n    - **stale-expect** deletes the whole directive line once the lint it\n      silenced stops firing — the mechanical inverse of writing a silence\n      directive. Withheld when the line also names a still-live or\n      unjudged lint (deleting it would silence that lint too).\n\n  `--fix` is non-destructive: it rewrites files but never deletes them.\n  Idempotent: re-running on a clean tree is a no-op. It never *writes* a\n  silence directive on your behalf — that's always a human decision (paste\n  the directive the diagnostic prints); deleting a stale one, which only\n  reduces suppression, is the one exception.\n- `workspace-lint --fix-auto-delete` — everything `--fix` does, plus:\n  **unused-pub** items that *appear unused entirely* are whole-item\n  DELETED, cascading through the entire dead chain in one pass and\n  trimming any `use` left dangling (see the unused-pub section). Only\n  git-tracked-clean files are touched — the deletion's backup is\n  `git checkout`. A manual, CLI-only operation by design: there is no\n  config equivalent, so CI `--fix` runs can never delete code.\n- `workspace-lint expand` — substitute command output into marker blocks.\n\n### Generated code (`include!`)\n\nGenerated code **participates in analysis** — the semantic lints judge the\ncompiler's own view, where every `include!` (literal, `CARGO_*`, and\n`OUT_DIR`-based) is already spliced, so a dependency or `pub` item used only\nfrom generated code is seen as used. Findings anchored *in* a generated file\nare dropped (a generated `pub fn` is never reported unused): the structural\nlints' build-free walk resolves literal / `CARGO_*` includes for the drop\nset, and the semantic lints skip anything under cargo's target directory.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fakesson%2Fworkspace-lint","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fakesson%2Fworkspace-lint","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fakesson%2Fworkspace-lint/lists"}