diff --git a/crates/cargo-gears-core/shared/SKILL.md b/crates/cargo-gears-core/shared/SKILL.md index 0361668..5abc124 100644 --- a/crates/cargo-gears-core/shared/SKILL.md +++ b/crates/cargo-gears-core/shared/SKILL.md @@ -910,7 +910,7 @@ Run workspace linting helpers from the selected workspace directory. Synopsis: ```bash -cargo gears lint [--app ] [--env ] [--manifest ] [-p ] [--all] [--fmt] [--clippy] [--strict] [--dylint] +cargo gears lint [--app ] [--env ] [--manifest ] [-p ] [--all] [--fmt] [--clippy] [--strict] [--dylint] [--list] ``` Arguments: @@ -924,6 +924,8 @@ Arguments: - **[`--strict`]** Turns Clippy warnings into errors; valid only when Clippy is selected explicitly or through `--all` - **[`--dylint`]** Runs the embedded `cargo-gears-lints` Dylint rules against the workspace rooted at the current or selected directory +- **[`--list`]** Lists available lint rules instead of running them. When combined with `--dylint`, lists only the + embedded dylint rules. Does not require a manifest or workspace path. Behavior: @@ -943,6 +945,8 @@ Behavior: listed rules are ignored for that lint run - **[toolchain bootstrap]** The build script ensures the lint package toolchain and components are installed when compiling embedded rules. Before running Dylint, the CLI ensures the embedded dylib toolchain is installed. +- **[list mode]** `--list` prints available lints grouped by category and exits without running any checks. + `--list --dylint` prints only the embedded dylint rules. `--list` alone prints all lint suites plus the dylint rules. Examples: @@ -966,6 +970,14 @@ cargo gears lint --app app1 --env dev --dylint cargo gears lint -p /tmp/cf-demo --app app1 --env dev --dylint ``` +```bash +cargo gears lint --list --dylint +``` + +```bash +cargo gears lint --list +``` + Manifest Dylint skip example: ```toml @@ -1150,7 +1162,7 @@ cargo gears config db rm [-p ] -c cargo gears ls modules [-p ] [--system] [--local] [--verbose] [--registry crates.io] [-f table|json] cargo gears src [-p ] [--version ] [--clean] [] -cargo gears lint [-p ] [--app ] [--env ] [--manifest ] [--all] [--clippy] [--strict] [--dylint] +cargo gears lint [-p ] [--app ] [--env ] [--manifest ] [--all] [--clippy] [--strict] [--dylint] [--list] cargo gears tools --all cargo gears run [-p ] [--app ] [--env ] [--manifest ] [--name ] [--watch] cargo gears build [-p ] [--app ] [--env ] [--manifest ] [--name ] diff --git a/crates/cargo-gears-core/src/lint/mod.rs b/crates/cargo-gears-core/src/lint/mod.rs index c913095..680a9ac 100644 --- a/crates/cargo-gears-core/src/lint/mod.rs +++ b/crates/cargo-gears-core/src/lint/mod.rs @@ -36,13 +36,207 @@ pub struct LintParams { pub dylint: bool, /// Lint names to skip when running dylint. pub dylint_skip: Vec, + /// List available lints instead of running them. + pub list: bool, } +/// Metadata for a single embedded dylint rule. +#[derive(Debug, Clone)] +pub struct DylintLintInfo { + /// Lint code, e.g. "DE0101". + pub code: &'static str, + /// Rustc-level lint name, e.g. `de0101_no_serde_in_contract`. + pub name: &'static str, + /// Category grouping, e.g. "Domain Layer". + pub category: &'static str, + /// One-line description of the lint. + pub description: &'static str, + /// Default lint level ("deny" or "warn"). + pub default_level: &'static str, +} + +/// All embedded dylint rules, sorted by code. +pub static DYLINT_LINTS: &[DylintLintInfo] = &[ + DylintLintInfo { + code: "DE0101", + name: "de0101_no_serde_in_contract", + category: "Domain Layer", + description: "domain models should not have serde derives", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0102", + name: "de0102_no_toschema_in_contract", + category: "Domain Layer", + description: "domain models should not have ToSchema derive", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0104", + name: "de0104_no_api_dto_in_contract", + category: "Domain Layer", + description: "domain models should not use api_dto macro", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0201", + name: "de0201_dtos_only_in_api_rest", + category: "API Layer", + description: "DTO types should only be defined in */api/rest/* files", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0202", + name: "de0202_dtos_not_referenced_outside_api", + category: "API Layer", + description: "DTO types should not be imported outside of api layer", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0203", + name: "de0203_dtos_must_use_api_dto", + category: "API Layer", + description: "DTO types must use the api_dto macro", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0204", + name: "de0204_dtos_must_have_toschema_derive", + category: "API Layer", + description: "DTO types must derive ToSchema for OpenAPI documentation", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0301", + name: "de0301_no_infra_in_domain", + category: "Domain Boundaries", + description: "domain modules should not import infrastructure dependencies", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0308", + name: "de0308_no_http_in_domain", + category: "Domain Boundaries", + description: "domain modules should not reference HTTP types or status codes", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0503", + name: "de0503_plugin_client_suffix", + category: "Client Layer", + description: "plugin client traits should use *PluginClient suffix", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0504", + name: "de0504_client_versioning", + category: "Client Layer", + description: "Client/PluginClient traits must have version suffixes (V1, V2, ...)", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0706", + name: "de0706_no_direct_sqlx", + category: "Security", + description: "direct sqlx usage is prohibited; use Sea-ORM or SecORM instead", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0707", + name: "de0707_drop_zeroize", + category: "Security", + description: "manual byte-zeroing in Drop may be optimized away; use zeroize crate", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0708", + name: "de0708_no_non_fips_hasher", + category: "Security", + description: "non-FIPS-validated hasher import outside allow-list", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0801", + name: "de0801_api_endpoint_version", + category: "REST API Conventions", + description: "API endpoints must follow /{service-name}/v{N}/{resource} format", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0802", + name: "de0802_use_odata_ext", + category: "REST API Conventions", + description: "use OperationBuilderODataExt instead of .query_param() for OData", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0803", + name: "de0803_api_snake_case", + category: "REST API Conventions", + description: "API DTOs must use snake_case in serde rename attributes", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0901", + name: "de0901_gts_string_pattern", + category: "GTS Layer", + description: "invalid GTS string pattern", + default_level: "deny", + }, + DylintLintInfo { + code: "DE0902", + name: "de0902_no_schema_for_on_gts_structs", + category: "GTS Layer", + description: "GTS structs must use gts_schema_with_refs_as_string() instead of schema_for!()", + default_level: "deny", + }, + DylintLintInfo { + code: "DE1101", + name: "de1101_tests_in_separate_files", + category: "Testing", + description: "tests must live in separate files, not inline in production files", + default_level: "deny", + }, + DylintLintInfo { + code: "DE1201", + name: "de1201_docs_rs_all_features", + category: "Documentation", + description: "crates with features must set docs.rs all-features metadata", + default_level: "deny", + }, + DylintLintInfo { + code: "DE1301", + name: "de1301_no_print_macros", + category: "Common Patterns", + description: "print/debug macros are forbidden in production code", + default_level: "deny", + }, + DylintLintInfo { + code: "DE1302", + name: "de1302_error_from_to_string", + category: "Common Patterns", + description: "calling .to_string() in From impl destroys the error chain", + default_level: "deny", + }, + DylintLintInfo { + code: "DE1303", + name: "de1303_no_primitive_type_alias", + category: "Common Patterns", + description: "pub type X = primitive is a transparent alias; use a newtype", + default_level: "deny", + }, +]; + #[cfg(feature = "dylint-rules")] include!(concat!(env!("OUT_DIR"), "/generated_libs.rs")); impl LintParams { pub fn run(&self) -> Result<()> { + if self.list { + list_lints(self.dylint); + return Ok(()); + } + if self.fmt { run_fmt(&self.workspace_root)?; } @@ -59,6 +253,36 @@ impl LintParams { } } +fn list_lints(dylint_only: bool) { + if !dylint_only { + println!("Built-in lint suites:"); + println!(" fmt Run `cargo fmt --check --all`"); + println!(" clippy Run `cargo clippy --workspace --all-targets`"); + println!(" dylint Run embedded architectural lint rules (see below)"); + println!(); + } + + println!("Embedded dylint rules ({} total):\n", DYLINT_LINTS.len()); + + // Group by category for readability. + let mut current_category = ""; + for lint in DYLINT_LINTS { + if lint.category != current_category { + if !current_category.is_empty() { + println!(); + } + println!(" {}:", lint.category); + current_category = lint.category; + } + println!( + " {code:<8} {name:<45} {desc}", + code = lint.code, + name = lint.name, + desc = lint.description, + ); + } +} + fn run_fmt(workspace_path: &Path) -> Result<()> { let mut cmd = cargo_cmd()?; cmd.args(["fmt", "--check", "--all"]); @@ -212,6 +436,8 @@ fn run_dylint(_workspace_path: &Path, _skipped_lints: &[String]) -> Result<()> { #[cfg(test)] mod tests { + use super::DYLINT_LINTS; + #[cfg(feature = "dylint-rules")] #[test] fn dylint_skip_list_is_converted_to_cargo_rustflags_config() { @@ -230,4 +456,35 @@ mod tests { ] ); } + + #[test] + fn dylint_lints_registry_is_sorted_by_code() { + for pair in DYLINT_LINTS.windows(2) { + assert!( + pair[0].code < pair[1].code, + "DYLINT_LINTS not sorted: {} should come before {}", + pair[0].code, + pair[1].code, + ); + } + } + + #[test] + fn dylint_lints_names_match_codes() { + for lint in DYLINT_LINTS { + let lower_code = lint.code.to_lowercase(); + assert!( + lint.name.starts_with(&lower_code), + "lint name `{}` should start with its lowercase code `{}`", + lint.name, + lower_code, + ); + } + } + + #[test] + fn list_lints_does_not_panic() { + super::list_lints(true); + super::list_lints(false); + } } diff --git a/crates/cargo-gears/src/lint.rs b/crates/cargo-gears/src/lint.rs index f8e81d8..f94191b 100644 --- a/crates/cargo-gears/src/lint.rs +++ b/crates/cargo-gears/src/lint.rs @@ -22,6 +22,10 @@ pub struct LintArgs { /// Run extra lint rules made for gears modules. #[arg(long)] dylint: bool, + /// List available lint rules instead of running them. + /// Combine with `--dylint` to list only dylint rules. + #[arg(long)] + list: bool, } impl LintArgs { @@ -31,6 +35,19 @@ impl LintArgs { /// Resolve manifest + CLI overrides into a fully-resolved `LintParams`. pub fn resolve(self) -> anyhow::Result { + // `--list` short-circuits: print available lints and exit. + if self.list { + return Ok(cargo_gears_core::lint::LintParams { + workspace_root: std::path::PathBuf::new(), + fmt: false, + clippy: false, + strict: false, + dylint: self.dylint || self.all, + dylint_skip: Vec::new(), + list: true, + }); + } + let workspace_path = cargo_gears_core::common::resolve_workspace_path(self.workspace.path.as_deref())?; @@ -66,6 +83,7 @@ impl LintArgs { strict: self.strict, dylint, dylint_skip, + list: false, }) } } @@ -213,4 +231,28 @@ mod tests { assert_eq!(resolved.dylint_skip, vec!["some_lint", "other_lint"]); } + + #[test] + fn list_flag_skips_manifest_resolution() { + // --list should resolve without a manifest or workspace path. + let cli = + TestCli::try_parse_from(["gears", "--app", "a", "--env", "e", "--list", "--dylint"]) + .expect("should parse"); + let resolved = cli.lint.resolve().expect("resolve"); + + assert!(resolved.list); + assert!(resolved.dylint); + assert!(!resolved.fmt); + assert!(!resolved.clippy); + } + + #[test] + fn list_without_dylint_sets_dylint_false() { + let cli = TestCli::try_parse_from(["gears", "--app", "a", "--env", "e", "--list"]) + .expect("should parse"); + let resolved = cli.lint.resolve().expect("resolve"); + + assert!(resolved.list); + assert!(!resolved.dylint); + } }