From 260caf607b3b779e9e03d5891b2d91c7dd5da9d2 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 16:07:26 +0200 Subject: [PATCH 01/11] feat(cargo-anvil): lint empty braced structs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 4 ++-- Cargo.toml | 6 +++--- crates/cargo-anvil/docs/design/README.md | 7 ++++++- crates/cargo-anvil/src/anvil/artifacts/region.rs | 3 ++- crates/cargo-anvil/templates/regions/cargo-lints-body.toml | 1 + .../tests/snapshots/snapshots__ado_backend.snap | 1 + .../tests/snapshots/snapshots__github_backend.snap | 1 + .../cargo-anvil/tests/snapshots/snapshots__local_only.snap | 1 + 8 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 51e7b3724..c4422211d 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.10.0" -catalog_checksum = "sha256:d98d8d30baa01fe5f59b243aec4147ff5de6921107bdc60879eef31e11286855" +catalog_checksum = "sha256:bdb1a42753806ce00fe1d6f2737a804439ba012ba477e511e2879e35d3d6c4d0" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -289,7 +289,7 @@ checksum = "sha256:b91854c7f0e6d14c74751f607d2c0e3dfcefef55f5178bbafae59d47e577a [[region]] host = "Cargo.toml" id = "anvil-workspace-lints" -checksum = "sha256:c0d399b21c665de0831143a3d78715ceb38f1d22511d8705f164aeb62e12f174" +checksum = "sha256:8a5e8ddf0e06b2c95f7aae84878ea47f791c3451d783a446ff4d290eb5eebbe8" [[region]] host = "Justfile" diff --git a/Cargo.toml b/Cargo.toml index 8b7b39891..46009c5db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -182,6 +182,7 @@ clippy.deref_by_slicing = "warn" clippy.disallowed_script_idents = "warn" clippy.empty_drop = "warn" clippy.empty_enum_variants_with_brackets = "warn" +clippy.empty_structs_with_brackets = "warn" clippy.fn_to_numeric_cast_any = "warn" clippy.if_then_some_else_none = "warn" clippy.map_err_ignore = "warn" @@ -210,9 +211,8 @@ clippy.wildcard_imports = "allow" # --- clippy: catalog entries this project adds outside the managed region --- # The dotted-key form above keeps `[workspace.lints]` open, so these extend the # same scope without editing the anvil-managed block and provoking its drift -# detector. They complete the `M-STATIC-VERIFICATION` catalog, which names all -# three: https://microsoft.github.io/rust-guidelines/guidelines/universal/#M-STATIC-VERIFICATION -clippy.empty_structs_with_brackets = "warn" +# detector. They complete the `M-STATIC-VERIFICATION` catalog: +# https://microsoft.github.io/rust-guidelines/guidelines/universal/#M-STATIC-VERIFICATION clippy.too_long_first_doc_paragraph = "warn" # The guideline's own opt-out: a structured-logging call site legitimately # passes a literal holding `{field}` placeholders for the logger to expand. diff --git a/crates/cargo-anvil/docs/design/README.md b/crates/cargo-anvil/docs/design/README.md index 4140f15ff..9879918b1 100644 --- a/crates/cargo-anvil/docs/design/README.md +++ b/crates/cargo-anvil/docs/design/README.md @@ -308,7 +308,12 @@ Detail on each host: `[lints]\nworkspace = true`. The emitter uses `toml-edit` for round-trip-safe manipulation. In a single-crate repo (no `[workspace]` table), the workspace region becomes `anvil-lints` and contains a single `[lints]` table with the same - dotted-key layout. + dotted-key layout. The catalog favors broadly applicable, low-false-positive + diagnostics. It warns on empty braced structs whose unit form is clearer + (`empty_structs_with_brackets`). Documentation completeness, whether production + code may panic, and whether crate-internal APIs may use `pub` remain repository + policy: `missing_docs`, `clippy::panic`, and `unreachable_pub` are not catalog + defaults and adopters can add them outside the managed region. - **`deny.toml`** — one managed region per top-level section (`[advisories]`, `[licenses]`, `[bans]`, `[sources]`) carrying the tool's baseline license/advisory rules. The bans baseline rejects wildcard registry requirements while allowing versionless path or Git diff --git a/crates/cargo-anvil/src/anvil/artifacts/region.rs b/crates/cargo-anvil/src/anvil/artifacts/region.rs index 98ecca239..6815ad15f 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/region.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/region.rs @@ -272,7 +272,7 @@ mod tests { #[test] fn catalog_intentionally_omits_contested_lints() { - for needle in ["rust.missing_docs", "clippy.expect_used", "clippy.panic "] { + for needle in ["rust.missing_docs", "rust.unreachable_pub", "clippy.expect_used", "clippy.panic "] { assert!( !LINTS_BODY.contains(needle), "catalog now contains '{needle}'; if intentional, update the catalog-omission test" @@ -288,6 +288,7 @@ mod tests { "clippy.deref_by_slicing = \"warn\"", "clippy.empty_drop = \"warn\"", "clippy.empty_enum_variants_with_brackets = \"warn\"", + "clippy.empty_structs_with_brackets = \"warn\"", "clippy.fn_to_numeric_cast_any = \"warn\"", "clippy.if_then_some_else_none = \"warn\"", "clippy.multiple_unsafe_ops_per_block = \"warn\"", diff --git a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml b/crates/cargo-anvil/templates/regions/cargo-lints-body.toml index 463475eb4..66a905fba 100644 --- a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml +++ b/crates/cargo-anvil/templates/regions/cargo-lints-body.toml @@ -59,6 +59,7 @@ clippy.deref_by_slicing = "warn" clippy.disallowed_script_idents = "warn" clippy.empty_drop = "warn" clippy.empty_enum_variants_with_brackets = "warn" +clippy.empty_structs_with_brackets = "warn" clippy.fn_to_numeric_cast_any = "warn" clippy.if_then_some_else_none = "warn" clippy.map_err_ignore = "warn" diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index efe8997e1..f744aecac 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1726,6 +1726,7 @@ clippy.deref_by_slicing = "warn" clippy.disallowed_script_idents = "warn" clippy.empty_drop = "warn" clippy.empty_enum_variants_with_brackets = "warn" +clippy.empty_structs_with_brackets = "warn" clippy.fn_to_numeric_cast_any = "warn" clippy.if_then_some_else_none = "warn" clippy.map_err_ignore = "warn" diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 3ead3b839..42bb61877 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1839,6 +1839,7 @@ clippy.deref_by_slicing = "warn" clippy.disallowed_script_idents = "warn" clippy.empty_drop = "warn" clippy.empty_enum_variants_with_brackets = "warn" +clippy.empty_structs_with_brackets = "warn" clippy.fn_to_numeric_cast_any = "warn" clippy.if_then_some_else_none = "warn" clippy.map_err_ignore = "warn" diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 49d2402ce..086ffa506 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -523,6 +523,7 @@ clippy.deref_by_slicing = "warn" clippy.disallowed_script_idents = "warn" clippy.empty_drop = "warn" clippy.empty_enum_variants_with_brackets = "warn" +clippy.empty_structs_with_brackets = "warn" clippy.fn_to_numeric_cast_any = "warn" clippy.if_then_some_else_none = "warn" clippy.map_err_ignore = "warn" From 060817ab87280d4eb6f8e922679ed63a44ac1c44 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 16:55:49 +0200 Subject: [PATCH 02/11] refactor(cargo-anvil): centralize lint overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 4 ++-- Cargo.toml | 12 ++---------- crates/cargo-anvil/docs/design/README.md | 10 ++++++---- crates/cargo-anvil/src/anvil/artifacts/region.rs | 1 + .../templates/regions/cargo-lints-body.toml | 2 ++ .../tests/snapshots/snapshots__ado_backend.snap | 2 ++ .../tests/snapshots/snapshots__github_backend.snap | 2 ++ .../tests/snapshots/snapshots__local_only.snap | 2 ++ 8 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index c4422211d..6709c22f9 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.10.0" -catalog_checksum = "sha256:bdb1a42753806ce00fe1d6f2737a804439ba012ba477e511e2879e35d3d6c4d0" +catalog_checksum = "sha256:b0ddc2a44542f124d32c4f9c7ce9e67401d8694dc6b43b05663c08dab8f5a0e0" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -289,7 +289,7 @@ checksum = "sha256:b91854c7f0e6d14c74751f607d2c0e3dfcefef55f5178bbafae59d47e577a [[region]] host = "Cargo.toml" id = "anvil-workspace-lints" -checksum = "sha256:8a5e8ddf0e06b2c95f7aae84878ea47f791c3451d783a446ff4d290eb5eebbe8" +checksum = "sha256:8745485a19a7f5ac9f11f75fa7af5a7845faedf7b91a4f831f869513449a287e" [[region]] host = "Justfile" diff --git a/Cargo.toml b/Cargo.toml index 46009c5db..99c811778 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -198,6 +198,8 @@ clippy.unused_result_ok = "warn" clippy.unwrap_used = "warn" # --- clippy: opinionated suppressions of category-enabled lints ------------ +# Literal braces are valid data in templates and structured-logging messages. +clippy.literal_string_with_formatting_args = "allow" clippy.missing_const_for_fn = "allow" clippy.multiple_crate_versions = "allow" clippy.option_if_let_else = "allow" @@ -208,16 +210,6 @@ clippy.significant_drop_tightening = "allow" clippy.wildcard_imports = "allow" # <<< anvil-managed: anvil-workspace-lints -# --- clippy: catalog entries this project adds outside the managed region --- -# The dotted-key form above keeps `[workspace.lints]` open, so these extend the -# same scope without editing the anvil-managed block and provoking its drift -# detector. They complete the `M-STATIC-VERIFICATION` catalog: -# https://microsoft.github.io/rust-guidelines/guidelines/universal/#M-STATIC-VERIFICATION -clippy.too_long_first_doc_paragraph = "warn" -# The guideline's own opt-out: a structured-logging call site legitimately -# passes a literal holding `{field}` placeholders for the logger to expand. -clippy.literal_string_with_formatting_args = "allow" - # A bit of debugging support for release builds. [profile.release] debug = "line-tables-only" diff --git a/crates/cargo-anvil/docs/design/README.md b/crates/cargo-anvil/docs/design/README.md index 9879918b1..57a49f0d2 100644 --- a/crates/cargo-anvil/docs/design/README.md +++ b/crates/cargo-anvil/docs/design/README.md @@ -310,10 +310,12 @@ Detail on each host: becomes `anvil-lints` and contains a single `[lints]` table with the same dotted-key layout. The catalog favors broadly applicable, low-false-positive diagnostics. It warns on empty braced structs whose unit form is clearer - (`empty_structs_with_brackets`). Documentation completeness, whether production - code may panic, and whether crate-internal APIs may use `pub` remain repository - policy: `missing_docs`, `clippy::panic`, and `unreachable_pub` are not catalog - defaults and adopters can add them outside the managed region. + (`empty_structs_with_brackets`) and allows literal strings with formatting-like + braces because templates and structured-logging messages legitimately carry such + text. Documentation completeness, whether production code may panic, and whether + crate-internal APIs may use `pub` remain repository policy: `missing_docs`, + `clippy::panic`, and `unreachable_pub` are not catalog defaults and adopters can + add them outside the managed region. - **`deny.toml`** — one managed region per top-level section (`[advisories]`, `[licenses]`, `[bans]`, `[sources]`) carrying the tool's baseline license/advisory rules. The bans baseline rejects wildcard registry requirements while allowing versionless path or Git diff --git a/crates/cargo-anvil/src/anvil/artifacts/region.rs b/crates/cargo-anvil/src/anvil/artifacts/region.rs index 6815ad15f..f6a7bb9b5 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/region.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/region.rs @@ -298,6 +298,7 @@ mod tests { "clippy.unnecessary_safety_doc = \"warn\"", "clippy.unneeded_field_pattern = \"warn\"", "clippy.unused_result_ok = \"warn\"", + "clippy.literal_string_with_formatting_args = \"allow\"", "clippy.redundant_pub_crate = \"allow\"", "clippy.should_panic_without_expect = \"allow\"", ] { diff --git a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml b/crates/cargo-anvil/templates/regions/cargo-lints-body.toml index 66a905fba..e06c88cec 100644 --- a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml +++ b/crates/cargo-anvil/templates/regions/cargo-lints-body.toml @@ -75,6 +75,8 @@ clippy.unused_result_ok = "warn" clippy.unwrap_used = "warn" # --- clippy: opinionated suppressions of category-enabled lints ------------ +# Literal braces are valid data in templates and structured-logging messages. +clippy.literal_string_with_formatting_args = "allow" clippy.missing_const_for_fn = "allow" clippy.multiple_crate_versions = "allow" clippy.option_if_let_else = "allow" diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index f744aecac..c63768929 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1742,6 +1742,8 @@ clippy.unused_result_ok = "warn" clippy.unwrap_used = "warn" # --- clippy: opinionated suppressions of category-enabled lints ------------ +# Literal braces are valid data in templates and structured-logging messages. +clippy.literal_string_with_formatting_args = "allow" clippy.missing_const_for_fn = "allow" clippy.multiple_crate_versions = "allow" clippy.option_if_let_else = "allow" diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 42bb61877..7c333e280 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1855,6 +1855,8 @@ clippy.unused_result_ok = "warn" clippy.unwrap_used = "warn" # --- clippy: opinionated suppressions of category-enabled lints ------------ +# Literal braces are valid data in templates and structured-logging messages. +clippy.literal_string_with_formatting_args = "allow" clippy.missing_const_for_fn = "allow" clippy.multiple_crate_versions = "allow" clippy.option_if_let_else = "allow" diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 086ffa506..7fceb63e9 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -539,6 +539,8 @@ clippy.unused_result_ok = "warn" clippy.unwrap_used = "warn" # --- clippy: opinionated suppressions of category-enabled lints ------------ +# Literal braces are valid data in templates and structured-logging messages. +clippy.literal_string_with_formatting_args = "allow" clippy.missing_const_for_fn = "allow" clippy.multiple_crate_versions = "allow" clippy.option_if_let_else = "allow" From 730a17cd44474973aeb270a7b2d2bf63953720fb Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 16:57:00 +0200 Subject: [PATCH 03/11] test(cargo-anvil): update lint adoption snapshot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../toml_adoption__keeps_an_unmanaged_dotted_lint.snap | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap b/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap index d1b87263b..c3c56aa45 100644 --- a/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap +++ b/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap @@ -86,6 +86,7 @@ clippy.deref_by_slicing = "warn" clippy.disallowed_script_idents = "warn" clippy.empty_drop = "warn" clippy.empty_enum_variants_with_brackets = "warn" +clippy.empty_structs_with_brackets = "warn" clippy.fn_to_numeric_cast_any = "warn" clippy.if_then_some_else_none = "warn" clippy.map_err_ignore = "warn" @@ -101,6 +102,8 @@ clippy.unused_result_ok = "warn" clippy.unwrap_used = "warn" # --- clippy: opinionated suppressions of category-enabled lints ------------ +# Literal braces are valid data in templates and structured-logging messages. +clippy.literal_string_with_formatting_args = "allow" clippy.missing_const_for_fn = "allow" clippy.multiple_crate_versions = "allow" clippy.option_if_let_else = "allow" From c8483b404404dda97382ff616326c777598d3336 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 19:14:29 +0200 Subject: [PATCH 04/11] feat(cargo-anvil): split managed lint regions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 16 +- Cargo.toml | 132 ++++++------ crates/cargo-anvil/docs/design/README.md | 27 ++- .../cargo-anvil/docs/design/extensibility.md | 25 ++- crates/cargo-anvil/docs/design/updates.md | 15 ++ crates/cargo-anvil/docs/verification.md | 2 +- crates/cargo-anvil/src/anvil/artifacts/mod.rs | 16 +- .../cargo-anvil/src/anvil/artifacts/region.rs | 196 +++++++++++------- crates/cargo-anvil/src/emit/managed_region.rs | 5 +- crates/cargo-anvil/src/region.rs | 183 ++++++++++++++++ crates/cargo-anvil/src/run.rs | 15 +- .../templates/regions/cargo-clippy-lints.toml | 47 +++++ .../templates/regions/cargo-lints-body.toml | 87 -------- .../templates/regions/cargo-rust-lints.toml | 25 +++ .../regions/cargo-rustdoc-lints.toml | 5 + crates/cargo-anvil/tests/fixtures.rs | 26 ++- .../snapshots/snapshots__ado_backend.snap | 136 ++++++------ .../snapshots/snapshots__github_backend.snap | 136 ++++++------ .../snapshots/snapshots__local_only.snap | 136 ++++++------ ...ption__keeps_an_unmanaged_dotted_lint.snap | 146 +++++++------ crates/cargo-anvil/tests/toml_adoption.rs | 72 ++++++- 21 files changed, 901 insertions(+), 547 deletions(-) create mode 100644 crates/cargo-anvil/templates/regions/cargo-clippy-lints.toml delete mode 100644 crates/cargo-anvil/templates/regions/cargo-lints-body.toml create mode 100644 crates/cargo-anvil/templates/regions/cargo-rust-lints.toml create mode 100644 crates/cargo-anvil/templates/regions/cargo-rustdoc-lints.toml diff --git a/.anvil.lock b/.anvil.lock index 63ab62a8f..266dd91b8 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.11.0" -catalog_checksum = "sha256:8faddf3d64368d24368a805d03d0e51a102fe5d6060a920c89208495e6930911" +catalog_checksum = "sha256:4bd4b5329075f104c5830e767c6f79e5eed6c1de72370b9836603488a359bcdb" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -288,8 +288,18 @@ checksum = "sha256:b91854c7f0e6d14c74751f607d2c0e3dfcefef55f5178bbafae59d47e577a [[region]] host = "Cargo.toml" -id = "anvil-workspace-lints" -checksum = "sha256:8745485a19a7f5ac9f11f75fa7af5a7845faedf7b91a4f831f869513449a287e" +id = "anvil-workspace-clippy-lints" +checksum = "sha256:cf78b123235b694a8222e499e7ba91729de9325d1de3f5275e58a85880fabc4e" + +[[region]] +host = "Cargo.toml" +id = "anvil-workspace-rust-lints" +checksum = "sha256:ade16a15a1ecfbe6a8f98aca044266f570a7e658bef51f4b3f6e23d8ebd14798" + +[[region]] +host = "Cargo.toml" +id = "anvil-workspace-rustdoc-lints" +checksum = "sha256:5040c38d64a80ddedc2c51d25881841a1670de77e68bbd14b67ac66fd0062091" [[region]] host = "Justfile" diff --git a/Cargo.toml b/Cargo.toml index 99c811778..3c9f10ee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,96 +119,94 @@ windows-sys = { version = "0.61.2", default-features = false } wiremock = { version = "0.6.5", default-features = false } zstd = { version = "0.14.0", default-features = false } -# >>> anvil-managed: anvil-workspace-lints -[workspace.lints] -# Catalog of opinionated lints, in dotted-key form so users can extend the -# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. -# The host-specific table header (`[workspace.lints]` or `[lints]`) is -# prepended by cargo-anvil based on whether the manifest is a workspace -# root or a single-crate Cargo.toml. - -# --- rust ------------------------------------------------------------------ -rust.ambiguous_negative_literals = "warn" -rust.missing_debug_implementations = "warn" -rust.redundant_imports = "warn" -rust.redundant_lifetimes = "warn" -rust.trivial_numeric_casts = "warn" -rust.unsafe_op_in_unsafe_fn = "warn" -rust.unused_lifetimes = "warn" +# >>> anvil-managed: anvil-workspace-rust-lints +[workspace.lints.rust] +# Rust compiler lint policy. Repository-specific Rust lints can follow this +# region as bare keys in the same table. +ambiguous_negative_literals = "warn" +missing_debug_implementations = "warn" +redundant_imports = "warn" +redundant_lifetimes = "warn" +trivial_numeric_casts = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_lifetimes = "warn" # `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined # with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name # becomes a hard build failure. Pre-declare the cfgs that # `cargo llvm-cov` sets so the recommended coverage-exclusion pattern # `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. # Adopters who need additional cfg names take ownership of this one -# line (edit the check-cfg array); anvil's drift detector will +# assignment (edit the check-cfg array); anvil's drift detector will # emit a `.anvil-proposed` sibling on future catalog bumps so the # customization is preserved. -rust.unexpected_cfgs = { level = "warn", check-cfg = [ +unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(coverage,coverage_nightly)', 'cfg(loom)', 'cfg(miri_race_coverage)', 'cfg(miri_strict_provenance)', 'cfg(miri_tree_borrows)', ] } +# <<< anvil-managed: anvil-workspace-rust-lints -# --- rustdoc --------------------------------------------------------------- -rustdoc.broken_intra_doc_links = "warn" -rustdoc.missing_crate_level_docs = "warn" -rustdoc.unescaped_backticks = "warn" - -# --- clippy: category gates (priority -1 so per-lint allows can override) -- -clippy.cargo = { level = "warn", priority = -1 } -clippy.complexity = { level = "warn", priority = -1 } -clippy.correctness = { level = "warn", priority = -1 } -clippy.nursery = { level = "warn", priority = -1 } -clippy.pedantic = { level = "warn", priority = -1 } -clippy.perf = { level = "warn", priority = -1 } -clippy.style = { level = "warn", priority = -1 } -clippy.suspicious = { level = "warn", priority = -1 } +# >>> anvil-managed: anvil-workspace-rustdoc-lints +[workspace.lints.rustdoc] +# Rustdoc lint policy. Repository-specific rustdoc lints can follow this +# region as bare keys in the same table. +broken_intra_doc_links = "warn" +missing_crate_level_docs = "warn" +unescaped_backticks = "warn" +# <<< anvil-managed: anvil-workspace-rustdoc-lints -# --- clippy: opinionated additions ----------------------------------------- +# >>> anvil-managed: anvil-workspace-clippy-lints +[workspace.lints.clippy] +# Clippy category gates use priority -1 so per-lint allows can override them. +cargo = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +correctness = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +suspicious = { level = "warn", priority = -1 } # Two-repo consensus (oxidizer + oxidizer-github). Restriction-group # lints that catch real code-smell cases. Adding a workspace-wide lint # means adopters can only opt out per-crate or by taking ownership of # this region; only enable when the consensus is strong enough to # justify that cost. -clippy.allow_attributes = "warn" -clippy.allow_attributes_without_reason = "warn" -clippy.as_pointer_underscore = "warn" -clippy.assertions_on_result_states = "warn" -clippy.clone_on_ref_ptr = "warn" -clippy.deref_by_slicing = "warn" -clippy.disallowed_script_idents = "warn" -clippy.empty_drop = "warn" -clippy.empty_enum_variants_with_brackets = "warn" -clippy.empty_structs_with_brackets = "warn" -clippy.fn_to_numeric_cast_any = "warn" -clippy.if_then_some_else_none = "warn" -clippy.map_err_ignore = "warn" -clippy.multiple_unsafe_ops_per_block = "warn" -clippy.redundant_type_annotations = "warn" -clippy.renamed_function_params = "warn" -clippy.semicolon_outside_block = "warn" -clippy.undocumented_unsafe_blocks = "warn" -clippy.unnecessary_safety_comment = "warn" -clippy.unnecessary_safety_doc = "warn" -clippy.unneeded_field_pattern = "warn" -clippy.unused_result_ok = "warn" -clippy.unwrap_used = "warn" - -# --- clippy: opinionated suppressions of category-enabled lints ------------ +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +as_pointer_underscore = "warn" +assertions_on_result_states = "warn" +clone_on_ref_ptr = "warn" +deref_by_slicing = "warn" +disallowed_script_idents = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +fn_to_numeric_cast_any = "warn" +if_then_some_else_none = "warn" +map_err_ignore = "warn" +multiple_unsafe_ops_per_block = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +semicolon_outside_block = "warn" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unneeded_field_pattern = "warn" +unused_result_ok = "warn" +unwrap_used = "warn" # Literal braces are valid data in templates and structured-logging messages. -clippy.literal_string_with_formatting_args = "allow" -clippy.missing_const_for_fn = "allow" -clippy.multiple_crate_versions = "allow" -clippy.option_if_let_else = "allow" -clippy.redundant_pub_crate = "allow" -clippy.should_panic_without_expect = "allow" -clippy.significant_drop_tightening = "allow" +literal_string_with_formatting_args = "allow" +missing_const_for_fn = "allow" +multiple_crate_versions = "allow" +option_if_let_else = "allow" +redundant_pub_crate = "allow" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" # Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 -clippy.wildcard_imports = "allow" -# <<< anvil-managed: anvil-workspace-lints +wildcard_imports = "allow" +# <<< anvil-managed: anvil-workspace-clippy-lints # A bit of debugging support for release builds. [profile.release] diff --git a/crates/cargo-anvil/docs/design/README.md b/crates/cargo-anvil/docs/design/README.md index 57a49f0d2..28a37cfec 100644 --- a/crates/cargo-anvil/docs/design/README.md +++ b/crates/cargo-anvil/docs/design/README.md @@ -255,7 +255,7 @@ repo/ ├── Justfile managed-region: anvil-imports ├── justfiles/anvil/ owned (see local.md) ├── .anvil/container/ owned — the container image definition (see containers.md) -├── Cargo.toml managed-region: anvil-workspace-lints (or anvil-lints in single-crate) +├── Cargo.toml managed-regions: anvil-{workspace-,}{rust,rustdoc,clippy}-lints ├── crates//Cargo.toml managed-region: anvil-lints (one per workspace member) ├── deny.toml managed-regions: anvil-deny-{advisories,licenses,bans,sources} ├── rustfmt.toml managed-region: anvil-rustfmt @@ -296,20 +296,19 @@ Detail on each host: credentials. `justfiles/` holds `.just` recipes and nothing else, so these live in a tool-owned directory of their own; see [containers.md](./containers.md). -- **`Cargo.toml` lints regions** — workspace `Cargo.toml` carries the - `anvil-workspace-lints` region containing a single `[workspace.lints]` table whose - rust/clippy/rustdoc entries are written in dotted-key form - (`rust.unsafe_op_in_unsafe_fn = "warn"`, `clippy.unwrap_used = "warn"`, etc.). This - form is chosen because TOML forbids re-declaring a table header — if anvil wrote - `[workspace.lints.clippy]` inside the region, users couldn't add another - `[workspace.lints.clippy]` block elsewhere in the file. With dotted keys, users - append new lints in the same scope right after the closing sentinel; see §7. Each - member `Cargo.toml` carries an `anvil-lints` region with exactly +- **`Cargo.toml` lints regions** — workspace `Cargo.toml` carries separate + `anvil-workspace-rust-lints`, `anvil-workspace-rustdoc-lints`, and + `anvil-workspace-clippy-lints` regions. Each opens its explicit + `[workspace.lints.]` table and contains bare lint names. Users append + repository-specific bare lint keys immediately after the corresponding closing + sentinel, before the next table header. This gives `cargo sort --grouped` a stable + sorting unit for each namespace without mixing managed and repository-owned keys. + Each member `Cargo.toml` carries an `anvil-lints` region with exactly `[lints]\nworkspace = true`. The emitter uses `toml-edit` for round-trip-safe - manipulation. In a single-crate repo (no `[workspace]` table), the workspace region - becomes `anvil-lints` and contains a single `[lints]` table with the same - dotted-key layout. The catalog favors broadly applicable, low-false-positive - diagnostics. It warns on empty braced structs whose unit form is clearer + manipulation. In a single-crate repo (no `[workspace]` table), the root manifest + instead carries `anvil-rust-lints`, `anvil-rustdoc-lints`, and + `anvil-clippy-lints` regions under `[lints.]`. The catalog favors broadly + applicable, low-false-positive diagnostics. It warns on empty braced structs whose unit form is clearer (`empty_structs_with_brackets`) and allows literal strings with formatting-like braces because templates and structured-logging messages legitimately carry such text. Documentation completeness, whether production code may panic, and whether diff --git a/crates/cargo-anvil/docs/design/extensibility.md b/crates/cargo-anvil/docs/design/extensibility.md index 3edfcffb4..523a49d6d 100644 --- a/crates/cargo-anvil/docs/design/extensibility.md +++ b/crates/cargo-anvil/docs/design/extensibility.md @@ -22,7 +22,8 @@ front-end binary. Every tool built on the engine emits the *same* fixed namespac - sidecar manifest `.anvil.lock` - review-sibling suffix `.anvil-proposed` - managed-region sentinels `# >>> anvil-managed: ` … `# <<< anvil-managed: ` -- region IDs `anvil-imports`, `anvil-workspace-lints`, `anvil-lints` +- region IDs such as `anvil-imports`, `anvil-workspace-rust-lints`, + `anvil-rust-lints`, and `anvil-lints` - recipe-name prefix `anvil-` (`anvil-pr`, `anvil-clippy`, …) That shared vocabulary is a feature: it signals "this content is managed by the anvil engine — @@ -208,8 +209,12 @@ pub mod artifacts { // Managed regions spliced into user-composed host files. pub mod region { pub fn justfile_imports() -> Artifact; // Justfile / anvil-imports - pub fn workspace_lints() -> Artifact; // Cargo.toml (workspace) / anvil-workspace-lints - pub fn single_crate_lints() -> Artifact; // Cargo.toml (single crate) / anvil-lints + pub fn workspace_rust_lints() -> Artifact; + pub fn workspace_rustdoc_lints() -> Artifact; + pub fn workspace_clippy_lints() -> Artifact; + pub fn single_crate_rust_lints() -> Artifact; + pub fn single_crate_rustdoc_lints() -> Artifact; + pub fn single_crate_clippy_lints() -> Artifact; pub fn member_lints() -> Artifact; // /Cargo.toml / anvil-lints pub fn deny_advisories() -> Artifact; // deny.toml / anvil-deny-advisories pub fn deny_licenses() -> Artifact; // deny.toml / anvil-deny-licenses @@ -305,13 +310,13 @@ crate's `Cargo.toml` just adds one artifact: and the engine replicates it across all members, tracks each in `.anvil.lock`, and reconciles drift per member — no per-fork engine changes. -> Note anvil's own lint regions are modeled as three separate artifacts under this scheme, with no -> region-id-specific engine logic: a `WorkspaceCargoToml` region carrying `[workspace.lints]` -> (`anvil-workspace-lints`), a `SingleCrateCargoToml` region carrying the full `[lints]` catalog -> (`anvil-lints`), and an `EachMemberManifest` member stub (`anvil-lints`). In a workspace the -> first and third emit; in a single-crate repo only the second does (it has no workspace members), -> so the full catalog lands directly in the root `[lints]`. Which set applies is purely a property -> of the selectors on the built-in artifacts, transparent to forks. +> Note anvil's own lint regions are modeled as seven separate artifacts under this scheme, with no +> region-id-specific engine logic: three `WorkspaceCargoToml` regions carrying the Rust, rustdoc, +> and Clippy `[workspace.lints.]` tables; three `SingleCrateCargoToml` equivalents under +> `[lints.]`; and an `EachMemberManifest` member stub (`anvil-lints`). In a workspace the +> workspace trio and member stub emit; in a single-crate repo only the single-crate trio does. +> Which set applies is purely a property of the selectors on the built-in artifacts, transparent +> to forks. > **On-disk casing.** Host paths and owned-file paths are canonical (`Justfile`, `Cargo.toml`), but > the engine resolves each against the repo case-insensitively and reuses whatever casing already diff --git a/crates/cargo-anvil/docs/design/updates.md b/crates/cargo-anvil/docs/design/updates.md index 8e42b364e..68f34e5bc 100644 --- a/crates/cargo-anvil/docs/design/updates.md +++ b/crates/cargo-anvil/docs/design/updates.md @@ -161,6 +161,21 @@ Independently configurable tables have separate catalog regions: * `deny.toml`: advisories, licenses, bans, sources. * `spellcheck.toml`: root settings, `[Hunspell]`, `[Hunspell.quirks]`. +* root `Cargo.toml`: Rust, rustdoc, and Clippy lint subtables. + +The lint regions own `[workspace.lints.rust]`, `[workspace.lints.rustdoc]`, and +`[workspace.lints.clippy]` in workspaces, or the corresponding `[lints.*]` +tables in single-crate repositories. A repository extends a subtable with bare +lint names after that region's closing sentinel. Keeping each namespace in its +own region lets `cargo sort --grouped` treat the managed and repository-owned +blocks as independent groups without moving a sentinel through another lint +namespace. + +The old combined `anvil-workspace-lints` or root `anvil-lints` block retires in +the same run that introduces the three replacements. During this migration, +repository-owned dotted assignments below the old region are rewritten into +bare assignments under the matching new subtable. Their values and comments +are preserved; assignments outside the three lint namespaces are unaffected. For example, a repository's `[Hunspell] transform_regex = ["^[0-9]+$"]` stays under `[Hunspell]` below `anvil-spellcheck-hunspell`, not under quirks. diff --git a/crates/cargo-anvil/docs/verification.md b/crates/cargo-anvil/docs/verification.md index e2823bc04..6fe1c3ff9 100644 --- a/crates/cargo-anvil/docs/verification.md +++ b/crates/cargo-anvil/docs/verification.md @@ -255,7 +255,7 @@ Acknowledged limits of this strategy: | `.github/workflows/anvil-pr.yml` | Hand-written self-validation wrapper (the one bootstrap file). | | `.github/workflows/anvil-pr-impl.yml` (and friends) | Regenerated by `cargo anvil`. Subject to the regenerate-check. | | `justfiles/anvil/*.just` | Regenerated. Subject to the regenerate-check. | -| `Cargo.toml` (anvil-workspace-lints region) | Regenerated. Subject to the regenerate-check. | +| `Cargo.toml` (anvil workspace lint regions) | Regenerated. Subject to the regenerate-check. | | `.anvil.lock` | The manifest itself. Diffed on every PR. | | `docs/release-checklist.md` | Pre-publish checks for things dogfooding misses. | diff --git a/crates/cargo-anvil/src/anvil/artifacts/mod.rs b/crates/cargo-anvil/src/anvil/artifacts/mod.rs index f7279229f..49bcfa038 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/mod.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/mod.rs @@ -67,8 +67,12 @@ pub(crate) fn anvil_artifacts() -> Vec { instructions::cargo_anvil(), instructions::adoption_skill(), region::justfile_imports(), - region::workspace_lints(), - region::single_crate_lints(), + region::workspace_rust_lints(), + region::workspace_rustdoc_lints(), + region::workspace_clippy_lints(), + region::single_crate_rust_lints(), + region::single_crate_rustdoc_lints(), + region::single_crate_clippy_lints(), region::member_lints(), region::deny_advisories(), region::deny_licenses(), @@ -227,8 +231,12 @@ mod tests { instructions::cargo_anvil(), instructions::adoption_skill(), region::justfile_imports(), - region::workspace_lints(), - region::single_crate_lints(), + region::workspace_rust_lints(), + region::workspace_rustdoc_lints(), + region::workspace_clippy_lints(), + region::single_crate_rust_lints(), + region::single_crate_rustdoc_lints(), + region::single_crate_clippy_lints(), region::member_lints(), region::deny_advisories(), region::deny_licenses(), diff --git a/crates/cargo-anvil/src/anvil/artifacts/region.rs b/crates/cargo-anvil/src/anvil/artifacts/region.rs index f6a7bb9b5..d202d4784 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/region.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/region.rs @@ -14,16 +14,21 @@ use super::justfile; use crate::catalog::{Artifact, HostSelector, RegionId, RegionSpec}; use crate::region::CommentSyntax; -/// Region id for the workspace-scope lints (multi-crate workspaces). -const WORKSPACE_LINTS_REGION_ID: &str = "anvil-workspace-lints"; +const WORKSPACE_RUST_LINTS_REGION_ID: &str = "anvil-workspace-rust-lints"; +const WORKSPACE_RUSTDOC_LINTS_REGION_ID: &str = "anvil-workspace-rustdoc-lints"; +const WORKSPACE_CLIPPY_LINTS_REGION_ID: &str = "anvil-workspace-clippy-lints"; +const RUST_LINTS_REGION_ID: &str = "anvil-rust-lints"; +const RUSTDOC_LINTS_REGION_ID: &str = "anvil-rustdoc-lints"; +const CLIPPY_LINTS_REGION_ID: &str = "anvil-clippy-lints"; /// Region id for crate-scope lints — used both for single-crate repos (full /// catalog) and for each member of a multi-crate workspace (`workspace = /// true`). const CRATE_LINTS_REGION_ID: &str = "anvil-lints"; -/// Embedded body of the lint catalog, in dotted-key form (no table header). -const LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-lints-body.toml"); +const RUST_LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-rust-lints.toml"); +const RUSTDOC_LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-rustdoc-lints.toml"); +const CLIPPY_LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-clippy-lints.toml"); /// Embedded body of a workspace-member lints region. const MEMBER_LINTS_BODY: &str = include_str!("../../../templates/regions/cargo-member-lints.toml"); @@ -97,24 +102,26 @@ const CLIPPY_BODY: &str = include_str!("../../../templates/regions/clippy.toml") /// Embedded body of the `.gitattributes` managed region. const GITATTRIBUTES_BODY: &str = include_str!("../../../templates/regions/gitattributes"); -/// Render the body of the workspace-scope lints region: `[workspace.lints]` -/// header followed by the embedded catalog. #[must_use] -fn render_workspace_lints_body() -> String { - let mut out = String::with_capacity(LINTS_BODY.len() + 32); - out.push_str("[workspace.lints]\n"); - out.push_str(LINTS_BODY); +fn render_lints_body(scope: &str, namespace: &str, body: &str) -> String { + let mut out = String::with_capacity(scope.len() + namespace.len() + body.len() + 8); + out.push('['); + out.push_str(scope); + out.push('.'); + out.push_str(namespace); + out.push_str("]\n"); + out.push_str(body); out } -/// Render the body of the single-crate lints region: `[lints]` header -/// followed by the embedded catalog. #[must_use] -fn render_single_crate_lints_body() -> String { - let mut out = String::with_capacity(LINTS_BODY.len() + 16); - out.push_str("[lints]\n"); - out.push_str(LINTS_BODY); - out +fn lint_region(host: HostSelector, id: &'static str, scope: &str, namespace: &str, body: &str) -> Artifact { + Artifact::region(RegionSpec { + host, + id: RegionId::new(id), + body: render_lints_body(scope, namespace, body), + syntax: CommentSyntax::Hash, + }) } /// Build a single-path `Hash`-syntax region artifact. @@ -137,35 +144,70 @@ pub fn justfile_imports() -> Artifact { ) } -/// Root `Cargo.toml` / `anvil-workspace-lints`. -/// -/// The workspace-scope lint catalog under `[workspace.lints]`. Emitted only -/// in a multi-crate workspace; the [`HostSelector::WorkspaceCargoToml`] host -/// skips it in a single-crate repo. #[must_use] -pub fn workspace_lints() -> Artifact { - Artifact::region(RegionSpec { - host: HostSelector::WorkspaceCargoToml, - id: RegionId::new(WORKSPACE_LINTS_REGION_ID), - body: render_workspace_lints_body(), - syntax: CommentSyntax::Hash, - }) +pub fn workspace_rust_lints() -> Artifact { + lint_region( + HostSelector::WorkspaceCargoToml, + WORKSPACE_RUST_LINTS_REGION_ID, + "workspace.lints", + "rust", + RUST_LINTS_BODY, + ) } -/// Root `Cargo.toml` / `anvil-lints`. -/// -/// The full lint catalog under `[lints]`. Emitted only in a single-crate -/// repo; the [`HostSelector::SingleCrateCargoToml`] host skips it in a -/// workspace, where the catalog lives under `[workspace.lints]` and members -/// inherit it. #[must_use] -pub fn single_crate_lints() -> Artifact { - Artifact::region(RegionSpec { - host: HostSelector::SingleCrateCargoToml, - id: RegionId::new(CRATE_LINTS_REGION_ID), - body: render_single_crate_lints_body(), - syntax: CommentSyntax::Hash, - }) +pub fn workspace_rustdoc_lints() -> Artifact { + lint_region( + HostSelector::WorkspaceCargoToml, + WORKSPACE_RUSTDOC_LINTS_REGION_ID, + "workspace.lints", + "rustdoc", + RUSTDOC_LINTS_BODY, + ) +} + +#[must_use] +pub fn workspace_clippy_lints() -> Artifact { + lint_region( + HostSelector::WorkspaceCargoToml, + WORKSPACE_CLIPPY_LINTS_REGION_ID, + "workspace.lints", + "clippy", + CLIPPY_LINTS_BODY, + ) +} + +#[must_use] +pub fn single_crate_rust_lints() -> Artifact { + lint_region( + HostSelector::SingleCrateCargoToml, + RUST_LINTS_REGION_ID, + "lints", + "rust", + RUST_LINTS_BODY, + ) +} + +#[must_use] +pub fn single_crate_rustdoc_lints() -> Artifact { + lint_region( + HostSelector::SingleCrateCargoToml, + RUSTDOC_LINTS_REGION_ID, + "lints", + "rustdoc", + RUSTDOC_LINTS_BODY, + ) +} + +#[must_use] +pub fn single_crate_clippy_lints() -> Artifact { + lint_region( + HostSelector::SingleCrateCargoToml, + CLIPPY_LINTS_REGION_ID, + "lints", + "clippy", + CLIPPY_LINTS_BODY, + ) } /// `/Cargo.toml` / `anvil-lints` — the per-member `workspace = true` @@ -258,23 +300,23 @@ mod tests { use crate::region::{TomlAdoption, adopt_unmanaged_toml_tables, upsert_region}; #[test] - fn embedded_catalog_uses_dotted_keys() { - for line in LINTS_BODY.lines() { + fn embedded_catalog_uses_bare_keys() { + for line in [RUST_LINTS_BODY, RUSTDOC_LINTS_BODY, CLIPPY_LINTS_BODY] + .into_iter() + .flat_map(str::lines) + { let trimmed = line.trim_start(); - assert!( - !trimmed.starts_with('['), - "unexpected table header in cargo-lints-body.toml: {line}" - ); + assert!(!trimmed.starts_with('['), "unexpected table header in lint body: {line}"); } - assert!(LINTS_BODY.contains("rust.unsafe_op_in_unsafe_fn = \"warn\"")); - assert!(LINTS_BODY.contains("clippy.unwrap_used = \"warn\"")); + assert!(RUST_LINTS_BODY.contains("unsafe_op_in_unsafe_fn = \"warn\"")); + assert!(CLIPPY_LINTS_BODY.contains("unwrap_used = \"warn\"")); } #[test] fn catalog_intentionally_omits_contested_lints() { - for needle in ["rust.missing_docs", "rust.unreachable_pub", "clippy.expect_used", "clippy.panic "] { + for needle in ["missing_docs =", "unreachable_pub =", "expect_used =", "panic ="] { assert!( - !LINTS_BODY.contains(needle), + !format!("{RUST_LINTS_BODY}{RUSTDOC_LINTS_BODY}{CLIPPY_LINTS_BODY}").contains(needle), "catalog now contains '{needle}'; if intentional, update the catalog-omission test" ); } @@ -302,40 +344,37 @@ mod tests { "clippy.redundant_pub_crate = \"allow\"", "clippy.should_panic_without_expect = \"allow\"", ] { - assert!(LINTS_BODY.contains(needle), "catalog missing consensus lint '{needle}'"); + assert!( + CLIPPY_LINTS_BODY.contains(needle.trim_start_matches("clippy.")), + "catalog missing consensus lint '{needle}'" + ); } } #[test] fn catalog_declares_llvm_cov_cfgs_for_unexpected_cfgs_lint() { assert!( - LINTS_BODY.contains("rust.unexpected_cfgs"), + RUST_LINTS_BODY.contains("unexpected_cfgs"), "catalog must declare rust.unexpected_cfgs to pre-allow llvm-cov's coverage cfgs" ); assert!( - LINTS_BODY.contains("'cfg(coverage,coverage_nightly)'"), + RUST_LINTS_BODY.contains("'cfg(coverage,coverage_nightly)'"), "catalog's unexpected_cfgs check-cfg list must include coverage,coverage_nightly" ); } #[test] - fn workspace_body_prepends_workspace_lints_header() { - let body = render_workspace_lints_body(); - assert!(body.starts_with("[workspace.lints]\n")); - assert!(body.contains("clippy.pedantic = { level = \"warn\", priority = -1 }")); + fn workspace_bodies_open_namespace_subtables() { + assert!(render_lints_body("workspace.lints", "rust", RUST_LINTS_BODY).starts_with("[workspace.lints.rust]\n")); + assert!( + render_lints_body("workspace.lints", "clippy", CLIPPY_LINTS_BODY).contains("pedantic = { level = \"warn\", priority = -1 }") + ); } #[test] - fn single_crate_body_prepends_lints_header() { - let body = render_single_crate_lints_body(); - assert!(body.starts_with("[lints]\n")); - assert!(body.contains("clippy.unwrap_used = \"warn\"")); - for line in body.lines() { - let trimmed = line.trim_start(); - if trimmed.starts_with('[') { - assert_eq!(trimmed, "[lints]", "unexpected table header in single-crate body: {line}"); - } - } + fn single_crate_bodies_open_namespace_subtables() { + assert!(render_lints_body("lints", "rustdoc", RUSTDOC_LINTS_BODY).starts_with("[lints.rustdoc]\n")); + assert!(render_lints_body("lints", "clippy", CLIPPY_LINTS_BODY).contains("unwrap_used = \"warn\"")); } #[test] @@ -345,10 +384,23 @@ mod tests { } #[test] - fn dotted_key_body_parses_as_valid_toml_when_appended_to_workspace() { + fn namespace_bodies_compose_as_valid_workspace_toml() { let host = "[workspace]\nmembers = [\"crates/a\"]\n"; - let region_body = render_workspace_lints_body(); - let spliced = upsert_region(host, WORKSPACE_LINTS_REGION_ID, ®ion_body, CommentSyntax::Hash).unwrap(); + let spliced = [ + (WORKSPACE_RUST_LINTS_REGION_ID, "rust", RUST_LINTS_BODY), + (WORKSPACE_RUSTDOC_LINTS_REGION_ID, "rustdoc", RUSTDOC_LINTS_BODY), + (WORKSPACE_CLIPPY_LINTS_REGION_ID, "clippy", CLIPPY_LINTS_BODY), + ] + .into_iter() + .try_fold(host.to_owned(), |text, (id, namespace, body)| { + upsert_region( + &text, + id, + &render_lints_body("workspace.lints", namespace, body), + CommentSyntax::Hash, + ) + }) + .unwrap(); let _: toml_edit::DocumentMut = spliced.parse().expect("spliced TOML must be valid"); } diff --git a/crates/cargo-anvil/src/emit/managed_region.rs b/crates/cargo-anvil/src/emit/managed_region.rs index 1f8b41d4d..c88e11d73 100644 --- a/crates/cargo-anvil/src/emit/managed_region.rs +++ b/crates/cargo-anvil/src/emit/managed_region.rs @@ -27,8 +27,8 @@ use crate::decision::Decision; use crate::manifest::Manifest; use crate::plan::{PlanItem, Target}; use crate::region::{ - CommentSyntax, RegionPlacement, TomlAdoption, adopt_unmanaged_toml_tables, find_region, insert_after_region, managed_region_ids, - mask_retiring_managed_regions, start_region_offset, text_newline, upsert_region_with_newline, + CommentSyntax, RegionPlacement, TomlAdoption, adopt_unmanaged_toml_tables, find_region, insert_after_region, lint_region_placement, + managed_region_ids, mask_retiring_managed_regions, start_region_offset, text_newline, upsert_region_with_newline, }; /// What the reader should do about a refused region. @@ -369,6 +369,7 @@ fn splice( base }; + let placement = lint_region_placement(region_id, Some(base)).unwrap_or(placement); let spliced = upsert_region_with_newline(base, region_id, rendered_body, syntax, placement, newline) .map_err(|error| ManagedRegionRefusal::new(error, RefusalRemedy::MalformedMarkers))?; insert_after_region(&spliced, region_id, &residue, syntax) diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs index bd0ba253b..bd5563f8d 100644 --- a/crates/cargo-anvil/src/region.rs +++ b/crates/cargo-anvil/src/region.rs @@ -592,6 +592,21 @@ pub fn adopt_unmanaged_toml_tables(text: &str, body: &str, syntax: CommentSyntax boundaries.extend(protected.iter().map(|range| range.start)); boundaries.sort_unstable(); + if let Some(adoption) = adopt_dotted_child_assignments(text, &managed, &candidates, &boundaries, &protected) { + return adoption; + } + let legacy_masked = mask_legacy_lint_region_to_header(text, syntax); + if legacy_masked != masked + && let Some(legacy_candidates) = headed_tables(&legacy_masked) + { + let mut legacy_boundaries: Vec = legacy_candidates.iter().map(|table| table.header.start).collect(); + legacy_boundaries.extend(protected.iter().map(|range| range.start)); + legacy_boundaries.sort_unstable(); + if let Some(adoption) = adopt_dotted_child_assignments(text, &managed, &legacy_candidates, &legacy_boundaries, &protected) { + return adoption; + } + } + let mut deletions: Vec = Vec::new(); let mut residue = String::new(); @@ -599,6 +614,7 @@ pub fn adopt_unmanaged_toml_tables(text: &str, body: &str, syntax: CommentSyntax if candidate.array_of_tables { continue; } + let Some(managed_values) = managed .iter() .find(|table| !table.array_of_tables && table.path == candidate.path) @@ -661,6 +677,173 @@ pub fn adopt_unmanaged_toml_tables(text: &str, body: &str, syntax: CommentSyntax } } +fn mask_legacy_lint_region_to_header(text: &str, syntax: CommentSyntax) -> String { + let mut masked = mask_managed_regions(text, syntax).into_bytes(); + for (id, header) in [("anvil-workspace-lints", "[workspace.lints]"), ("anvil-lints", "[lints]")] { + let Ok(Some(region)) = find_region(text, id, syntax) else { + continue; + }; + let Some(relative) = region.body_str().find(header) else { + continue; + }; + let start = region.body.start + relative; + let end = start + header.len(); + masked[start..end].copy_from_slice(&text.as_bytes()[start..end]); + } + String::from_utf8(masked).expect("masking preserves UTF-8 and only restores original UTF-8 slices") +} + +pub(crate) fn lint_region_placement(region_id: &str, current: Option<&str>) -> Option { + const WORKSPACE: &[&str] = &[ + "anvil-workspace-rust-lints", + "anvil-workspace-rustdoc-lints", + "anvil-workspace-clippy-lints", + ]; + const SINGLE_CRATE: &[&str] = &["anvil-rust-lints", "anvil-rustdoc-lints", "anvil-clippy-lints"]; + let order = if WORKSPACE.contains(®ion_id) { + WORKSPACE + } else if SINGLE_CRATE.contains(®ion_id) { + SINGLE_CRATE + } else { + return None; + }; + let Some(text) = current else { + return Some(RegionPlacement::End); + }; + if matches!(find_region(text, region_id, CommentSyntax::Hash), Ok(Some(_))) { + return Some(RegionPlacement::End); + } + let position = order + .iter() + .position(|candidate| *candidate == region_id) + .expect("region membership was established above"); + for successor in &order[position + 1..] { + if let Ok(Some(region)) = find_region(text, successor, CommentSyntax::Hash) { + return Some(RegionPlacement::At(region.start_line.start)); + } + } + + let retiring: BTreeSet = ["anvil-workspace-lints", "anvil-lints"].into_iter().map(str::to_owned).collect(); + let parseable = mask_retiring_managed_regions(text, CommentSyntax::Hash, &retiring); + let before_profiles = headed_tables(&parseable).and_then(|tables| { + tables + .into_iter() + .filter(|table| table.path.first().is_some_and(|root| root == "patch" || root == "profile")) + .map(|table| table.header.start) + .min() + }); + let before_legacy = retiring + .iter() + .filter_map(|id| find_region(text, id, CommentSyntax::Hash).ok().flatten()) + .map(|region| region.start_line.start) + .min(); + Some(RegionPlacement::At( + before_profiles.into_iter().chain(before_legacy).min().unwrap_or(text.len()), + )) +} + +/// Move dotted assignments from a headed parent table into a managed child +/// table. For example, introducing `[workspace.lints.rust]` must turn an +/// existing `rust.missing_docs = "warn"` under `[workspace.lints]` into the +/// bare `missing_docs = "warn"` residue that follows the new region. +fn adopt_dotted_child_assignments( + text: &str, + managed: &[HeadedTable], + candidates: &[HeadedTable], + boundaries: &[usize], + protected: &[ByteRange], +) -> Option { + let [managed] = managed else { + return None; + }; + let (namespace, parent_path) = managed.path.split_last()?; + let candidate = candidates + .iter() + .find(|table| !table.array_of_tables && table.path == parent_path)?; + let end = boundary_after(boundaries, candidate.header.start, text.len()); + let mut deletions = Vec::new(); + let mut residue = String::new(); + + for entry in candidate + .entries + .iter() + .filter(|entry| entry.path.first().is_some_and(|segment| segment == namespace)) + { + let start = protected + .iter() + .find(|range| range.start <= entry.span.start && entry.span.start < range.end) + .map_or(entry.span.start, |range| range.end); + let relative_path = &entry.path[1..]; + match managed.values.get(relative_path) { + Some(managed_value) if *managed_value == entry.value => {} + Some(managed_value) => { + return Some(TomlAdoption::Conflict { + table: candidate.path.join("."), + key: candidate + .path + .iter() + .chain(entry.path.iter()) + .cloned() + .collect::>() + .join("."), + managed: managed_value.clone(), + hand_written: entry.value.clone(), + }); + } + None => { + let source = &text[start..entry.span.end.min(end)]; + let Some(bare) = strip_dotted_namespace(source, namespace) else { + return Some(TomlAdoption::Unchanged); + }; + residue.push_str(&bare); + } + } + deletions.push(ByteRange { + start, + end: entry.span.end.min(end), + }); + } + + if deletions.is_empty() { + return None; + } + + let mut out = String::with_capacity(text.len()); + let mut cursor = 0; + for range in deletions { + out.push_str(&text[cursor..range.start]); + cursor = range.end; + } + out.push_str(&text[cursor..]); + Some(TomlAdoption::Adopted { + text: out, + residue: tidy_residue(&residue, text_newline(text)), + }) +} + +fn strip_dotted_namespace(source: &str, namespace: &str) -> Option { + let prefixes = [format!("{namespace}."), format!("\"{namespace}\"."), format!("'{namespace}'.")]; + let (offset, prefix_len) = source + .split_inclusive('\n') + .scan(0, |offset, line| { + let start = *offset; + *offset += line.len(); + Some((start, line)) + }) + .find_map(|(start, line)| { + let indentation = line.len() - line.trim_start().len(); + prefixes + .iter() + .find(|prefix| line[indentation..].starts_with(prefix.as_str())) + .map(|prefix| (start + indentation, prefix.len())) + })?; + + let mut out = String::with_capacity(source.len() - prefix_len); + out.push_str(&source[..offset]); + out.push_str(&source[offset + prefix_len..]); + Some(out) +} + /// Trim the blank lines that bounded the residue inside the table it came /// from, leaving exactly one trailing newline when anything is left. /// diff --git a/crates/cargo-anvil/src/run.rs b/crates/cargo-anvil/src/run.rs index 59cb9525d..dfa851612 100644 --- a/crates/cargo-anvil/src/run.rs +++ b/crates/cargo-anvil/src/run.rs @@ -26,7 +26,9 @@ use crate::manifest::Manifest; use crate::plan::{Plan, PlanItem, Target}; #[cfg(test)] use crate::region::upsert_region; -use crate::region::{CommentSyntax, MarkerRepair, RegionPlacement, find_region, managed_region_ids, remove_region, repair_markers}; +use crate::region::{ + CommentSyntax, MarkerRepair, RegionPlacement, find_region, lint_region_placement, managed_region_ids, remove_region, repair_markers, +}; use crate::workspace::{self, Workspace}; /// Outcome of an `update` invocation. @@ -707,6 +709,9 @@ fn region_placement(region_id: &str, current: Option<&str>) -> RegionPlacement { // empty block establishes no table context; append replacements instead. return RegionPlacement::At(old.start_line.start); } + if let Some(placement) = lint_region_placement(region_id, current) { + return placement; + } RegionPlacement::End } @@ -1615,7 +1620,13 @@ mod tests { } let root_manifest = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); - assert!(root_manifest.contains("# >>> anvil-managed: anvil-workspace-lints")); + for region in [ + "anvil-workspace-rust-lints", + "anvil-workspace-rustdoc-lints", + "anvil-workspace-clippy-lints", + ] { + assert!(root_manifest.contains(&format!("# >>> anvil-managed: {region}"))); + } let member_manifest = fs::read_to_string(tmp.path().join("crates/alpha/Cargo.toml")).unwrap(); assert!(member_manifest.contains("# >>> anvil-managed: anvil-lints")); assert!(member_manifest.contains("workspace = true")); diff --git a/crates/cargo-anvil/templates/regions/cargo-clippy-lints.toml b/crates/cargo-anvil/templates/regions/cargo-clippy-lints.toml new file mode 100644 index 000000000..833a0dfc9 --- /dev/null +++ b/crates/cargo-anvil/templates/regions/cargo-clippy-lints.toml @@ -0,0 +1,47 @@ +# Clippy category gates use priority -1 so per-lint allows can override them. +cargo = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +correctness = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +suspicious = { level = "warn", priority = -1 } +# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group +# lints that catch real code-smell cases. Adding a workspace-wide lint +# means adopters can only opt out per-crate or by taking ownership of +# this region; only enable when the consensus is strong enough to +# justify that cost. +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +as_pointer_underscore = "warn" +assertions_on_result_states = "warn" +clone_on_ref_ptr = "warn" +deref_by_slicing = "warn" +disallowed_script_idents = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +fn_to_numeric_cast_any = "warn" +if_then_some_else_none = "warn" +map_err_ignore = "warn" +multiple_unsafe_ops_per_block = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +semicolon_outside_block = "warn" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unneeded_field_pattern = "warn" +unused_result_ok = "warn" +unwrap_used = "warn" +# Literal braces are valid data in templates and structured-logging messages. +literal_string_with_formatting_args = "allow" +missing_const_for_fn = "allow" +multiple_crate_versions = "allow" +option_if_let_else = "allow" +redundant_pub_crate = "allow" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" +# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 +wildcard_imports = "allow" diff --git a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml b/crates/cargo-anvil/templates/regions/cargo-lints-body.toml deleted file mode 100644 index e06c88cec..000000000 --- a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml +++ /dev/null @@ -1,87 +0,0 @@ -# Catalog of opinionated lints, in dotted-key form so users can extend the -# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. -# The host-specific table header (`[workspace.lints]` or `[lints]`) is -# prepended by cargo-anvil based on whether the manifest is a workspace -# root or a single-crate Cargo.toml. - -# --- rust ------------------------------------------------------------------ -rust.ambiguous_negative_literals = "warn" -rust.missing_debug_implementations = "warn" -rust.redundant_imports = "warn" -rust.redundant_lifetimes = "warn" -rust.trivial_numeric_casts = "warn" -rust.unsafe_op_in_unsafe_fn = "warn" -rust.unused_lifetimes = "warn" -# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined -# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name -# becomes a hard build failure. Pre-declare the cfgs that -# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern -# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. -# Adopters who need additional cfg names take ownership of this one -# line (edit the check-cfg array); anvil's drift detector will -# emit a `.anvil-proposed` sibling on future catalog bumps so the -# customization is preserved. -rust.unexpected_cfgs = { level = "warn", check-cfg = [ - 'cfg(coverage,coverage_nightly)', - 'cfg(loom)', - 'cfg(miri_race_coverage)', - 'cfg(miri_strict_provenance)', - 'cfg(miri_tree_borrows)', -] } - -# --- rustdoc --------------------------------------------------------------- -rustdoc.broken_intra_doc_links = "warn" -rustdoc.missing_crate_level_docs = "warn" -rustdoc.unescaped_backticks = "warn" - -# --- clippy: category gates (priority -1 so per-lint allows can override) -- -clippy.cargo = { level = "warn", priority = -1 } -clippy.complexity = { level = "warn", priority = -1 } -clippy.correctness = { level = "warn", priority = -1 } -clippy.nursery = { level = "warn", priority = -1 } -clippy.pedantic = { level = "warn", priority = -1 } -clippy.perf = { level = "warn", priority = -1 } -clippy.style = { level = "warn", priority = -1 } -clippy.suspicious = { level = "warn", priority = -1 } - -# --- clippy: opinionated additions ----------------------------------------- -# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group -# lints that catch real code-smell cases. Adding a workspace-wide lint -# means adopters can only opt out per-crate or by taking ownership of -# this region; only enable when the consensus is strong enough to -# justify that cost. -clippy.allow_attributes = "warn" -clippy.allow_attributes_without_reason = "warn" -clippy.as_pointer_underscore = "warn" -clippy.assertions_on_result_states = "warn" -clippy.clone_on_ref_ptr = "warn" -clippy.deref_by_slicing = "warn" -clippy.disallowed_script_idents = "warn" -clippy.empty_drop = "warn" -clippy.empty_enum_variants_with_brackets = "warn" -clippy.empty_structs_with_brackets = "warn" -clippy.fn_to_numeric_cast_any = "warn" -clippy.if_then_some_else_none = "warn" -clippy.map_err_ignore = "warn" -clippy.multiple_unsafe_ops_per_block = "warn" -clippy.redundant_type_annotations = "warn" -clippy.renamed_function_params = "warn" -clippy.semicolon_outside_block = "warn" -clippy.undocumented_unsafe_blocks = "warn" -clippy.unnecessary_safety_comment = "warn" -clippy.unnecessary_safety_doc = "warn" -clippy.unneeded_field_pattern = "warn" -clippy.unused_result_ok = "warn" -clippy.unwrap_used = "warn" - -# --- clippy: opinionated suppressions of category-enabled lints ------------ -# Literal braces are valid data in templates and structured-logging messages. -clippy.literal_string_with_formatting_args = "allow" -clippy.missing_const_for_fn = "allow" -clippy.multiple_crate_versions = "allow" -clippy.option_if_let_else = "allow" -clippy.redundant_pub_crate = "allow" -clippy.should_panic_without_expect = "allow" -clippy.significant_drop_tightening = "allow" -# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 -clippy.wildcard_imports = "allow" diff --git a/crates/cargo-anvil/templates/regions/cargo-rust-lints.toml b/crates/cargo-anvil/templates/regions/cargo-rust-lints.toml new file mode 100644 index 000000000..5d1a1c770 --- /dev/null +++ b/crates/cargo-anvil/templates/regions/cargo-rust-lints.toml @@ -0,0 +1,25 @@ +# Rust compiler lint policy. Repository-specific Rust lints can follow this +# region as bare keys in the same table. +ambiguous_negative_literals = "warn" +missing_debug_implementations = "warn" +redundant_imports = "warn" +redundant_lifetimes = "warn" +trivial_numeric_casts = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_lifetimes = "warn" +# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined +# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name +# becomes a hard build failure. Pre-declare the cfgs that +# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern +# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. +# Adopters who need additional cfg names take ownership of this one +# assignment (edit the check-cfg array); anvil's drift detector will +# emit a `.anvil-proposed` sibling on future catalog bumps so the +# customization is preserved. +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(coverage,coverage_nightly)', + 'cfg(loom)', + 'cfg(miri_race_coverage)', + 'cfg(miri_strict_provenance)', + 'cfg(miri_tree_borrows)', +] } diff --git a/crates/cargo-anvil/templates/regions/cargo-rustdoc-lints.toml b/crates/cargo-anvil/templates/regions/cargo-rustdoc-lints.toml new file mode 100644 index 000000000..74ebdba6f --- /dev/null +++ b/crates/cargo-anvil/templates/regions/cargo-rustdoc-lints.toml @@ -0,0 +1,5 @@ +# Rustdoc lint policy. Repository-specific rustdoc lints can follow this +# region as bare keys in the same table. +broken_intra_doc_links = "warn" +missing_crate_level_docs = "warn" +unescaped_backticks = "warn" diff --git a/crates/cargo-anvil/tests/fixtures.rs b/crates/cargo-anvil/tests/fixtures.rs index 0176df1ee..b192dc878 100644 --- a/crates/cargo-anvil/tests/fixtures.rs +++ b/crates/cargo-anvil/tests/fixtures.rs @@ -106,12 +106,14 @@ fn single_crate_emits_crate_lints_and_justfiles() { run(&tmp); let cargo = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + for region in ["anvil-rust-lints", "anvil-rustdoc-lints", "anvil-clippy-lints"] { + assert!( + cargo.contains(&format!("anvil-managed: {region}")), + "single-crate fixture should receive the per-crate {region} region; got:\n{cargo}" + ); + } assert!( - cargo.contains("anvil-managed: anvil-lints"), - "single-crate fixture should receive the per-crate lints region; got:\n{cargo}" - ); - assert!( - !cargo.contains("anvil-workspace-lints"), + !cargo.contains("anvil-workspace-rust-lints"), "single-crate fixture must not receive the workspace lints region" ); @@ -280,10 +282,16 @@ fn migration_preserves_user_content() { cargo.contains("lto = \"thin\""), "user-authored [profile.release] must survive migration; got:\n{cargo}" ); - assert!( - cargo.contains("anvil-workspace-lints"), - "anvil workspace lints region must be spliced into Cargo.toml" - ); + for region in [ + "anvil-workspace-rust-lints", + "anvil-workspace-rustdoc-lints", + "anvil-workspace-clippy-lints", + ] { + assert!( + cargo.contains(&format!("anvil-managed: {region}")), + "anvil workspace {region} region must be spliced into Cargo.toml" + ); + } // The defect this fixture used to hide: the hand-written `[advisories]` // declares an `ignore` list the managed body does not, so adoption cannot diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index 48f2480d8..da2c4941b 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1663,96 +1663,94 @@ stages: resolver = "2" members = ["crates/*"] -# >>> anvil-managed: anvil-workspace-lints -[workspace.lints] -# Catalog of opinionated lints, in dotted-key form so users can extend the -# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. -# The host-specific table header (`[workspace.lints]` or `[lints]`) is -# prepended by cargo-anvil based on whether the manifest is a workspace -# root or a single-crate Cargo.toml. - -# --- rust ------------------------------------------------------------------ -rust.ambiguous_negative_literals = "warn" -rust.missing_debug_implementations = "warn" -rust.redundant_imports = "warn" -rust.redundant_lifetimes = "warn" -rust.trivial_numeric_casts = "warn" -rust.unsafe_op_in_unsafe_fn = "warn" -rust.unused_lifetimes = "warn" +# >>> anvil-managed: anvil-workspace-rust-lints +[workspace.lints.rust] +# Rust compiler lint policy. Repository-specific Rust lints can follow this +# region as bare keys in the same table. +ambiguous_negative_literals = "warn" +missing_debug_implementations = "warn" +redundant_imports = "warn" +redundant_lifetimes = "warn" +trivial_numeric_casts = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_lifetimes = "warn" # `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined # with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name # becomes a hard build failure. Pre-declare the cfgs that # `cargo llvm-cov` sets so the recommended coverage-exclusion pattern # `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. # Adopters who need additional cfg names take ownership of this one -# line (edit the check-cfg array); anvil's drift detector will +# assignment (edit the check-cfg array); anvil's drift detector will # emit a `.anvil-proposed` sibling on future catalog bumps so the # customization is preserved. -rust.unexpected_cfgs = { level = "warn", check-cfg = [ +unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(coverage,coverage_nightly)', 'cfg(loom)', 'cfg(miri_race_coverage)', 'cfg(miri_strict_provenance)', 'cfg(miri_tree_borrows)', ] } - -# --- rustdoc --------------------------------------------------------------- -rustdoc.broken_intra_doc_links = "warn" -rustdoc.missing_crate_level_docs = "warn" -rustdoc.unescaped_backticks = "warn" - -# --- clippy: category gates (priority -1 so per-lint allows can override) -- -clippy.cargo = { level = "warn", priority = -1 } -clippy.complexity = { level = "warn", priority = -1 } -clippy.correctness = { level = "warn", priority = -1 } -clippy.nursery = { level = "warn", priority = -1 } -clippy.pedantic = { level = "warn", priority = -1 } -clippy.perf = { level = "warn", priority = -1 } -clippy.style = { level = "warn", priority = -1 } -clippy.suspicious = { level = "warn", priority = -1 } - -# --- clippy: opinionated additions ----------------------------------------- +# <<< anvil-managed: anvil-workspace-rust-lints + +# >>> anvil-managed: anvil-workspace-rustdoc-lints +[workspace.lints.rustdoc] +# Rustdoc lint policy. Repository-specific rustdoc lints can follow this +# region as bare keys in the same table. +broken_intra_doc_links = "warn" +missing_crate_level_docs = "warn" +unescaped_backticks = "warn" +# <<< anvil-managed: anvil-workspace-rustdoc-lints + +# >>> anvil-managed: anvil-workspace-clippy-lints +[workspace.lints.clippy] +# Clippy category gates use priority -1 so per-lint allows can override them. +cargo = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +correctness = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +suspicious = { level = "warn", priority = -1 } # Two-repo consensus (oxidizer + oxidizer-github). Restriction-group # lints that catch real code-smell cases. Adding a workspace-wide lint # means adopters can only opt out per-crate or by taking ownership of # this region; only enable when the consensus is strong enough to # justify that cost. -clippy.allow_attributes = "warn" -clippy.allow_attributes_without_reason = "warn" -clippy.as_pointer_underscore = "warn" -clippy.assertions_on_result_states = "warn" -clippy.clone_on_ref_ptr = "warn" -clippy.deref_by_slicing = "warn" -clippy.disallowed_script_idents = "warn" -clippy.empty_drop = "warn" -clippy.empty_enum_variants_with_brackets = "warn" -clippy.empty_structs_with_brackets = "warn" -clippy.fn_to_numeric_cast_any = "warn" -clippy.if_then_some_else_none = "warn" -clippy.map_err_ignore = "warn" -clippy.multiple_unsafe_ops_per_block = "warn" -clippy.redundant_type_annotations = "warn" -clippy.renamed_function_params = "warn" -clippy.semicolon_outside_block = "warn" -clippy.undocumented_unsafe_blocks = "warn" -clippy.unnecessary_safety_comment = "warn" -clippy.unnecessary_safety_doc = "warn" -clippy.unneeded_field_pattern = "warn" -clippy.unused_result_ok = "warn" -clippy.unwrap_used = "warn" - -# --- clippy: opinionated suppressions of category-enabled lints ------------ +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +as_pointer_underscore = "warn" +assertions_on_result_states = "warn" +clone_on_ref_ptr = "warn" +deref_by_slicing = "warn" +disallowed_script_idents = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +fn_to_numeric_cast_any = "warn" +if_then_some_else_none = "warn" +map_err_ignore = "warn" +multiple_unsafe_ops_per_block = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +semicolon_outside_block = "warn" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unneeded_field_pattern = "warn" +unused_result_ok = "warn" +unwrap_used = "warn" # Literal braces are valid data in templates and structured-logging messages. -clippy.literal_string_with_formatting_args = "allow" -clippy.missing_const_for_fn = "allow" -clippy.multiple_crate_versions = "allow" -clippy.option_if_let_else = "allow" -clippy.redundant_pub_crate = "allow" -clippy.should_panic_without_expect = "allow" -clippy.significant_drop_tightening = "allow" +literal_string_with_formatting_args = "allow" +missing_const_for_fn = "allow" +multiple_crate_versions = "allow" +option_if_let_else = "allow" +redundant_pub_crate = "allow" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" # Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 -clippy.wildcard_imports = "allow" -# <<< anvil-managed: anvil-workspace-lints +wildcard_imports = "allow" +# <<< anvil-managed: anvil-workspace-clippy-lints === Justfile === # >>> anvil-managed: anvil-imports diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 3ca12b8d3..cab1af7bf 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1776,96 +1776,94 @@ jobs: resolver = "2" members = ["crates/*"] -# >>> anvil-managed: anvil-workspace-lints -[workspace.lints] -# Catalog of opinionated lints, in dotted-key form so users can extend the -# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. -# The host-specific table header (`[workspace.lints]` or `[lints]`) is -# prepended by cargo-anvil based on whether the manifest is a workspace -# root or a single-crate Cargo.toml. - -# --- rust ------------------------------------------------------------------ -rust.ambiguous_negative_literals = "warn" -rust.missing_debug_implementations = "warn" -rust.redundant_imports = "warn" -rust.redundant_lifetimes = "warn" -rust.trivial_numeric_casts = "warn" -rust.unsafe_op_in_unsafe_fn = "warn" -rust.unused_lifetimes = "warn" +# >>> anvil-managed: anvil-workspace-rust-lints +[workspace.lints.rust] +# Rust compiler lint policy. Repository-specific Rust lints can follow this +# region as bare keys in the same table. +ambiguous_negative_literals = "warn" +missing_debug_implementations = "warn" +redundant_imports = "warn" +redundant_lifetimes = "warn" +trivial_numeric_casts = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_lifetimes = "warn" # `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined # with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name # becomes a hard build failure. Pre-declare the cfgs that # `cargo llvm-cov` sets so the recommended coverage-exclusion pattern # `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. # Adopters who need additional cfg names take ownership of this one -# line (edit the check-cfg array); anvil's drift detector will +# assignment (edit the check-cfg array); anvil's drift detector will # emit a `.anvil-proposed` sibling on future catalog bumps so the # customization is preserved. -rust.unexpected_cfgs = { level = "warn", check-cfg = [ +unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(coverage,coverage_nightly)', 'cfg(loom)', 'cfg(miri_race_coverage)', 'cfg(miri_strict_provenance)', 'cfg(miri_tree_borrows)', ] } - -# --- rustdoc --------------------------------------------------------------- -rustdoc.broken_intra_doc_links = "warn" -rustdoc.missing_crate_level_docs = "warn" -rustdoc.unescaped_backticks = "warn" - -# --- clippy: category gates (priority -1 so per-lint allows can override) -- -clippy.cargo = { level = "warn", priority = -1 } -clippy.complexity = { level = "warn", priority = -1 } -clippy.correctness = { level = "warn", priority = -1 } -clippy.nursery = { level = "warn", priority = -1 } -clippy.pedantic = { level = "warn", priority = -1 } -clippy.perf = { level = "warn", priority = -1 } -clippy.style = { level = "warn", priority = -1 } -clippy.suspicious = { level = "warn", priority = -1 } - -# --- clippy: opinionated additions ----------------------------------------- +# <<< anvil-managed: anvil-workspace-rust-lints + +# >>> anvil-managed: anvil-workspace-rustdoc-lints +[workspace.lints.rustdoc] +# Rustdoc lint policy. Repository-specific rustdoc lints can follow this +# region as bare keys in the same table. +broken_intra_doc_links = "warn" +missing_crate_level_docs = "warn" +unescaped_backticks = "warn" +# <<< anvil-managed: anvil-workspace-rustdoc-lints + +# >>> anvil-managed: anvil-workspace-clippy-lints +[workspace.lints.clippy] +# Clippy category gates use priority -1 so per-lint allows can override them. +cargo = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +correctness = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +suspicious = { level = "warn", priority = -1 } # Two-repo consensus (oxidizer + oxidizer-github). Restriction-group # lints that catch real code-smell cases. Adding a workspace-wide lint # means adopters can only opt out per-crate or by taking ownership of # this region; only enable when the consensus is strong enough to # justify that cost. -clippy.allow_attributes = "warn" -clippy.allow_attributes_without_reason = "warn" -clippy.as_pointer_underscore = "warn" -clippy.assertions_on_result_states = "warn" -clippy.clone_on_ref_ptr = "warn" -clippy.deref_by_slicing = "warn" -clippy.disallowed_script_idents = "warn" -clippy.empty_drop = "warn" -clippy.empty_enum_variants_with_brackets = "warn" -clippy.empty_structs_with_brackets = "warn" -clippy.fn_to_numeric_cast_any = "warn" -clippy.if_then_some_else_none = "warn" -clippy.map_err_ignore = "warn" -clippy.multiple_unsafe_ops_per_block = "warn" -clippy.redundant_type_annotations = "warn" -clippy.renamed_function_params = "warn" -clippy.semicolon_outside_block = "warn" -clippy.undocumented_unsafe_blocks = "warn" -clippy.unnecessary_safety_comment = "warn" -clippy.unnecessary_safety_doc = "warn" -clippy.unneeded_field_pattern = "warn" -clippy.unused_result_ok = "warn" -clippy.unwrap_used = "warn" - -# --- clippy: opinionated suppressions of category-enabled lints ------------ +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +as_pointer_underscore = "warn" +assertions_on_result_states = "warn" +clone_on_ref_ptr = "warn" +deref_by_slicing = "warn" +disallowed_script_idents = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +fn_to_numeric_cast_any = "warn" +if_then_some_else_none = "warn" +map_err_ignore = "warn" +multiple_unsafe_ops_per_block = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +semicolon_outside_block = "warn" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unneeded_field_pattern = "warn" +unused_result_ok = "warn" +unwrap_used = "warn" # Literal braces are valid data in templates and structured-logging messages. -clippy.literal_string_with_formatting_args = "allow" -clippy.missing_const_for_fn = "allow" -clippy.multiple_crate_versions = "allow" -clippy.option_if_let_else = "allow" -clippy.redundant_pub_crate = "allow" -clippy.should_panic_without_expect = "allow" -clippy.significant_drop_tightening = "allow" +literal_string_with_formatting_args = "allow" +missing_const_for_fn = "allow" +multiple_crate_versions = "allow" +option_if_let_else = "allow" +redundant_pub_crate = "allow" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" # Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 -clippy.wildcard_imports = "allow" -# <<< anvil-managed: anvil-workspace-lints +wildcard_imports = "allow" +# <<< anvil-managed: anvil-workspace-clippy-lints === Justfile === # >>> anvil-managed: anvil-imports diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 132c4cbc7..329bd8359 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -460,96 +460,94 @@ Finish with: resolver = "2" members = ["crates/*"] -# >>> anvil-managed: anvil-workspace-lints -[workspace.lints] -# Catalog of opinionated lints, in dotted-key form so users can extend the -# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. -# The host-specific table header (`[workspace.lints]` or `[lints]`) is -# prepended by cargo-anvil based on whether the manifest is a workspace -# root or a single-crate Cargo.toml. - -# --- rust ------------------------------------------------------------------ -rust.ambiguous_negative_literals = "warn" -rust.missing_debug_implementations = "warn" -rust.redundant_imports = "warn" -rust.redundant_lifetimes = "warn" -rust.trivial_numeric_casts = "warn" -rust.unsafe_op_in_unsafe_fn = "warn" -rust.unused_lifetimes = "warn" +# >>> anvil-managed: anvil-workspace-rust-lints +[workspace.lints.rust] +# Rust compiler lint policy. Repository-specific Rust lints can follow this +# region as bare keys in the same table. +ambiguous_negative_literals = "warn" +missing_debug_implementations = "warn" +redundant_imports = "warn" +redundant_lifetimes = "warn" +trivial_numeric_casts = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_lifetimes = "warn" # `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined # with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name # becomes a hard build failure. Pre-declare the cfgs that # `cargo llvm-cov` sets so the recommended coverage-exclusion pattern # `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. # Adopters who need additional cfg names take ownership of this one -# line (edit the check-cfg array); anvil's drift detector will +# assignment (edit the check-cfg array); anvil's drift detector will # emit a `.anvil-proposed` sibling on future catalog bumps so the # customization is preserved. -rust.unexpected_cfgs = { level = "warn", check-cfg = [ +unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(coverage,coverage_nightly)', 'cfg(loom)', 'cfg(miri_race_coverage)', 'cfg(miri_strict_provenance)', 'cfg(miri_tree_borrows)', ] } - -# --- rustdoc --------------------------------------------------------------- -rustdoc.broken_intra_doc_links = "warn" -rustdoc.missing_crate_level_docs = "warn" -rustdoc.unescaped_backticks = "warn" - -# --- clippy: category gates (priority -1 so per-lint allows can override) -- -clippy.cargo = { level = "warn", priority = -1 } -clippy.complexity = { level = "warn", priority = -1 } -clippy.correctness = { level = "warn", priority = -1 } -clippy.nursery = { level = "warn", priority = -1 } -clippy.pedantic = { level = "warn", priority = -1 } -clippy.perf = { level = "warn", priority = -1 } -clippy.style = { level = "warn", priority = -1 } -clippy.suspicious = { level = "warn", priority = -1 } - -# --- clippy: opinionated additions ----------------------------------------- +# <<< anvil-managed: anvil-workspace-rust-lints + +# >>> anvil-managed: anvil-workspace-rustdoc-lints +[workspace.lints.rustdoc] +# Rustdoc lint policy. Repository-specific rustdoc lints can follow this +# region as bare keys in the same table. +broken_intra_doc_links = "warn" +missing_crate_level_docs = "warn" +unescaped_backticks = "warn" +# <<< anvil-managed: anvil-workspace-rustdoc-lints + +# >>> anvil-managed: anvil-workspace-clippy-lints +[workspace.lints.clippy] +# Clippy category gates use priority -1 so per-lint allows can override them. +cargo = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +correctness = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +suspicious = { level = "warn", priority = -1 } # Two-repo consensus (oxidizer + oxidizer-github). Restriction-group # lints that catch real code-smell cases. Adding a workspace-wide lint # means adopters can only opt out per-crate or by taking ownership of # this region; only enable when the consensus is strong enough to # justify that cost. -clippy.allow_attributes = "warn" -clippy.allow_attributes_without_reason = "warn" -clippy.as_pointer_underscore = "warn" -clippy.assertions_on_result_states = "warn" -clippy.clone_on_ref_ptr = "warn" -clippy.deref_by_slicing = "warn" -clippy.disallowed_script_idents = "warn" -clippy.empty_drop = "warn" -clippy.empty_enum_variants_with_brackets = "warn" -clippy.empty_structs_with_brackets = "warn" -clippy.fn_to_numeric_cast_any = "warn" -clippy.if_then_some_else_none = "warn" -clippy.map_err_ignore = "warn" -clippy.multiple_unsafe_ops_per_block = "warn" -clippy.redundant_type_annotations = "warn" -clippy.renamed_function_params = "warn" -clippy.semicolon_outside_block = "warn" -clippy.undocumented_unsafe_blocks = "warn" -clippy.unnecessary_safety_comment = "warn" -clippy.unnecessary_safety_doc = "warn" -clippy.unneeded_field_pattern = "warn" -clippy.unused_result_ok = "warn" -clippy.unwrap_used = "warn" - -# --- clippy: opinionated suppressions of category-enabled lints ------------ +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +as_pointer_underscore = "warn" +assertions_on_result_states = "warn" +clone_on_ref_ptr = "warn" +deref_by_slicing = "warn" +disallowed_script_idents = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +fn_to_numeric_cast_any = "warn" +if_then_some_else_none = "warn" +map_err_ignore = "warn" +multiple_unsafe_ops_per_block = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +semicolon_outside_block = "warn" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unneeded_field_pattern = "warn" +unused_result_ok = "warn" +unwrap_used = "warn" # Literal braces are valid data in templates and structured-logging messages. -clippy.literal_string_with_formatting_args = "allow" -clippy.missing_const_for_fn = "allow" -clippy.multiple_crate_versions = "allow" -clippy.option_if_let_else = "allow" -clippy.redundant_pub_crate = "allow" -clippy.should_panic_without_expect = "allow" -clippy.significant_drop_tightening = "allow" +literal_string_with_formatting_args = "allow" +missing_const_for_fn = "allow" +multiple_crate_versions = "allow" +option_if_let_else = "allow" +redundant_pub_crate = "allow" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" # Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 -clippy.wildcard_imports = "allow" -# <<< anvil-managed: anvil-workspace-lints +wildcard_imports = "allow" +# <<< anvil-managed: anvil-workspace-clippy-lints === Justfile === # >>> anvil-managed: anvil-imports diff --git a/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap b/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap index c3c56aa45..e93803d23 100644 --- a/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap +++ b/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap @@ -12,8 +12,14 @@ members = ["crates/*"] rust.a_custom_lint = "warn" rust.unsafe_op_in_unsafe_fn = "warn" +[profile.release] +lto = "thin" +incremental = false + --- decisions --- -anvil-workspace-lints: Write +anvil-workspace-clippy-lints: Write +anvil-workspace-rust-lints: Write +anvil-workspace-rustdoc-lints: Write --- refusals --- (none) @@ -23,95 +29,99 @@ anvil-workspace-lints: Write resolver = "2" members = ["crates/*"] -# >>> anvil-managed: anvil-workspace-lints [workspace.lints] -# Catalog of opinionated lints, in dotted-key form so users can extend the -# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. -# The host-specific table header (`[workspace.lints]` or `[lints]`) is -# prepended by cargo-anvil based on whether the manifest is a workspace -# root or a single-crate Cargo.toml. -# --- rust ------------------------------------------------------------------ -rust.ambiguous_negative_literals = "warn" -rust.missing_debug_implementations = "warn" -rust.redundant_imports = "warn" -rust.redundant_lifetimes = "warn" -rust.trivial_numeric_casts = "warn" -rust.unsafe_op_in_unsafe_fn = "warn" -rust.unused_lifetimes = "warn" +# >>> anvil-managed: anvil-workspace-rust-lints +[workspace.lints.rust] +# Rust compiler lint policy. Repository-specific Rust lints can follow this +# region as bare keys in the same table. +ambiguous_negative_literals = "warn" +missing_debug_implementations = "warn" +redundant_imports = "warn" +redundant_lifetimes = "warn" +trivial_numeric_casts = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_lifetimes = "warn" # `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined # with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name # becomes a hard build failure. Pre-declare the cfgs that # `cargo llvm-cov` sets so the recommended coverage-exclusion pattern # `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. # Adopters who need additional cfg names take ownership of this one -# line (edit the check-cfg array); anvil's drift detector will +# assignment (edit the check-cfg array); anvil's drift detector will # emit a `.anvil-proposed` sibling on future catalog bumps so the # customization is preserved. -rust.unexpected_cfgs = { level = "warn", check-cfg = [ +unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(coverage,coverage_nightly)', 'cfg(loom)', 'cfg(miri_race_coverage)', 'cfg(miri_strict_provenance)', 'cfg(miri_tree_borrows)', ] } +# <<< anvil-managed: anvil-workspace-rust-lints +# House rule, not in anvil's catalog. +a_custom_lint = "warn" -# --- rustdoc --------------------------------------------------------------- -rustdoc.broken_intra_doc_links = "warn" -rustdoc.missing_crate_level_docs = "warn" -rustdoc.unescaped_backticks = "warn" - -# --- clippy: category gates (priority -1 so per-lint allows can override) -- -clippy.cargo = { level = "warn", priority = -1 } -clippy.complexity = { level = "warn", priority = -1 } -clippy.correctness = { level = "warn", priority = -1 } -clippy.nursery = { level = "warn", priority = -1 } -clippy.pedantic = { level = "warn", priority = -1 } -clippy.perf = { level = "warn", priority = -1 } -clippy.style = { level = "warn", priority = -1 } -clippy.suspicious = { level = "warn", priority = -1 } +# >>> anvil-managed: anvil-workspace-rustdoc-lints +[workspace.lints.rustdoc] +# Rustdoc lint policy. Repository-specific rustdoc lints can follow this +# region as bare keys in the same table. +broken_intra_doc_links = "warn" +missing_crate_level_docs = "warn" +unescaped_backticks = "warn" +# <<< anvil-managed: anvil-workspace-rustdoc-lints -# --- clippy: opinionated additions ----------------------------------------- +# >>> anvil-managed: anvil-workspace-clippy-lints +[workspace.lints.clippy] +# Clippy category gates use priority -1 so per-lint allows can override them. +cargo = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +correctness = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +suspicious = { level = "warn", priority = -1 } # Two-repo consensus (oxidizer + oxidizer-github). Restriction-group # lints that catch real code-smell cases. Adding a workspace-wide lint # means adopters can only opt out per-crate or by taking ownership of # this region; only enable when the consensus is strong enough to # justify that cost. -clippy.allow_attributes = "warn" -clippy.allow_attributes_without_reason = "warn" -clippy.as_pointer_underscore = "warn" -clippy.assertions_on_result_states = "warn" -clippy.clone_on_ref_ptr = "warn" -clippy.deref_by_slicing = "warn" -clippy.disallowed_script_idents = "warn" -clippy.empty_drop = "warn" -clippy.empty_enum_variants_with_brackets = "warn" -clippy.empty_structs_with_brackets = "warn" -clippy.fn_to_numeric_cast_any = "warn" -clippy.if_then_some_else_none = "warn" -clippy.map_err_ignore = "warn" -clippy.multiple_unsafe_ops_per_block = "warn" -clippy.redundant_type_annotations = "warn" -clippy.renamed_function_params = "warn" -clippy.semicolon_outside_block = "warn" -clippy.undocumented_unsafe_blocks = "warn" -clippy.unnecessary_safety_comment = "warn" -clippy.unnecessary_safety_doc = "warn" -clippy.unneeded_field_pattern = "warn" -clippy.unused_result_ok = "warn" -clippy.unwrap_used = "warn" - -# --- clippy: opinionated suppressions of category-enabled lints ------------ +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +as_pointer_underscore = "warn" +assertions_on_result_states = "warn" +clone_on_ref_ptr = "warn" +deref_by_slicing = "warn" +disallowed_script_idents = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +fn_to_numeric_cast_any = "warn" +if_then_some_else_none = "warn" +map_err_ignore = "warn" +multiple_unsafe_ops_per_block = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +semicolon_outside_block = "warn" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unneeded_field_pattern = "warn" +unused_result_ok = "warn" +unwrap_used = "warn" # Literal braces are valid data in templates and structured-logging messages. -clippy.literal_string_with_formatting_args = "allow" -clippy.missing_const_for_fn = "allow" -clippy.multiple_crate_versions = "allow" -clippy.option_if_let_else = "allow" -clippy.redundant_pub_crate = "allow" -clippy.should_panic_without_expect = "allow" -clippy.significant_drop_tightening = "allow" +literal_string_with_formatting_args = "allow" +missing_const_for_fn = "allow" +multiple_crate_versions = "allow" +option_if_let_else = "allow" +redundant_pub_crate = "allow" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" # Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 -clippy.wildcard_imports = "allow" -# <<< anvil-managed: anvil-workspace-lints -# House rule, not in anvil's catalog. -rust.a_custom_lint = "warn" +wildcard_imports = "allow" +# <<< anvil-managed: anvil-workspace-clippy-lints + +[profile.release] +lto = "thin" +incremental = false diff --git a/crates/cargo-anvil/tests/toml_adoption.rs b/crates/cargo-anvil/tests/toml_adoption.rs index a551b2580..98983f721 100644 --- a/crates/cargo-anvil/tests/toml_adoption.rs +++ b/crates/cargo-anvil/tests/toml_adoption.rs @@ -30,7 +30,7 @@ use std::fmt::Write as _; use std::path::Path; -use cargo_anvil::test_support::{Cli, RunOutcome, Target, run_update}; +use cargo_anvil::test_support::{Cli, Decision, Manifest, RunOutcome, Target, checksum_str, run_update}; use tempfile::TempDir; /// A workspace with nothing in it but the manifest anvil needs to find, plus @@ -214,13 +214,83 @@ members = [\"crates/*\"] # House rule, not in anvil's catalog. rust.a_custom_lint = \"warn\" rust.unsafe_op_in_unsafe_fn = \"warn\" + +[profile.release] +lto = \"thin\" +incremental = false "; let tmp = workspace_with("Cargo.toml", before); let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + let document: toml_edit::DocumentMut = after.parse().unwrap(); + assert_eq!(document["workspace"]["lints"]["rust"]["a_custom_lint"].as_str(), Some("warn")); + assert_eq!(document["profile"]["release"]["lto"].as_str(), Some("thin")); + assert_eq!(document["profile"]["release"]["incremental"].as_bool(), Some(false)); insta::assert_snapshot!("keeps_an_unmanaged_dotted_lint", report(before, &tmp, "Cargo.toml", &outcome)); } +#[test] +fn replaces_the_combined_lint_region_without_losing_local_lints() { + let old_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; + let before = format!( + "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n\ + # >>> anvil-managed: anvil-workspace-lints\n{old_body}\ + # <<< anvil-managed: anvil-workspace-lints\n\n\ + # Repository-specific lint policy.\n\ + rust.missing_docs = \"warn\"\n\ + clippy.panic = \"warn\"\n" + ); + let tmp = workspace_with("Cargo.toml", &before); + let mut manifest = Manifest::default(); + manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body)); + manifest.save(tmp.path()).unwrap(); + + let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!(outcome.plan.refusals().is_empty(), "{:?}\n{after}", outcome.plan.refusals()); + let document: toml_edit::DocumentMut = after.parse().unwrap(); + assert!(!after.contains("anvil-workspace-lints")); + assert!(after.contains("[workspace.lints.rust]\n")); + assert_eq!(document["workspace"]["lints"]["rust"]["missing_docs"].as_str(), Some("warn")); + assert!(after.contains("[workspace.lints.clippy]\n")); + assert_eq!(document["workspace"]["lints"]["clippy"]["panic"].as_str(), Some("warn")); + assert!(!after.contains("rust.missing_docs"), "{after}"); + assert!(!after.contains("clippy.panic"), "{after}"); + + let second = run(&tmp); + assert!( + second.plan.items().iter().all(|item| item.decision == Decision::InSync), + "the migration must settle in one run: {:?}", + second.plan.items() + ); +} + +#[test] +fn replaces_the_single_crate_combined_lint_region() { + let old_body = "[lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; + let before = format!( + "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n\ + # >>> anvil-managed: anvil-lints\n{old_body}\ + # <<< anvil-managed: anvil-lints\n\n\ + rust.missing_docs = \"warn\"\n" + ); + let tmp = TempDir::new().unwrap(); + write(&tmp.path().join("Cargo.toml"), &before); + write(&tmp.path().join("src/lib.rs"), ""); + let mut manifest = Manifest::default(); + manifest.set_region("Cargo.toml", "anvil-lints", checksum_str(old_body)); + manifest.save(tmp.path()).unwrap(); + + let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!(outcome.plan.refusals().is_empty(), "{:?}\n{after}", outcome.plan.refusals()); + let document: toml_edit::DocumentMut = after.parse().unwrap(); + assert_eq!(document["lints"]["rust"]["missing_docs"].as_str(), Some("warn")); + assert!(after.contains("anvil-managed: anvil-rust-lints")); + assert!(!after.contains("anvil-managed: anvil-lints\n")); +} + /// Catalog arrays-of-tables are unsupported, but user bin targets survive. #[test] fn preserves_user_binary_targets() { From 28da9150dc066e011b79f5ba0cb603cbb64c855f Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 19:15:11 +0200 Subject: [PATCH 05/11] chore(cargo-anvil): release v0.12.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 +- crates/cargo-anvil/CHANGELOG.md | 19 +++++++++++++++++++ crates/cargo-anvil/Cargo.toml | 2 +- crates/cargo-anvil/README.md | 8 ++++---- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5bb99c60..c7bc8d655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ dependencies = [ [[package]] name = "cargo-anvil" -version = "0.11.0" +version = "0.12.0" dependencies = [ "assert_cmd", "clap", diff --git a/crates/cargo-anvil/CHANGELOG.md b/crates/cargo-anvil/CHANGELOG.md index 7705b6f29..1852614c8 100644 --- a/crates/cargo-anvil/CHANGELOG.md +++ b/crates/cargo-anvil/CHANGELOG.md @@ -2,6 +2,25 @@ ## [Unreleased] +## [0.12.0] - 2026-09-18 + +- ✨ Features + + - split managed lint regions + - lint empty braced structs + +- 🐛 Bug Fixes + + - scope coverage reports to affected packages ([#190](https://github.com/microsoft/ox-tools/pull/190)) + +- ♻️ Code Refactoring + + - centralize lint overrides + +- 🧩 Miscellaneous + + - Merge origin/main into feat/anvil-empty-struct-lint + ## [0.11.0] - 2026-09-17 - 🐛 Bug Fixes diff --git a/crates/cargo-anvil/Cargo.toml b/crates/cargo-anvil/Cargo.toml index dc3d34d4a..4be9c4dc7 100644 --- a/crates/cargo-anvil/Cargo.toml +++ b/crates/cargo-anvil/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "cargo-anvil" -version = "0.11.0" +version = "0.12.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/cargo-anvil/README.md b/crates/cargo-anvil/README.md index 13e5ed129..96d039bb3 100644 --- a/crates/cargo-anvil/README.md +++ b/crates/cargo-anvil/README.md @@ -400,7 +400,7 @@ More detailed design and operational guidance is available in the This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQblRYhli3L8qob_NSi_WYo69wbWnMVqZw3jJwb3u56HnT6RDphYvRhcoQbBALu36V1VAYbFUDdfp-8dCobobFjKbRep8AbYNMPgi_aMhFhZIGDa2NhcmdvLWFudmlsZjAuMTEuMGtjYXJnb19hbnZpbA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjNhdIQblRYhli3L8qob_NSi_WYo69wbWnMVqZw3jJwb3u56HnT6RDphYvRhcoQbBALu36V1VAYbFUDdfp-8dCobobFjKbRep8AbYNMPgi_aMhFhZIGDa2NhcmdvLWFudmlsZjAuMTIuMGtjYXJnb19hbnZpbA [__link0]: https://github.com/casey/just [__link1]: https://rust-lang.github.io/rustfmt/ [__link10]: https://embarkstudios.github.io/cargo-deny/ @@ -419,9 +419,9 @@ This crate was developed as part of The Oxidizer Project. Br [__link22]: https://mutants.rs/ [__link23]: https://crates.io/crates/cargo-hack [__link24]: https://crates.io/crates/cargo-coverage-gate - [__link25]: https://docs.rs/cargo-anvil/0.11.0/cargo_anvil/?search=Catalog::anvil - [__link26]: https://docs.rs/cargo-anvil/0.11.0/cargo_anvil/?search=Artifact - [__link27]: https://docs.rs/cargo-anvil/0.11.0/cargo_anvil/fn.run_app.html + [__link25]: https://docs.rs/cargo-anvil/0.12.0/cargo_anvil/?search=Catalog::anvil + [__link26]: https://docs.rs/cargo-anvil/0.12.0/cargo_anvil/?search=Artifact + [__link27]: https://docs.rs/cargo-anvil/0.12.0/cargo_anvil/fn.run_app.html [__link3]: https://crates.io/crates/cargo-sort [__link4]: https://crates.io/crates/cargo-heather [__link5]: https://crates.io/crates/cargo-ensure-no-cyclic-deps From 642aad008b59acf15b8a1c9e6dca631bf8852276 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 19:36:01 +0200 Subject: [PATCH 06/11] fix(cargo-anvil): record release tool version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.anvil.lock b/.anvil.lock index 266dd91b8..2d4824f72 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,6 +1,6 @@ version = 1 tool = "anvil" -tool_version = "0.11.0" +tool_version = "0.12.0" catalog_checksum = "sha256:4bd4b5329075f104c5830e767c6f79e5eed6c1de72370b9836603488a359bcdb" [[file]] From dddcab3d110fc8c3d4d6f3eff66fd5a8ed5f5693 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Mon, 21 Sep 2026 20:01:12 +0200 Subject: [PATCH 07/11] fix(cargo-anvil): cover lint region migration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .anvil.lock | 6 +- clippy.toml | 2 +- crates/cargo-anvil/docs/design/updates.md | 12 ++- crates/cargo-anvil/src/region.rs | 98 ++++++++++++++++++- .../anvil/checks/miri-tree-borrows.just | 2 +- .../cargo-anvil/templates/regions/clippy.toml | 2 +- .../snapshots/snapshots__ado_backend.snap | 4 +- .../snapshots/snapshots__github_backend.snap | 4 +- .../snapshots/snapshots__local_only.snap | 4 +- justfiles/anvil/checks/miri-tree-borrows.just | 2 +- 10 files changed, 113 insertions(+), 23 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 2d4824f72..9528dfdc1 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.12.0" -catalog_checksum = "sha256:4bd4b5329075f104c5830e767c6f79e5eed6c1de72370b9836603488a359bcdb" +catalog_checksum = "sha256:c1d74799d088a92d84970f41ec399a717872967d7e91bc1077c2ce31a097d1fc" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -141,7 +141,7 @@ checksum = "sha256:bab029c5ca3be78fcea2d6af02763a0e0640722dfe1431a0eaa541801a981 [[file]] path = "justfiles/anvil/checks/miri-tree-borrows.just" -checksum = "sha256:ea3349941fd3cf3811bce48a679490fe466dddafdb6d0955c1f5471155669f79" +checksum = "sha256:65f7a333394fc9ce48a54c281b6b87b28cc622710fb5c985cdd5292921da490e" [[file]] path = "justfiles/anvil/checks/miri.just" @@ -309,7 +309,7 @@ checksum = "sha256:f8affd59b69c7083c2f3b6f593c63672116dda974c1e661dcb66a4412eb3e [[region]] host = "clippy.toml" id = "anvil-clippy" -checksum = "sha256:aba0733632eac4cb54c4768db578fe1f7b7cfe730aa0d7dc13e2828c9062d67d" +checksum = "sha256:0c37975f24d538358d792626e32d67a043c3a892d076ddf90267ce6757b3245b" [[region]] host = "crates/automation/Cargo.toml" diff --git a/clippy.toml b/clippy.toml index c3292d956..569b4011b 100644 --- a/clippy.toml +++ b/clippy.toml @@ -28,7 +28,7 @@ allow-panic-in-tests = true allow-unwrap-in-tests = true # Aspirational: when clippy.wildcard_imports is re-enabled (currently -# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036), # we want the stricter variant that warns on ALL wildcard imports # including prelude. Setting it now means flipping the lint level to # warn later is a one-line change with no tuning afterthought. diff --git a/crates/cargo-anvil/docs/design/updates.md b/crates/cargo-anvil/docs/design/updates.md index 68f34e5bc..6f96537c4 100644 --- a/crates/cargo-anvil/docs/design/updates.md +++ b/crates/cargo-anvil/docs/design/updates.md @@ -171,11 +171,13 @@ own region lets `cargo sort --grouped` treat the managed and repository-owned blocks as independent groups without moving a sentinel through another lint namespace. -The old combined `anvil-workspace-lints` or root `anvil-lints` block retires in -the same run that introduces the three replacements. During this migration, -repository-owned dotted assignments below the old region are rewritten into -bare assignments under the matching new subtable. Their values and comments -are preserved; assignments outside the three lint namespaces are unaffected. +An unchanged or empty old combined `anvil-workspace-lints` or root `anvil-lints` +block retires in the same run that introduces the three replacements. An edited +old block remains tracked and causes a refusal rather than being overwritten or +retired. During a successful migration, repository-owned dotted assignments +below the old region are rewritten into bare assignments under the matching new +subtable. Their values and comments are preserved; assignments outside the +three lint namespaces are unaffected. For example, a repository's `[Hunspell] transform_regex = ["^[0-9]+$"]` stays under `[Hunspell]` below `anvil-spellcheck-hunspell`, not under quirks. diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs index bd5563f8d..55a7326e6 100644 --- a/crates/cargo-anvil/src/region.rs +++ b/crates/cargo-anvil/src/region.rs @@ -717,7 +717,7 @@ pub(crate) fn lint_region_placement(region_id: &str, current: Option<&str>) -> O .iter() .position(|candidate| *candidate == region_id) .expect("region membership was established above"); - for successor in &order[position + 1..] { + for successor in order.iter().skip(position).skip(1) { if let Ok(Some(region)) = find_region(text, successor, CommentSyntax::Hash) { return Some(RegionPlacement::At(region.start_line.start)); } @@ -771,7 +771,7 @@ fn adopt_dotted_child_assignments( { let start = protected .iter() - .find(|range| range.start <= entry.span.start && entry.span.start < range.end) + .find(|range| (range.start..range.end).contains(&entry.span.start)) .map_or(entry.span.start, |range| range.end); let relative_path = &entry.path[1..]; match managed.values.get(relative_path) { @@ -792,9 +792,8 @@ fn adopt_dotted_child_assignments( } None => { let source = &text[start..entry.span.end.min(end)]; - let Some(bare) = strip_dotted_namespace(source, namespace) else { - return Some(TomlAdoption::Unchanged); - }; + let bare = strip_dotted_namespace(source, namespace) + .expect("the parsed entry starts with the namespace selected by the filter above"); residue.push_str(&bare); } } @@ -2451,4 +2450,93 @@ mod tests { ); assert!(!masked.contains("yanked = \"deny\""), "the other region is blanked:\n{masked}"); } + + #[test] + fn legacy_lint_mask_restores_only_a_nonleading_table_header() { + let text = "# >>> anvil-managed: anvil-workspace-lints\n\ + # old catalog\n\ + [workspace.lints]\n\ + rust.unsafe_code = \"warn\"\n\ + # <<< anvil-managed: anvil-workspace-lints\n"; + let masked = mask_legacy_lint_region_to_header(text, SYN); + + assert!( + masked.contains("[workspace.lints]"), + "the parent table remains parseable:\n{masked}" + ); + assert!(!masked.contains("# old catalog"), "other legacy content stays masked:\n{masked}"); + assert!(!masked.contains("rust.unsafe_code"), "managed assignments stay masked:\n{masked}"); + } + + #[test] + fn legacy_lint_mask_ignores_a_region_without_its_expected_parent_header() { + let text = "# >>> anvil-managed: anvil-workspace-lints\n\ + [workspace.metadata]\n\ + policy = true\n\ + # <<< anvil-managed: anvil-workspace-lints\n"; + + assert_eq!(mask_legacy_lint_region_to_header(text, SYN), mask_managed_regions(text, SYN)); + } + + #[test] + fn lint_region_placement_uses_the_next_namespace_and_canonical_table_boundary() { + let clippy = "# >>> anvil-managed: anvil-workspace-clippy-lints\n\ + [workspace.lints.clippy]\n\ + # <<< anvil-managed: anvil-workspace-clippy-lints\n"; + let with_profile = format!("{clippy}\n[profile.release]\nlto = true\n"); + + assert_eq!( + lint_region_placement("anvil-workspace-rustdoc-lints", Some(&with_profile)), + Some(RegionPlacement::At(0)), + "rustdoc belongs before the existing Clippy region" + ); + assert_eq!( + lint_region_placement("anvil-workspace-clippy-lints", Some(&with_profile)), + Some(RegionPlacement::End), + "an existing region updates in place" + ); + assert_eq!( + lint_region_placement("anvil-workspace-rust-lints", Some("[profile.release]\nlto = true\n")), + Some(RegionPlacement::At(0)), + "the first lint table precedes Cargo profiles" + ); + assert_eq!(lint_region_placement("unrelated", Some(&with_profile)), None); + assert_eq!( + lint_region_placement("anvil-rust-lints", None), + Some(RegionPlacement::End), + "a new host appends its first region" + ); + } + + #[test] + fn differing_dotted_child_assignment_is_a_conflict() { + let adoption = adopt_unmanaged_toml_tables( + "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"deny\"\n", + "[workspace.lints.rust]\nunsafe_op_in_unsafe_fn = \"warn\"\n", + SYN, + ); + + assert_eq!( + adoption, + TomlAdoption::Conflict { + table: "workspace.lints".to_owned(), + key: "workspace.lints.rust.unsafe_op_in_unsafe_fn".to_owned(), + managed: "\"warn\"".to_owned(), + hand_written: "\"deny\"".to_owned(), + } + ); + } + + #[test] + fn dotted_namespace_stripping_preserves_leading_trivia_and_quoted_namespaces() { + assert_eq!( + strip_dotted_namespace("\n# policy\n \"rust\".missing_docs = \"warn\"\n", "rust"), + Some("\n# policy\n missing_docs = \"warn\"\n".to_owned()) + ); + assert_eq!( + strip_dotted_namespace("'rust'.missing_docs = \"warn\"\n", "rust"), + Some("missing_docs = \"warn\"\n".to_owned()) + ); + assert_eq!(strip_dotted_namespace("clippy.panic = \"warn\"\n", "rust"), None); + } } diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/miri-tree-borrows.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/miri-tree-borrows.just index bfafb7a0c..c58e56e6f 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/miri-tree-borrows.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/miri-tree-borrows.just @@ -12,7 +12,7 @@ # `#[cfg_attr(miri_, ignore = "")]`, keeping the # suppression next to the test rather than in a sidecar file. # The cfg names are declared in the workspace check-cfg list -# (regions/cargo-lints-body.toml) so non-miri builds don't warn. +# (regions/cargo-rust-lints.toml) so non-miri builds don't warn. # # Under scheduled-runtime-analysis these profiles run full-workspace: # that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves diff --git a/crates/cargo-anvil/templates/regions/clippy.toml b/crates/cargo-anvil/templates/regions/clippy.toml index 8d5be6246..347933766 100644 --- a/crates/cargo-anvil/templates/regions/clippy.toml +++ b/crates/cargo-anvil/templates/regions/clippy.toml @@ -24,7 +24,7 @@ allow-panic-in-tests = true allow-unwrap-in-tests = true # Aspirational: when clippy.wildcard_imports is re-enabled (currently -# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036), # we want the stricter variant that warns on ALL wildcard imports # including prelude. Setting it now means flipping the lint level to # warn later is a one-line change with no tuning afterthought. diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index da2c4941b..0b6b6a603 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1785,7 +1785,7 @@ allow-panic-in-tests = true allow-unwrap-in-tests = true # Aspirational: when clippy.wildcard_imports is re-enabled (currently -# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036), # we want the stricter variant that warns on ALL wildcard imports # including prelude. Setting it now means flipping the lint level to # warn later is a one-line change with no tuning afterthought. @@ -3165,7 +3165,7 @@ anvil-miri-strict-provenance-validate-prereqs: anvil-component-nightly-miri-vali # `#[cfg_attr(miri_, ignore = "")]`, keeping the # suppression next to the test rather than in a sidecar file. # The cfg names are declared in the workspace check-cfg list -# (regions/cargo-lints-body.toml) so non-miri builds don't warn. +# (regions/cargo-rust-lints.toml) so non-miri builds don't warn. # # Under scheduled-runtime-analysis these profiles run full-workspace: # that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index cab1af7bf..cad1f1f4d 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1898,7 +1898,7 @@ allow-panic-in-tests = true allow-unwrap-in-tests = true # Aspirational: when clippy.wildcard_imports is re-enabled (currently -# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036), # we want the stricter variant that warns on ALL wildcard imports # including prelude. Setting it now means flipping the lint level to # warn later is a one-line change with no tuning afterthought. @@ -3278,7 +3278,7 @@ anvil-miri-strict-provenance-validate-prereqs: anvil-component-nightly-miri-vali # `#[cfg_attr(miri_, ignore = "")]`, keeping the # suppression next to the test rather than in a sidecar file. # The cfg names are declared in the workspace check-cfg list -# (regions/cargo-lints-body.toml) so non-miri builds don't warn. +# (regions/cargo-rust-lints.toml) so non-miri builds don't warn. # # Under scheduled-runtime-analysis these profiles run full-workspace: # that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 329bd8359..a9efbbd9f 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -582,7 +582,7 @@ allow-panic-in-tests = true allow-unwrap-in-tests = true # Aspirational: when clippy.wildcard_imports is re-enabled (currently -# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# allowed in cargo-clippy-lints.toml due to upstream bug rust-clippy#15036), # we want the stricter variant that warns on ALL wildcard imports # including prelude. Setting it now means flipping the lint level to # warn later is a one-line change with no tuning afterthought. @@ -1962,7 +1962,7 @@ anvil-miri-strict-provenance-validate-prereqs: anvil-component-nightly-miri-vali # `#[cfg_attr(miri_, ignore = "")]`, keeping the # suppression next to the test rather than in a sidecar file. # The cfg names are declared in the workspace check-cfg list -# (regions/cargo-lints-body.toml) so non-miri builds don't warn. +# (regions/cargo-rust-lints.toml) so non-miri builds don't warn. # # Under scheduled-runtime-analysis these profiles run full-workspace: # that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves diff --git a/justfiles/anvil/checks/miri-tree-borrows.just b/justfiles/anvil/checks/miri-tree-borrows.just index bfafb7a0c..c58e56e6f 100644 --- a/justfiles/anvil/checks/miri-tree-borrows.just +++ b/justfiles/anvil/checks/miri-tree-borrows.just @@ -12,7 +12,7 @@ # `#[cfg_attr(miri_, ignore = "")]`, keeping the # suppression next to the test rather than in a sidecar file. # The cfg names are declared in the workspace check-cfg list -# (regions/cargo-lints-body.toml) so non-miri builds don't warn. +# (regions/cargo-rust-lints.toml) so non-miri builds don't warn. # # Under scheduled-runtime-analysis these profiles run full-workspace: # that tier sets ANVIL_IMPACT=off, so _anvil-impact-include resolves From 12443c8e364943c2155ac7599ae2e1a24966054c Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Tue, 22 Sep 2026 12:59:45 +0200 Subject: [PATCH 08/11] fix(cargo-anvil): preserve unmatched legacy lints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-anvil/docs/design/README.md | 17 +++---- crates/cargo-anvil/docs/verification.md | 5 +- .../cargo-anvil/src/anvil/artifacts/region.rs | 6 +++ crates/cargo-anvil/src/region.rs | 48 ++++++++++++++++--- ...ption__keeps_an_unmanaged_dotted_lint.snap | 1 - crates/cargo-anvil/tests/toml_adoption.rs | 30 ++++++++++++ 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/crates/cargo-anvil/docs/design/README.md b/crates/cargo-anvil/docs/design/README.md index 28a37cfec..88075ddd3 100644 --- a/crates/cargo-anvil/docs/design/README.md +++ b/crates/cargo-anvil/docs/design/README.md @@ -117,8 +117,9 @@ Corollaries that drive every section below: - Drift detection lives inside the files themselves (per-file checksums and per-managed-region checksums). There is no parallel metadata file. See [updates.md](./updates.md). - The tool inserts managed sections into the user's `Justfile` and into a small set of shared - config files (`deny.toml`, `[workspace.lints]` in the workspace `Cargo.toml`, and `[lints]` - in each crate's `Cargo.toml`, plus `.delta.toml` and `rustfmt.toml`). Outside those sections, + config files (`deny.toml`, separate `[workspace.lints.]` regions in the workspace + `Cargo.toml`, and `[lints]` in each member crate's `Cargo.toml`, plus `.delta.toml` and + `rustfmt.toml`). Outside those sections, the user's content is preserved verbatim. Everything else is in tool-owned files under `justfiles/anvil/` and the backend-specific cloud workflows directories. @@ -362,12 +363,12 @@ Four escape valves, in increasing severity: The path of least resistance and the recommended approach for project-specific checks. 2. **Edit a managed-region host file outside the sentinels**: extra recipes in your `Justfile`, extra rules in `deny.toml` outside the managed regions (or in the gaps - between its per-section regions), extra clippy - lints written in dotted-key form after the closing sentinel (e.g. `clippy.pedantic = "warn"` - in the `[workspace.lints]` scope). The tool preserves everything outside the - sentinels verbatim. Note that TOML forbids redeclaring a table header (`[workspace.lints.clippy]` - etc.), so user extensions must use dotted-key form or sit in a different parent - table. Repeating a managed key is invalid TOML; editing it inside the block + between its per-section regions), and extra lint keys written in bare form immediately + after the matching namespace sentinel (for example, `pedantic = "warn"` after + `anvil-workspace-clippy-lints`). The tool preserves everything outside the + sentinels verbatim. Note that TOML forbids redeclaring a table header + (`[workspace.lints.clippy]` etc.), so user extensions continue the table opened by + the managed region. Repeating a managed key is invalid TOML; editing it inside the block causes a refusal, even if the template has not changed. 3. **Disable an owned file by emptying it.** An unchanged template leaves it alone; a changed template can produce a `.anvil-proposed` sibling. Emptying a managed diff --git a/crates/cargo-anvil/docs/verification.md b/crates/cargo-anvil/docs/verification.md index 6fe1c3ff9..9e8776a34 100644 --- a/crates/cargo-anvil/docs/verification.md +++ b/crates/cargo-anvil/docs/verification.md @@ -29,8 +29,9 @@ See also: ### 2.1 Self-hosting (primary) `microsoft/ox-tools` is the canonical adopter of `cargo-anvil`. Its `.github/workflows/`, -`.github/actions/`, `justfiles/anvil/`, `[workspace.lints]` region in `Cargo.toml`, etc. -are all emitted by `cargo anvil` against the in-repo version of the binary. There +`.github/actions/`, `justfiles/anvil/`, and the `anvil-workspace-rust-lints`, +`anvil-workspace-rustdoc-lints`, and `anvil-workspace-clippy-lints` regions in +`Cargo.toml` are all emitted by `cargo anvil` against the in-repo version of the binary. There is no manual maintenance of these files after the initial migration. Every PR runs (via a small bootstrap workflow described in §3): diff --git a/crates/cargo-anvil/src/anvil/artifacts/region.rs b/crates/cargo-anvil/src/anvil/artifacts/region.rs index d202d4784..40ec20a50 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/region.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/region.rs @@ -144,6 +144,7 @@ pub fn justfile_imports() -> Artifact { ) } +/// Workspace `Cargo.toml` / `anvil-workspace-rust-lints` — Rust compiler lints. #[must_use] pub fn workspace_rust_lints() -> Artifact { lint_region( @@ -155,6 +156,7 @@ pub fn workspace_rust_lints() -> Artifact { ) } +/// Workspace `Cargo.toml` / `anvil-workspace-rustdoc-lints` — rustdoc lints. #[must_use] pub fn workspace_rustdoc_lints() -> Artifact { lint_region( @@ -166,6 +168,7 @@ pub fn workspace_rustdoc_lints() -> Artifact { ) } +/// Workspace `Cargo.toml` / `anvil-workspace-clippy-lints` — Clippy lints. #[must_use] pub fn workspace_clippy_lints() -> Artifact { lint_region( @@ -177,6 +180,7 @@ pub fn workspace_clippy_lints() -> Artifact { ) } +/// Single-crate `Cargo.toml` / `anvil-rust-lints` — Rust compiler lints. #[must_use] pub fn single_crate_rust_lints() -> Artifact { lint_region( @@ -188,6 +192,7 @@ pub fn single_crate_rust_lints() -> Artifact { ) } +/// Single-crate `Cargo.toml` / `anvil-rustdoc-lints` — rustdoc lints. #[must_use] pub fn single_crate_rustdoc_lints() -> Artifact { lint_region( @@ -199,6 +204,7 @@ pub fn single_crate_rustdoc_lints() -> Artifact { ) } +/// Single-crate `Cargo.toml` / `anvil-clippy-lints` — Clippy lints. #[must_use] pub fn single_crate_clippy_lints() -> Artifact { lint_region( diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs index 55a7326e6..97c4874de 100644 --- a/crates/cargo-anvil/src/region.rs +++ b/crates/cargo-anvil/src/region.rs @@ -19,11 +19,12 @@ //! because TOML rejects a duplicate table header outright. See //! [`adopt_unmanaged_toml_tables`]. //! The `id` is globally unique within the catalog (e.g. `anvil-imports`, -//! `anvil-workspace-lints`). +//! `anvil-workspace-rust-lints`). //! //! Empty bodies are regenerated; nonempty edits require reconciliation. use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; use ohno::{AppError, app_err, bail}; use toml_edit::{Item, Key, RawString, Table}; @@ -763,16 +764,30 @@ fn adopt_dotted_child_assignments( let end = boundary_after(boundaries, candidate.header.start, text.len()); let mut deletions = Vec::new(); let mut residue = String::new(); + let mut parent_residue = String::new(); + let is_final_lint_namespace = namespace == "clippy" && parent_path.last().is_some_and(|segment| segment == "lints"); - for entry in candidate - .entries - .iter() - .filter(|entry| entry.path.first().is_some_and(|segment| segment == namespace)) - { + for entry in &candidate.entries { let start = protected .iter() .find(|range| (range.start..range.end).contains(&entry.span.start)) .map_or(entry.span.start, |range| range.end); + if entry.path.first().is_none_or(|segment| segment != namespace) { + if is_final_lint_namespace { + parent_residue.push_str(&text[start..entry.span.end.min(end)]); + deletions.push(ByteRange { + start, + end: entry.span.end.min(end), + }); + } + continue; + } + if entry.path.len() == 1 { + return Some(TomlAdoption::Unrelocatable { + table: parent_path.iter().chain(entry.path.iter()).cloned().collect::>().join("."), + tail_table: managed.path.join("."), + }); + } let relative_path = &entry.path[1..]; match managed.values.get(relative_path) { Some(managed_value) if *managed_value == entry.value => {} @@ -802,6 +817,10 @@ fn adopt_dotted_child_assignments( end: entry.span.end.min(end), }); } + if !parent_residue.trim().is_empty() { + writeln!(residue, "[{}]", parent_path.join(".")).expect("writing to a String cannot fail"); + residue.push_str(&parent_residue); + } if deletions.is_empty() { return None; @@ -2527,6 +2546,23 @@ mod tests { ); } + #[test] + fn exact_namespace_inline_table_is_refused_instead_of_panicking() { + let adoption = adopt_unmanaged_toml_tables( + "[workspace.lints]\nrust = { missing_docs = \"warn\" }\n", + "[workspace.lints.rust]\nunsafe_op_in_unsafe_fn = \"warn\"\n", + SYN, + ); + + assert_eq!( + adoption, + TomlAdoption::Unrelocatable { + table: "workspace.lints.rust".to_owned(), + tail_table: "workspace.lints.rust".to_owned(), + } + ); + } + #[test] fn dotted_namespace_stripping_preserves_leading_trivia_and_quoted_namespaces() { assert_eq!( diff --git a/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap b/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap index e93803d23..3e48cc05c 100644 --- a/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap +++ b/crates/cargo-anvil/tests/snapshots/toml_adoption__keeps_an_unmanaged_dotted_lint.snap @@ -30,7 +30,6 @@ resolver = "2" members = ["crates/*"] [workspace.lints] - # >>> anvil-managed: anvil-workspace-rust-lints [workspace.lints.rust] # Rust compiler lint policy. Repository-specific Rust lints can follow this diff --git a/crates/cargo-anvil/tests/toml_adoption.rs b/crates/cargo-anvil/tests/toml_adoption.rs index 98983f721..6e6876a9b 100644 --- a/crates/cargo-anvil/tests/toml_adoption.rs +++ b/crates/cargo-anvil/tests/toml_adoption.rs @@ -266,6 +266,36 @@ fn replaces_the_combined_lint_region_without_losing_local_lints() { ); } +#[test] +fn combined_lint_migration_preserves_other_lint_namespaces() { + let old_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; + let before = format!( + "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n\ + # >>> anvil-managed: anvil-workspace-lints\n{old_body}\ + # <<< anvil-managed: anvil-workspace-lints\n\n\ + rust.missing_docs = \"warn\"\n\ + other.custom = \"warn\"\n" + ); + let tmp = workspace_with("Cargo.toml", &before); + let mut manifest = Manifest::default(); + manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body)); + manifest.save(tmp.path()).unwrap(); + + let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!(outcome.plan.refusals().is_empty(), "{:?}\n{after}", outcome.plan.refusals()); + let document: toml_edit::DocumentMut = after.parse().unwrap(); + assert_eq!(document["workspace"]["lints"]["rust"]["missing_docs"].as_str(), Some("warn")); + assert_eq!(document["workspace"]["lints"]["other"]["custom"].as_str(), Some("warn")); + + let second = run(&tmp); + assert!( + second.plan.items().iter().all(|item| item.decision == Decision::InSync), + "the migration must settle in one run: {:?}", + second.plan.items() + ); +} + #[test] fn replaces_the_single_crate_combined_lint_region() { let old_body = "[lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; From 2056d79a26250487930b700fcf140ac524ce9eca Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Tue, 22 Sep 2026 13:43:37 +0200 Subject: [PATCH 09/11] fix(cargo-anvil): guard legacy lint ownership Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-anvil/src/emit/managed_region.rs | 17 +++++- crates/cargo-anvil/src/region.rs | 22 +++++++- crates/cargo-anvil/tests/toml_adoption.rs | 52 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/cargo-anvil/src/emit/managed_region.rs b/crates/cargo-anvil/src/emit/managed_region.rs index c88e11d73..ea401104b 100644 --- a/crates/cargo-anvil/src/emit/managed_region.rs +++ b/crates/cargo-anvil/src/emit/managed_region.rs @@ -27,8 +27,9 @@ use crate::decision::Decision; use crate::manifest::Manifest; use crate::plan::{PlanItem, Target}; use crate::region::{ - CommentSyntax, RegionPlacement, TomlAdoption, adopt_unmanaged_toml_tables, find_region, insert_after_region, lint_region_placement, - managed_region_ids, mask_retiring_managed_regions, start_region_offset, text_newline, upsert_region_with_newline, + CommentSyntax, RegionPlacement, TomlAdoption, adopt_unmanaged_toml_tables, find_region, insert_after_region, legacy_lint_region_id, + lint_region_placement, managed_region_ids, mask_retiring_managed_regions, start_region_offset, text_newline, + upsert_region_with_newline, }; /// What the reader should do about a refused region. @@ -187,6 +188,18 @@ pub fn plan_managed_region( RefusalRemedy::EditedRegion, )); } + if let Some(legacy_id) = legacy_lint_region_id(region_id) + && host_text.is_some_and(|text| matches!(find_region(text, legacy_id, syntax), Ok(Some(_)))) + && manifest.region_checksum(host_relpath, legacy_id).is_none() + { + return Err(ManagedRegionRefusal::new( + app_err!( + "the host contains legacy managed region '{legacy_id}', but the manifest does not record it as owned. \ + Refusing to add replacement lint regions alongside an orphan that cargo-anvil cannot retire." + ), + RefusalRemedy::EditedRetirement, + )); + } let spliced = splice(host_relpath, host_text, region_id, rendered_body, syntax, placement, newline)?; Ok(PlanItem::write_region( host_relpath, diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs index 97c4874de..9f9b1ebf0 100644 --- a/crates/cargo-anvil/src/region.rs +++ b/crates/cargo-anvil/src/region.rs @@ -684,16 +684,34 @@ fn mask_legacy_lint_region_to_header(text: &str, syntax: CommentSyntax) -> Strin let Ok(Some(region)) = find_region(text, id, syntax) else { continue; }; - let Some(relative) = region.body_str().find(header) else { + let start = if let Some(relative) = region.body_str().find(header) { + region.body.start + relative + } else if region.is_empty() { + if region.body.end - region.body.start >= header.len() { + region.body.start + } else { + region.end_line.start + } + } else { continue; }; - let start = region.body.start + relative; let end = start + header.len(); masked[start..end].copy_from_slice(&text.as_bytes()[start..end]); + if region.is_empty() { + masked[start..end].copy_from_slice(header.as_bytes()); + } } String::from_utf8(masked).expect("masking preserves UTF-8 and only restores original UTF-8 slices") } +pub(crate) fn legacy_lint_region_id(region_id: &str) -> Option<&'static str> { + match region_id { + "anvil-workspace-rust-lints" | "anvil-workspace-rustdoc-lints" | "anvil-workspace-clippy-lints" => Some("anvil-workspace-lints"), + "anvil-rust-lints" | "anvil-rustdoc-lints" | "anvil-clippy-lints" => Some("anvil-lints"), + _ => None, + } +} + pub(crate) fn lint_region_placement(region_id: &str, current: Option<&str>) -> Option { const WORKSPACE: &[&str] = &[ "anvil-workspace-rust-lints", diff --git a/crates/cargo-anvil/tests/toml_adoption.rs b/crates/cargo-anvil/tests/toml_adoption.rs index 6e6876a9b..24ddc609c 100644 --- a/crates/cargo-anvil/tests/toml_adoption.rs +++ b/crates/cargo-anvil/tests/toml_adoption.rs @@ -296,6 +296,58 @@ fn combined_lint_migration_preserves_other_lint_namespaces() { ); } +#[test] +fn empty_combined_lint_region_still_migrates_following_local_lints() { + let old_body = ""; + let before = "\ +[workspace] +resolver = \"2\" +members = [\"crates/*\"] + +# >>> anvil-managed: anvil-workspace-lints +# <<< anvil-managed: anvil-workspace-lints + +rust.missing_docs = \"warn\" +clippy.panic = \"warn\" +"; + let tmp = workspace_with("Cargo.toml", before); + let mut manifest = Manifest::default(); + manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body)); + manifest.save(tmp.path()).unwrap(); + + let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!(outcome.plan.refusals().is_empty(), "{:?}\n{after}", outcome.plan.refusals()); + let document: toml_edit::DocumentMut = after.parse().unwrap(); + assert_eq!(document["workspace"]["lints"]["rust"]["missing_docs"].as_str(), Some("warn")); + assert_eq!(document["workspace"]["lints"]["clippy"]["panic"].as_str(), Some("warn")); + assert!(!after.contains("anvil-workspace-lints")); +} + +#[test] +fn untracked_combined_lint_region_is_refused() { + let old_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; + let before = format!( + "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n\ + # >>> anvil-managed: anvil-workspace-lints\n{old_body}\ + # <<< anvil-managed: anvil-workspace-lints\n" + ); + let tmp = workspace_with("Cargo.toml", &before); + + let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!( + outcome + .plan + .refusals() + .iter() + .any(|item| item.contains("manifest does not record it as owned")), + "{:?}", + outcome.plan.refusals() + ); + assert_eq!(after, before); +} + #[test] fn replaces_the_single_crate_combined_lint_region() { let old_body = "[lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; From e70583e771ebb1705454691e3a847ea30f954d01 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Tue, 22 Sep 2026 16:30:41 +0200 Subject: [PATCH 10/11] test(cargo-anvil): cover legacy lint branches Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-anvil/src/region.rs | 6 +----- crates/cargo-anvil/tests/toml_adoption.rs | 26 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs index 9f9b1ebf0..212e6b047 100644 --- a/crates/cargo-anvil/src/region.rs +++ b/crates/cargo-anvil/src/region.rs @@ -687,11 +687,7 @@ fn mask_legacy_lint_region_to_header(text: &str, syntax: CommentSyntax) -> Strin let start = if let Some(relative) = region.body_str().find(header) { region.body.start + relative } else if region.is_empty() { - if region.body.end - region.body.start >= header.len() { - region.body.start - } else { - region.end_line.start - } + region.body.start } else { continue; }; diff --git a/crates/cargo-anvil/tests/toml_adoption.rs b/crates/cargo-anvil/tests/toml_adoption.rs index 24ddc609c..fb12c5521 100644 --- a/crates/cargo-anvil/tests/toml_adoption.rs +++ b/crates/cargo-anvil/tests/toml_adoption.rs @@ -348,6 +348,32 @@ fn untracked_combined_lint_region_is_refused() { assert_eq!(after, before); } +#[test] +fn untracked_single_crate_combined_lint_region_is_refused() { + let old_body = "[lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; + let before = format!( + "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n\ + # >>> anvil-managed: anvil-lints\n{old_body}\ + # <<< anvil-managed: anvil-lints\n" + ); + let tmp = TempDir::new().unwrap(); + write(&tmp.path().join("Cargo.toml"), &before); + write(&tmp.path().join("src/lib.rs"), ""); + + let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!( + outcome + .plan + .refusals() + .iter() + .any(|item| item.contains("manifest does not record it as owned")), + "{:?}", + outcome.plan.refusals() + ); + assert_eq!(after, before); +} + #[test] fn replaces_the_single_crate_combined_lint_region() { let old_body = "[lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; From ce82ac35d25ed845ba4804cdd49ad6329b880186 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Tue, 22 Sep 2026 18:50:15 +0200 Subject: [PATCH 11/11] fix(cargo-anvil): refuse unsafe lint migrations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cargo-anvil/docs/design/updates.md | 4 +- crates/cargo-anvil/src/emit/managed_region.rs | 32 ++++++++--- crates/cargo-anvil/src/region.rs | 13 +++++ crates/cargo-anvil/src/run.rs | 14 ++++- crates/cargo-anvil/tests/toml_adoption.rs | 55 +++++++++++++++++++ 5 files changed, 106 insertions(+), 12 deletions(-) diff --git a/crates/cargo-anvil/docs/design/updates.md b/crates/cargo-anvil/docs/design/updates.md index 6f96537c4..e352c5f79 100644 --- a/crates/cargo-anvil/docs/design/updates.md +++ b/crates/cargo-anvil/docs/design/updates.md @@ -177,7 +177,9 @@ old block remains tracked and causes a refusal rather than being overwritten or retired. During a successful migration, repository-owned dotted assignments below the old region are rewritten into bare assignments under the matching new subtable. Their values and comments are preserved; assignments outside the -three lint namespaces are unaffected. +three lint namespaces are unaffected. An empty legacy block cannot identify its +parent table when that parent is also declared outside the block, so this +ambiguous layout is refused and left unchanged for manual reconciliation. For example, a repository's `[Hunspell] transform_regex = ["^[0-9]+$"]` stays under `[Hunspell]` below `anvil-spellcheck-hunspell`, not under quirks. diff --git a/crates/cargo-anvil/src/emit/managed_region.rs b/crates/cargo-anvil/src/emit/managed_region.rs index ea401104b..2e8641d37 100644 --- a/crates/cargo-anvil/src/emit/managed_region.rs +++ b/crates/cargo-anvil/src/emit/managed_region.rs @@ -189,16 +189,30 @@ pub fn plan_managed_region( )); } if let Some(legacy_id) = legacy_lint_region_id(region_id) - && host_text.is_some_and(|text| matches!(find_region(text, legacy_id, syntax), Ok(Some(_)))) - && manifest.region_checksum(host_relpath, legacy_id).is_none() + && let Some(text) = host_text + && let Some(legacy_region) = + find_region(text, legacy_id, syntax).map_err(|error| ManagedRegionRefusal::new(error, RefusalRemedy::MalformedMarkers))? { - return Err(ManagedRegionRefusal::new( - app_err!( - "the host contains legacy managed region '{legacy_id}', but the manifest does not record it as owned. \ - Refusing to add replacement lint regions alongside an orphan that cargo-anvil cannot retire." - ), - RefusalRemedy::EditedRetirement, - )); + let recorded_checksum = manifest.region_checksum(host_relpath, legacy_id); + if recorded_checksum.is_none() { + return Err(ManagedRegionRefusal::new( + app_err!( + "the host contains legacy managed region '{legacy_id}', but the manifest does not record it as owned. \ + Refusing to add replacement lint regions alongside an orphan that cargo-anvil cannot retire." + ), + RefusalRemedy::EditedRetirement, + )); + } + let disk_checksum = checksum_str(legacy_region.body_str()); + if !legacy_region.is_empty() && recorded_checksum != Some(disk_checksum.as_str()) { + return Err(ManagedRegionRefusal::new( + app_err!( + "legacy managed region '{legacy_id}' contains edits that do not match its last render. \ + Restore its generated content, or empty its body before migrating to the replacement lint regions." + ), + RefusalRemedy::EditedRetirement, + )); + } } let spliced = splice(host_relpath, host_text, region_id, rendered_body, syntax, placement, newline)?; Ok(PlanItem::write_region( diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs index 212e6b047..2a3b18fc7 100644 --- a/crates/cargo-anvil/src/region.rs +++ b/crates/cargo-anvil/src/region.rs @@ -593,6 +593,19 @@ pub fn adopt_unmanaged_toml_tables(text: &str, body: &str, syntax: CommentSyntax boundaries.extend(protected.iter().map(|range| range.start)); boundaries.sort_unstable(); + for (legacy_id, parent) in [ + ("anvil-workspace-lints", ["workspace", "lints"].as_slice()), + ("anvil-lints", ["lints"].as_slice()), + ] { + if matches!(find_region(text, legacy_id, syntax), Ok(Some(region)) if region.is_empty()) + && candidates.iter().any(|table| table.path == parent) + { + return TomlAdoption::Unrelocatable { + table: parent.join("."), + tail_table: tail.join("."), + }; + } + } if let Some(adoption) = adopt_dotted_child_assignments(text, &managed, &candidates, &boundaries, &protected) { return adoption; } diff --git a/crates/cargo-anvil/src/run.rs b/crates/cargo-anvil/src/run.rs index dfa851612..cc9cb07cc 100644 --- a/crates/cargo-anvil/src/run.rs +++ b/crates/cargo-anvil/src/run.rs @@ -27,7 +27,8 @@ use crate::plan::{Plan, PlanItem, Target}; #[cfg(test)] use crate::region::upsert_region; use crate::region::{ - CommentSyntax, MarkerRepair, RegionPlacement, find_region, lint_region_placement, managed_region_ids, remove_region, repair_markers, + CommentSyntax, MarkerRepair, RegionPlacement, find_region, legacy_lint_region_id, lint_region_placement, managed_region_ids, + remove_region, repair_markers, }; use crate::workspace::{self, Workspace}; @@ -407,7 +408,16 @@ fn push_region_at( let item = match plan_managed_region(manifest, current.as_deref(), request) { Ok(item) => item, Err(refusal) => { - refuse_region(plan, host, spec.id.as_str(), &refusal.reason.to_string(), refusal.remedy); + let reason = refusal.reason.to_string(); + if legacy_lint_region_id(spec.id.as_str()).is_some_and(|legacy_id| { + current + .as_deref() + .is_some_and(|text| matches!(find_region(text, legacy_id, spec.syntax), Ok(Some(_)))) + }) { + composed.states.insert(host.clone(), ComposedHostState::Unsafe(reason.clone())); + composed.reported.insert(host.clone()); + } + refuse_region(plan, host, spec.id.as_str(), &reason, refusal.remedy); return Ok(()); } }; diff --git a/crates/cargo-anvil/tests/toml_adoption.rs b/crates/cargo-anvil/tests/toml_adoption.rs index fb12c5521..7d4f6d437 100644 --- a/crates/cargo-anvil/tests/toml_adoption.rs +++ b/crates/cargo-anvil/tests/toml_adoption.rs @@ -374,6 +374,61 @@ fn untracked_single_crate_combined_lint_region_is_refused() { assert_eq!(after, before); } +#[test] +fn edited_combined_lint_region_is_refused() { + let old_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n"; + let edited_body = "[workspace.lints]\nrust.unsafe_op_in_unsafe_fn = \"deny\"\n"; + let before = format!( + "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n\n\ + # >>> anvil-managed: anvil-workspace-lints\n{edited_body}\ + # <<< anvil-managed: anvil-workspace-lints\n" + ); + let tmp = workspace_with("Cargo.toml", &before); + let mut manifest = Manifest::default(); + manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body)); + manifest.save(tmp.path()).unwrap(); + + let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!( + outcome + .plan + .refusals() + .iter() + .any(|item| item.contains("legacy managed region 'anvil-workspace-lints' contains edits")), + "{:?}", + outcome.plan.refusals() + ); + assert_eq!(after, before); +} + +#[test] +fn empty_combined_lint_region_after_external_parent_table_is_refused() { + let old_body = ""; + let before = "\ +[workspace] +resolver = \"2\" +members = [\"crates/*\"] + +[workspace.lints] +rust.unreachable_pub = \"warn\" + +# >>> anvil-managed: anvil-workspace-lints +# <<< anvil-managed: anvil-workspace-lints + +rust.missing_docs = \"warn\" +"; + let tmp = workspace_with("Cargo.toml", before); + let mut manifest = Manifest::default(); + manifest.set_region("Cargo.toml", "anvil-workspace-lints", checksum_str(old_body)); + manifest.save(tmp.path()).unwrap(); + + let outcome = run(&tmp); + let after = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!(!outcome.plan.refusals().is_empty(), "{after}"); + assert_eq!(after, before); +} + #[test] fn replaces_the_single_crate_combined_lint_region() { let old_body = "[lints]\nrust.unsafe_op_in_unsafe_fn = \"warn\"\n";