From adb5d2d5c3062360f601b8bbca5cbe51c04f5d05 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 13:02:31 +0000 Subject: [PATCH 001/141] feat: add flint Rust binary skeleton (M1) Working binary that discovers tools from PATH via built-in registry, runs them in parallel against changed files (merge-base diff), and exits non-zero on failure. Replaces run-linters.sh for standard linters. Supported linters: shellcheck, shfmt, markdownlint, prettier, actionlint, hadolint, codespell, ec, golangci-lint, ruff, ruff-format, biome, biome-format. Flags: --fix (AUTOFIX env), --full, --from-ref, --to-ref, [linters...] Config: optional flint.toml with settings.base_branch and settings.exclude. Signed-off-by: Gregor Zeitlinger --- .editorconfig | 4 + .gitignore | 1 + Cargo.lock | 555 ++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 16 ++ mise.toml | 29 ++- src/config.rs | 35 +++ src/files.rs | 142 +++++++++++++ src/main.rs | 97 +++++++++ src/registry.rs | 132 ++++++++++++ src/runner.rs | 207 ++++++++++++++++++ 10 files changed, 1217 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/config.rs create mode 100644 src/files.rs create mode 100644 src/main.rs create mode 100644 src/registry.rs create mode 100644 src/runner.rs diff --git a/.editorconfig b/.editorconfig index 7add053..bfa467e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,5 +10,9 @@ max_line_length = 120 [*.py] indent_size = 4 +[*.rs] +indent_size = 4 +max_line_length = off + [{CLAUDE.md,.editorconfig,super-linter.env,lychee.toml,renovate.json5,default.json,mise.toml}] max_line_length = 300 diff --git a/.gitignore b/.gitignore index 1933a30..549fd40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea .mise.super-linter-*.toml +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b8519ac --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,555 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "flint" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "regex", + "serde", + "tokio", + "toml", + "which", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b0be4c5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "flint" +version = "0.1.0" +edition = "2024" +description = "mise-native lint orchestrator" +license = "Apache-2.0" +repository = "https://github.com/grafana/flint" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive", "env"] } +serde = { version = "1", features = ["derive"] } +toml = "1.0" +tokio = { version = "1", features = ["full"] } +which = "7" +regex = "1" diff --git a/mise.toml b/mise.toml index 9615cb3..01357d4 100644 --- a/mise.toml +++ b/mise.toml @@ -43,7 +43,7 @@ run = "AUTOFIX=true mise run lint" [tasks.native-lint] description = "Run lints natively (no container)" -run = "NATIVE=true mise run lint:fast" +depends = ["lint:rust", "check-fmt"] [tasks.pre-commit] description = "Pre-commit hook: native lint" @@ -53,3 +53,30 @@ run = "NATIVE=true mise run lint:fast" [tasks."setup:pre-commit-hook"] description = "Install git pre-commit hook that runs native linting" run = "mise generate git-pre-commit --write --task=pre-commit" + +# Rust tasks +[tasks.build] +description = "Build the project" +run = "cargo build" + +[tasks."lint:rust"] +description = "Lint Rust code (clippy)" +run = """ +if [ "${AUTOFIX:-}" = "true" ]; then + cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings +else + cargo clippy -q -- -D warnings +fi +""" + +[tasks.fmt] +description = "Format Rust code" +run = "cargo fmt" + +[tasks."check-fmt"] +description = "Check Rust formatting" +run = "cargo fmt -- --check" + +[tasks.test] +description = "Run tests" +run = "cargo test -q" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..177b775 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct Config { + pub settings: Settings, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct Settings { + pub base_branch: String, + pub exclude: Option, +} + +impl Default for Settings { + fn default() -> Self { + Self { + base_branch: "main".to_string(), + exclude: None, + } + } +} + +pub fn load(project_root: &Path) -> Result { + let path = project_root.join("flint.toml"); + if !path.exists() { + return Ok(Config::default()); + } + let text = std::fs::read_to_string(&path)?; + let cfg: Config = toml::from_str(&text)?; + Ok(cfg) +} diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..e3959ec --- /dev/null +++ b/src/files.rs @@ -0,0 +1,142 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::Config; + +pub struct FileList { + pub files: Vec, + /// The merge base ref, used by project-scoped checks (e.g. golangci-lint). + pub merge_base: Option, +} + +pub fn changed( + project_root: &Path, + cfg: &Config, + full: bool, + from_ref: Option<&str>, + to_ref: Option<&str>, +) -> Result { + if full { + return all_files(project_root, cfg); + } + + // Determine merge base. + let merge_base = resolve_merge_base(project_root, cfg, from_ref)?; + + let files = if let Some(ref base) = merge_base { + let to = to_ref.unwrap_or("HEAD"); + collect_changed_files(project_root, cfg, base, to)? + } else { + // No merge base (shallow clone etc.) — fall back to all files. + return all_files(project_root, cfg); + }; + + Ok(FileList { files, merge_base }) +} + +fn resolve_merge_base( + project_root: &Path, + cfg: &Config, + from_ref: Option<&str>, +) -> Result> { + let base_ref = from_ref.unwrap_or(cfg.settings.base_branch.as_str()); + + // Try `origin/` first, then bare ``. + for candidate in [format!("origin/{base_ref}"), base_ref.to_string()] { + let out = Command::new("git") + .args(["merge-base", &candidate, "HEAD"]) + .current_dir(project_root) + .output() + .context("git merge-base")?; + if out.status.success() { + return Ok(Some( + String::from_utf8_lossy(&out.stdout).trim().to_string(), + )); + } + } + + Ok(None) +} + +fn collect_changed_files( + project_root: &Path, + cfg: &Config, + base: &str, + to: &str, +) -> Result> { + let range = format!("{base}...{to}"); + let mut names: std::collections::BTreeSet = Default::default(); + + // Committed changes in the range. + for line in git_diff_names(project_root, &["--diff-filter=d", &range])? { + names.insert(line); + } + // Unstaged changes. + for line in git_diff_names(project_root, &["--diff-filter=d"])? { + names.insert(line); + } + // Staged changes. + for line in git_diff_names(project_root, &["--cached", "--diff-filter=d"])? { + names.insert(line); + } + + Ok(filter_existing(project_root, cfg, names)) +} + +fn all_files(project_root: &Path, cfg: &Config) -> Result { + let out = Command::new("git") + .args(["ls-files"]) + .current_dir(project_root) + .output() + .context("git ls-files")?; + + let names: std::collections::BTreeSet = String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect(); + + Ok(FileList { + files: filter_existing(project_root, cfg, names), + merge_base: None, + }) +} + +fn git_diff_names(project_root: &Path, extra_args: &[&str]) -> Result> { + let mut args = vec!["diff", "--name-only"]; + args.extend_from_slice(extra_args); + let out = Command::new("git") + .args(&args) + .current_dir(project_root) + .output() + .context("git diff --name-only")?; + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect()) +} + +fn filter_existing( + project_root: &Path, + cfg: &Config, + names: std::collections::BTreeSet, +) -> Vec { + let exclude_re: Option = cfg + .settings + .exclude + .as_deref() + .and_then(|pat| regex::Regex::new(pat).ok()); + + names + .into_iter() + .filter(|name| { + if let Some(re) = &exclude_re { + !re.is_match(name) + } else { + true + } + }) + .map(|name| project_root.join(&name)) + .filter(|p| p.exists()) + .collect() +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bf0fe78 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,97 @@ +mod config; +mod files; +mod registry; +mod runner; + +use anyhow::Result; +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(name = "flint", about = "mise-native lint orchestrator")] +struct Cli { + /// Auto-fix issues instead of checking + #[arg(long, env = "AUTOFIX")] + fix: bool, + + /// Lint all files instead of only changed files + #[arg(long)] + full: bool, + + /// Compare changed files from this ref (default: merge base with base branch) + #[arg(long)] + from_ref: Option, + + /// Compare changed files to this ref (default: HEAD) + #[arg(long)] + to_ref: Option, + + /// Linters to run (default: all discovered) + linters: Vec, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + let project_root = std::env::var("MISE_PROJECT_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().expect("cannot determine working directory")); + + std::env::set_current_dir(&project_root)?; + + let cfg = config::load(&project_root)?; + + let registry = registry::builtin(); + + // Filter registry to requested linters (or all if none specified). + let checks: Vec<®istry::Check> = if cli.linters.is_empty() { + registry.iter().collect() + } else { + let mut out = vec![]; + for name in &cli.linters { + match registry.iter().find(|c| c.name == name.as_str()) { + Some(c) => out.push(c), + None => { + eprintln!("flint: unknown linter: {name}"); + std::process::exit(1); + } + } + } + out + }; + + // Discover which checks have their tool available in PATH. + let active: Vec<®istry::Check> = checks + .into_iter() + .filter(|c| which::which(c.bin()).is_ok()) + .collect(); + + let file_list = files::changed( + &project_root, + &cfg, + cli.full, + cli.from_ref.as_deref(), + cli.to_ref.as_deref(), + )?; + + let results = runner::run(&active, &file_list, cli.fix, &project_root).await?; + + let mut failed = false; + for (name, ok) in &results { + if !ok { + eprintln!("flint: {name} failed"); + failed = true; + } + } + + if failed { + if !cli.fix { + eprintln!( + "\nšŸ’” Try `mise run fix` to auto-fix lint issues, then re-run `mise run lint` to verify." + ); + } + std::process::exit(1); + } + + Ok(()) +} diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..9706718 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,132 @@ +/// How a check is invoked relative to the file list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + /// Invoked once per matched file: `{FILE}` placeholder. + File, + /// Invoked once with all matched files: `{FILES}` placeholder. + Files, + /// Invoked once with no file args (e.g. golangci-lint). + Project, +} + +#[derive(Debug, Clone)] +pub struct Check { + pub name: &'static str, + /// Command template for check mode. + pub check_cmd: &'static str, + /// Command template for fix mode (empty string = no fix support). + pub fix_cmd: &'static str, + /// Glob patterns (space-separated) for matching files. + pub patterns: &'static str, + pub scope: Scope, +} + +impl Check { + /// The binary name (first word of check_cmd). + pub fn bin(&self) -> &str { + self.check_cmd + .split_whitespace() + .next() + .unwrap_or(self.name) + } + + pub fn has_fix(&self) -> bool { + !self.fix_cmd.is_empty() + } +} + +pub fn builtin() -> Vec { + vec![ + Check { + name: "shellcheck", + check_cmd: "shellcheck {FILE}", + fix_cmd: "", + patterns: "*.sh *.bash *.bats", + scope: Scope::File, + }, + Check { + name: "shfmt", + check_cmd: "shfmt -d {FILE}", + fix_cmd: "shfmt -w {FILE}", + patterns: "*.sh *.bash", + scope: Scope::File, + }, + Check { + name: "markdownlint", + check_cmd: "markdownlint {FILE}", + fix_cmd: "markdownlint --fix {FILE}", + patterns: "*.md", + scope: Scope::File, + }, + Check { + name: "prettier", + check_cmd: "prettier --check {FILES}", + fix_cmd: "prettier --write {FILES}", + patterns: "*.md *.json *.yml *.yaml", + scope: Scope::Files, + }, + Check { + name: "actionlint", + check_cmd: "actionlint {FILE}", + fix_cmd: "", + patterns: ".github/workflows/*.yml .github/workflows/*.yaml", + scope: Scope::File, + }, + Check { + name: "hadolint", + check_cmd: "hadolint {FILE}", + fix_cmd: "", + patterns: "Dockerfile Dockerfile.* *.dockerfile", + scope: Scope::File, + }, + Check { + name: "codespell", + check_cmd: "codespell {FILES}", + fix_cmd: "codespell --write-changes {FILES}", + patterns: "*", + scope: Scope::Files, + }, + Check { + name: "ec", + check_cmd: "ec {FILES}", + fix_cmd: "", + patterns: "*", + scope: Scope::Files, + }, + Check { + name: "golangci-lint", + check_cmd: "golangci-lint run --new-from-rev={MERGE_BASE}", + fix_cmd: "", + patterns: "*.go", + scope: Scope::Project, + }, + Check { + name: "ruff", + check_cmd: "ruff check {FILE}", + fix_cmd: "ruff check --fix {FILE}", + patterns: "*.py", + scope: Scope::File, + }, + Check { + name: "ruff-format", + check_cmd: "ruff format --check {FILE}", + fix_cmd: "ruff format {FILE}", + patterns: "*.py", + scope: Scope::File, + }, + Check { + name: "biome", + check_cmd: "biome check {FILE}", + fix_cmd: "biome check --fix {FILE}", + patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", + scope: Scope::File, + }, + Check { + name: "biome-format", + check_cmd: "biome format {FILE}", + fix_cmd: "biome format --write {FILE}", + patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", + scope: Scope::File, + }, + ] +} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..932f472 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,207 @@ +use anyhow::Result; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::process::Command; +use tokio::task::JoinSet; + +use crate::files::FileList; +use crate::registry::{Check, Scope}; + +pub async fn run( + checks: &[&Check], + file_list: &FileList, + fix: bool, + project_root: &Path, +) -> Result> { + let mut set: JoinSet<(String, bool)> = JoinSet::new(); + + for &check in checks { + let invocations = build_invocations(check, file_list, fix, project_root); + if invocations.is_empty() { + continue; + } + + let name = check.name.to_string(); + let root = project_root.to_path_buf(); + + set.spawn(async move { + let ok = run_invocations(&name, &invocations, &root).await; + (name, ok) + }); + } + + let mut results = vec![]; + while let Some(res) = set.join_next().await { + results.push(res?); + } + + Ok(results) +} + +/// Returns the list of argv vectors to execute for a check. +fn build_invocations( + check: &Check, + file_list: &FileList, + fix: bool, + project_root: &Path, +) -> Vec> { + let cmd_template = if fix && check.has_fix() { + check.fix_cmd + } else { + check.check_cmd + }; + + match check.scope { + Scope::Project => { + let cmd = substitute_merge_base(cmd_template, file_list.merge_base.as_deref()); + vec![shell_words(cmd)] + } + + Scope::File => { + let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); + let matched = match_files(&file_list.files, &patterns, project_root); + matched + .iter() + .map(|f| { + let cmd = cmd_template.replace("{FILE}", "e_path(f)); + shell_words(cmd) + }) + .collect() + } + + Scope::Files => { + let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); + let matched = match_files(&file_list.files, &patterns, project_root); + if matched.is_empty() { + return vec![]; + } + let files_arg: String = matched + .iter() + .map(|f| quote_path(f)) + .collect::>() + .join(" "); + let cmd = cmd_template.replace("{FILES}", &files_arg); + vec![shell_words(cmd)] + } + } +} + +async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) -> bool { + let mut all_ok = true; + for argv in invocations { + if argv.is_empty() { + continue; + } + let status = Command::new(&argv[0]) + .args(&argv[1..]) + .current_dir(root) + .stdin(Stdio::null()) + .status() + .await; + match status { + Ok(s) if s.success() => {} + Ok(_) => { + all_ok = false; + } + Err(e) => { + eprintln!("flint: {name}: failed to spawn: {e}"); + all_ok = false; + } + } + } + all_ok +} + +fn match_files<'a>( + files: &'a [PathBuf], + patterns: &[&str], + project_root: &Path, +) -> Vec<&'a PathBuf> { + files + .iter() + .filter(|p| { + let rel = p.strip_prefix(project_root).unwrap_or(p); + let rel_str = rel.to_string_lossy(); + let file_name = p + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + patterns.iter().any(|pat| { + if *pat == "*" { + return true; + } + glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) + }) + }) + .collect() +} + +fn glob_match(pattern: &str, name: &str) -> bool { + // Simple glob: splits on `*` and checks that each segment appears in order. + // Handles `*.ext`, `prefix*`, `dir/*.yml`, etc. + let parts: Vec<&str> = pattern.splitn(2, '*').collect(); + match parts.as_slice() { + [only] => name == *only || name.ends_with(&format!("/{only}")), + [prefix, suffix] => { + let n = name; + // The prefix must match the start of the name (or the part after the last slash). + let anchor_start = prefix.is_empty() || n.starts_with(prefix) || { + // Allow matching the basename portion for patterns like `*.sh`. + n.contains('/') && { + let after_slash = n.rfind('/').map(|i| &n[i + 1..]).unwrap_or(n); + prefix.is_empty() || after_slash.starts_with(prefix) + } + }; + anchor_start && n.ends_with(suffix) + } + _ => false, + } +} + +fn substitute_merge_base(cmd: &str, merge_base: Option<&str>) -> String { + if let Some(base) = merge_base { + cmd.replace("{MERGE_BASE}", base) + } else { + // Strip any flag containing {MERGE_BASE} (e.g. --new-from-rev={MERGE_BASE}). + cmd.split_whitespace() + .filter(|tok| !tok.contains("{MERGE_BASE}")) + .collect::>() + .join(" ") + } +} + +fn quote_path(p: &Path) -> String { + let s = p.to_string_lossy(); + // Simple single-quote escaping. + format!("'{}'", s.replace('\'', "'\\''")) +} + +fn shell_words(cmd: String) -> Vec { + // Minimal word-splitting that respects single-quoted strings. + let mut words = vec![]; + let mut current = String::new(); + let mut in_single = false; + let chars: Vec = cmd.chars().collect(); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '\'' if !in_single => { + in_single = true; + } + '\'' if in_single => { + in_single = false; + } + ' ' | '\t' if !in_single => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + } + c => current.push(c), + } + i += 1; + } + if !current.is_empty() { + words.push(current); + } + words +} From b44fd328f16266715de9944a74f337ee7f790bd1 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 14:02:56 +0000 Subject: [PATCH 002/141] feat: add built-in links and renovate-deps special checks (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CheckKind enum to registry to support special checks alongside template-based ones; restructure Check with explicit bin_name field - Add links check: lychee orchestration with diff-mode/full-mode logic, config-change fallback, check_all_local opt-in, and GitHub remap args (same-repo PR → local file paths; fork PR → raw.githubusercontent.com) - Add renovate-deps check: embeds renovate-deps.py via include_str! and runs it as a subprocess; tagged slow=true, supports --fix via AUTOFIX - Add ChecksConfig / LinksConfig / RenovateDepsConfig to config.rs - Parallel runner now captures and defers output; fix mode is serial Signed-off-by: Gregor Zeitlinger --- src/config.rs | 35 ++++- src/files.rs | 1 + src/links.rs | 318 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 69 +++++++++- src/registry.rs | 183 ++++++++++++++++++------- src/renovate_deps.rs | 47 +++++++ src/runner.rs | 159 ++++++++++++++++++---- 7 files changed, 728 insertions(+), 84 deletions(-) create mode 100644 src/links.rs create mode 100644 src/renovate_deps.rs diff --git a/src/config.rs b/src/config.rs index 177b775..7f6701f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,13 +2,14 @@ use anyhow::Result; use serde::Deserialize; use std::path::Path; -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Clone)] #[serde(default)] pub struct Config { pub settings: Settings, + pub checks: ChecksConfig, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] #[serde(default)] pub struct Settings { pub base_branch: String, @@ -24,6 +25,36 @@ impl Default for Settings { } } +#[derive(Debug, Default, Deserialize, Clone)] +#[serde(default)] +pub struct ChecksConfig { + pub links: LinksConfig, + #[serde(rename = "renovate-deps")] + pub renovate_deps: RenovateDepsConfig, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(default)] +pub struct LinksConfig { + pub config: Option, + pub check_all_local: bool, +} + +impl Default for LinksConfig { + fn default() -> Self { + Self { + config: None, + check_all_local: false, + } + } +} + +#[derive(Debug, Default, Deserialize, Clone)] +#[serde(default)] +pub struct RenovateDepsConfig { + pub exclude_managers: Vec, +} + pub fn load(project_root: &Path) -> Result { let path = project_root.join("flint.toml"); if !path.exists() { diff --git a/src/files.rs b/src/files.rs index e3959ec..4d454df 100644 --- a/src/files.rs +++ b/src/files.rs @@ -4,6 +4,7 @@ use std::process::Command; use crate::config::Config; +#[derive(Clone)] pub struct FileList { pub files: Vec, /// The merge base ref, used by project-scoped checks (e.g. golangci-lint). diff --git a/src/links.rs b/src/links.rs new file mode 100644 index 0000000..bdeb356 --- /dev/null +++ b/src/links.rs @@ -0,0 +1,318 @@ +use std::path::Path; +use std::process::Stdio; +use tokio::process::Command; + +use crate::config::LinksConfig; +use crate::files::FileList; + +pub async fn run(cfg: &LinksConfig, file_list: &FileList, project_root: &Path) -> (bool, Vec, Vec) { + let lychee_cfg = cfg + .config + .as_deref() + .unwrap_or(".github/config/lychee.toml") + .to_string(); + + let remap_args = build_remap_args(project_root); + + // Full mode: no merge base (shallow clone or --full flag) + if file_list.merge_base.is_none() { + return run_lychee_cmd("Checking all links in all files", &lychee_cfg, &remap_args, &["."], false).await; + } + + // Check if lychee config is in the changed file list + let config_changed = file_list.files.iter().any(|f| { + let rel = f.strip_prefix(project_root).unwrap_or(f); + rel == Path::new(&lychee_cfg) + }); + + if config_changed { + let mut stderr = b"Config changes detected, falling back to full check.\n".to_vec(); + let (ok, stdout, extra_stderr) = + run_lychee_cmd("Checking all links in all files", &lychee_cfg, &remap_args, &["."], false).await; + stderr.extend_from_slice(&extra_stderr); + return (ok, stdout, stderr); + } + + // Diff mode: filter changed files to link-checkable extensions + let checkable: Vec = file_list + .files + .iter() + .filter(|f| is_link_checkable(f)) + .map(|f| { + f.strip_prefix(project_root) + .unwrap_or(f) + .to_string_lossy() + .into_owned() + }) + .collect(); + + let mut all_ok = true; + let mut combined_stdout = Vec::new(); + let mut combined_stderr = Vec::new(); + + if !checkable.is_empty() { + let file_refs: Vec<&str> = checkable.iter().map(String::as_str).collect(); + let (ok, stdout, stderr) = + run_lychee_cmd("Checking all links in modified files", &lychee_cfg, &remap_args, &file_refs, false).await; + if !ok { + all_ok = false; + } + combined_stdout.extend_from_slice(&stdout); + combined_stderr.extend_from_slice(&stderr); + } else { + combined_stdout.extend_from_slice(b"No modified files to check for all links.\n"); + } + + if cfg.check_all_local { + let (ok, stdout, stderr) = + run_lychee_cmd("Checking local links in all files", &lychee_cfg, &remap_args, &["."], true).await; + if !ok { + all_ok = false; + } + combined_stdout.extend_from_slice(&stdout); + combined_stderr.extend_from_slice(&stderr); + } + + (all_ok, combined_stdout, combined_stderr) +} + +async fn run_lychee_cmd( + description: &str, + lychee_cfg: &str, + remap_args: &[String], + files: &[&str], + local_only: bool, +) -> (bool, Vec, Vec) { + let mut argv: Vec = vec!["lychee".to_string(), "--config".to_string(), lychee_cfg.to_string()]; + + if local_only { + argv.push("--scheme".to_string()); + argv.push("file".to_string()); + argv.push("--include-fragments".to_string()); + } + + argv.extend_from_slice(remap_args); + argv.push("--".to_string()); + argv.extend(files.iter().map(|s| s.to_string())); + + let mut stdout_prefix = format!("==> {description}\n").into_bytes(); + + let result = Command::new(&argv[0]) + .args(&argv[1..]) + .stdin(Stdio::null()) + .output() + .await; + + match result { + Ok(out) => { + stdout_prefix.extend_from_slice(&out.stdout); + let ok = out.status.success(); + (ok, stdout_prefix, out.stderr) + } + Err(e) => { + let stderr = format!("flint: links: failed to spawn lychee: {e}\n").into_bytes(); + (false, stdout_prefix, stderr) + } + } +} + +fn build_remap_args(project_root: &Path) -> Vec { + if std::env::var("LYCHEE_SKIP_GITHUB_REMAPS").as_deref() == Ok("true") { + return vec![]; + } + + let mut args = build_global_github_args(); + args.extend(build_branch_remap_args(project_root)); + args +} + +fn build_global_github_args() -> Vec { + vec![ + "--remap".to_string(), + r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#L[0-9]+.*$ https://raw.githubusercontent.com/$1/$2/$3".to_string(), + "--remap".to_string(), + r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#:~:text=.*$ https://raw.githubusercontent.com/$1/$2/$3".to_string(), + "--remap".to_string(), + r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*)$ https://raw.githubusercontent.com/$1/$2/$3".to_string(), + "--remap".to_string(), + r"^https://github.com/([^/]+/[^/]+)/(issues|pull)/([0-9]+)#issuecomment-.*$ https://github.com/$1/$2/$3".to_string(), + ] +} + +fn build_branch_remap_args(project_root: &Path) -> Vec { + let repo = match resolve_repo(project_root) { + Some(r) => r, + None => return vec![], + }; + + let base_ref = resolve_base_ref(project_root); + + let head_ref = match resolve_head_ref(project_root) { + Some(r) => r, + None => return vec![], + }; + + // Skip if on the base branch + if head_ref == base_ref { + return vec![]; + } + + let head_repo = std::env::var("PR_HEAD_REPO").unwrap_or_else(|_| repo.clone()); + + let base_url = format!("https://github.com/{repo}"); + + if head_repo == repo { + // Same-repo PR: remap to local file paths + let pwd = project_root.to_string_lossy(); + vec![ + // /blob/ rule 1: line-number anchors + "--remap".to_string(), + format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1"), + // /blob/ rule 2: scroll to text fragments + "--remap".to_string(), + format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ file://{pwd}/$1"), + // /blob/ rule 3: all other blob URLs + "--remap".to_string(), + format!("^{base_url}/blob/{base_ref}/(.*)$ file://{pwd}/$1"), + // /tree/ rule 4: line-number anchors on tree URLs + "--remap".to_string(), + format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1"), + // /tree/ rule 5: non-fragment tree URLs + "--remap".to_string(), + format!("^{base_url}/tree/{base_ref}/(.*)$ file://{pwd}/$1"), + ] + } else { + // Fork PR: remap to raw.githubusercontent.com and github.com head branch + let raw_head = format!("https://raw.githubusercontent.com/{head_repo}/{head_ref}"); + let head_url = format!("https://github.com/{head_repo}"); + vec![ + // /blob/ rule 1: line-number anchors + "--remap".to_string(), + format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ {raw_head}/$1"), + // /blob/ rule 2: scroll to text fragments + "--remap".to_string(), + format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ {raw_head}/$1"), + // /blob/ rule 3: all other blob URLs + "--remap".to_string(), + format!("^{base_url}/blob/{base_ref}/(.*)$ {raw_head}/$1"), + // /tree/ rule 4: line-number anchors on tree URLs + "--remap".to_string(), + format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ {head_url}/tree/{head_ref}/$1"), + // /tree/ rule 5: non-fragment tree URLs + "--remap".to_string(), + format!("^{base_url}/tree/{base_ref}/(.*)$ {head_url}/tree/{head_ref}/$1"), + ] + } +} + +fn resolve_repo(project_root: &Path) -> Option { + if let Ok(repo) = std::env::var("GITHUB_REPOSITORY") { + if !repo.is_empty() { + return Some(repo); + } + } + + let out = std::process::Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .current_dir(project_root) + .output() + .ok()?; + + if !out.status.success() { + return None; + } + + let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); + parse_github_repo(&url) +} + +fn parse_github_repo(url: &str) -> Option { + // HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo + if let Some(rest) = url.strip_prefix("https://github.com/") { + let repo = rest.trim_end_matches(".git"); + if !repo.is_empty() { + return Some(repo.to_string()); + } + } + // SSH: git@github.com:owner/repo.git or git@github.com:owner/repo + if let Some(rest) = url.strip_prefix("git@github.com:") { + let repo = rest.trim_end_matches(".git"); + if !repo.is_empty() { + return Some(repo.to_string()); + } + } + None +} + +fn resolve_base_ref(project_root: &Path) -> String { + if let Ok(base) = std::env::var("GITHUB_BASE_REF") { + if !base.is_empty() { + return base; + } + } + + let out = std::process::Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) + .current_dir(project_root) + .output(); + + if let Ok(out) = out { + if out.status.success() { + let full = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // refs/remotes/origin/main → main + if let Some(branch) = full.rsplit('/').next() { + if !branch.is_empty() { + return branch.to_string(); + } + } + } + } + + "main".to_string() +} + +fn resolve_head_ref(project_root: &Path) -> Option { + if let Ok(head) = std::env::var("GITHUB_HEAD_REF") { + if !head.is_empty() { + return Some(head); + } + } + + let out = std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(project_root) + .output() + .ok()?; + + if !out.status.success() { + return None; + } + + let branch = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if branch.is_empty() { + None + } else { + Some(branch) + } +} + +fn is_link_checkable(path: &Path) -> bool { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + matches!( + ext.as_str(), + "md" | "mkd" + | "mdx" + | "mdown" + | "mdwn" + | "mkdn" + | "mkdown" + | "markdown" + | "html" + | "htm" + | "txt" + ) +} + diff --git a/src/main.rs b/src/main.rs index bf0fe78..1b1c64c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,20 @@ mod config; mod files; +mod links; mod registry; +mod renovate_deps; mod runner; use anyhow::Result; -use clap::Parser; +use clap::{Parser, Subcommand}; #[derive(Parser, Debug)] #[command(name = "flint", about = "mise-native lint orchestrator")] +#[command(args_conflicts_with_subcommands = true)] struct Cli { + #[command(subcommand)] + command: Option, + /// Auto-fix issues instead of checking #[arg(long, env = "AUTOFIX")] fix: bool, @@ -17,6 +23,14 @@ struct Cli { #[arg(long)] full: bool, + /// Skip slow checks + #[arg(long)] + fast: bool, + + /// Show all linter output, not just failures + #[arg(long)] + verbose: bool, + /// Compare changed files from this ref (default: merge base with base branch) #[arg(long)] from_ref: Option, @@ -29,6 +43,12 @@ struct Cli { linters: Vec, } +#[derive(Subcommand, Debug)] +enum SubCommand { + /// List all available checks with their status + List, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -39,10 +59,15 @@ async fn main() -> Result<()> { std::env::set_current_dir(&project_root)?; - let cfg = config::load(&project_root)?; - let registry = registry::builtin(); + if let Some(SubCommand::List) = cli.command { + print_list(®istry); + return Ok(()); + } + + let cfg = config::load(&project_root)?; + // Filter registry to requested linters (or all if none specified). let checks: Vec<®istry::Check> = if cli.linters.is_empty() { registry.iter().collect() @@ -60,10 +85,11 @@ async fn main() -> Result<()> { out }; - // Discover which checks have their tool available in PATH. + // Discover which checks have their tool available in PATH, and apply --fast filter. let active: Vec<®istry::Check> = checks .into_iter() .filter(|c| which::which(c.bin()).is_ok()) + .filter(|c| !cli.fast || !c.slow) .collect(); let file_list = files::changed( @@ -74,7 +100,7 @@ async fn main() -> Result<()> { cli.to_ref.as_deref(), )?; - let results = runner::run(&active, &file_list, cli.fix, &project_root).await?; + let results = runner::run(&active, &file_list, cli.fix, cli.verbose, &project_root, &cfg).await?; let mut failed = false; for (name, ok) in &results { @@ -95,3 +121,36 @@ async fn main() -> Result<()> { Ok(()) } + +fn print_list(registry: &[registry::Check]) { + // Column widths. + let name_w = registry.iter().map(|c| c.name.len()).max().unwrap_or(4).max(4); + let bin_w = registry.iter().map(|c| c.bin().len()).max().unwrap_or(6).max(6); + + println!( + "{: &str { - self.check_cmd - .split_whitespace() - .next() - .unwrap_or(self.name) + self.bin_name } pub fn has_fix(&self) -> bool { - !self.fix_cmd.is_empty() + match &self.kind { + CheckKind::Template { fix_cmd, .. } => !fix_cmd.is_empty(), + CheckKind::Special(SpecialKind::Links) => false, + CheckKind::Special(SpecialKind::RenovateDeps) => true, + } } } @@ -39,94 +56,160 @@ pub fn builtin() -> Vec { vec![ Check { name: "shellcheck", - check_cmd: "shellcheck {FILE}", - fix_cmd: "", + bin_name: "shellcheck", patterns: "*.sh *.bash *.bats", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "shellcheck {FILE}", + fix_cmd: "", + scope: Scope::File, + }, }, Check { name: "shfmt", - check_cmd: "shfmt -d {FILE}", - fix_cmd: "shfmt -w {FILE}", + bin_name: "shfmt", patterns: "*.sh *.bash", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "shfmt -d {FILE}", + fix_cmd: "shfmt -w {FILE}", + scope: Scope::File, + }, }, Check { name: "markdownlint", - check_cmd: "markdownlint {FILE}", - fix_cmd: "markdownlint --fix {FILE}", + bin_name: "markdownlint", patterns: "*.md", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "markdownlint {FILE}", + fix_cmd: "markdownlint --fix {FILE}", + scope: Scope::File, + }, }, Check { name: "prettier", - check_cmd: "prettier --check {FILES}", - fix_cmd: "prettier --write {FILES}", + bin_name: "prettier", patterns: "*.md *.json *.yml *.yaml", - scope: Scope::Files, + slow: false, + kind: CheckKind::Template { + check_cmd: "prettier --check {FILES}", + fix_cmd: "prettier --write {FILES}", + scope: Scope::Files, + }, }, Check { name: "actionlint", - check_cmd: "actionlint {FILE}", - fix_cmd: "", + bin_name: "actionlint", patterns: ".github/workflows/*.yml .github/workflows/*.yaml", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "actionlint {FILE}", + fix_cmd: "", + scope: Scope::File, + }, }, Check { name: "hadolint", - check_cmd: "hadolint {FILE}", - fix_cmd: "", + bin_name: "hadolint", patterns: "Dockerfile Dockerfile.* *.dockerfile", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "hadolint {FILE}", + fix_cmd: "", + scope: Scope::File, + }, }, Check { name: "codespell", - check_cmd: "codespell {FILES}", - fix_cmd: "codespell --write-changes {FILES}", + bin_name: "codespell", patterns: "*", - scope: Scope::Files, + slow: false, + kind: CheckKind::Template { + check_cmd: "codespell {FILES}", + fix_cmd: "codespell --write-changes {FILES}", + scope: Scope::Files, + }, }, Check { name: "ec", - check_cmd: "ec {FILES}", - fix_cmd: "", + bin_name: "ec", patterns: "*", - scope: Scope::Files, + slow: false, + kind: CheckKind::Template { + check_cmd: "ec {FILES}", + fix_cmd: "", + scope: Scope::Files, + }, }, Check { name: "golangci-lint", - check_cmd: "golangci-lint run --new-from-rev={MERGE_BASE}", - fix_cmd: "", + bin_name: "golangci-lint", patterns: "*.go", - scope: Scope::Project, + slow: false, + kind: CheckKind::Template { + check_cmd: "golangci-lint run --new-from-rev={MERGE_BASE}", + fix_cmd: "", + scope: Scope::Project, + }, }, Check { name: "ruff", - check_cmd: "ruff check {FILE}", - fix_cmd: "ruff check --fix {FILE}", + bin_name: "ruff", patterns: "*.py", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "ruff check {FILE}", + fix_cmd: "ruff check --fix {FILE}", + scope: Scope::File, + }, }, Check { name: "ruff-format", - check_cmd: "ruff format --check {FILE}", - fix_cmd: "ruff format {FILE}", + bin_name: "ruff", patterns: "*.py", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "ruff format --check {FILE}", + fix_cmd: "ruff format {FILE}", + scope: Scope::File, + }, }, Check { name: "biome", - check_cmd: "biome check {FILE}", - fix_cmd: "biome check --fix {FILE}", + bin_name: "biome", patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "biome check {FILE}", + fix_cmd: "biome check --fix {FILE}", + scope: Scope::File, + }, }, Check { name: "biome-format", - check_cmd: "biome format {FILE}", - fix_cmd: "biome format --write {FILE}", + bin_name: "biome", patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", - scope: Scope::File, + slow: false, + kind: CheckKind::Template { + check_cmd: "biome format {FILE}", + fix_cmd: "biome format --write {FILE}", + scope: Scope::File, + }, + }, + Check { + name: "links", + bin_name: "lychee", + patterns: "", + slow: false, + kind: CheckKind::Special(SpecialKind::Links), + }, + Check { + name: "renovate-deps", + bin_name: "renovate", + patterns: "", + slow: true, + kind: CheckKind::Special(SpecialKind::RenovateDeps), }, ] } diff --git a/src/renovate_deps.rs b/src/renovate_deps.rs new file mode 100644 index 0000000..dd386ba --- /dev/null +++ b/src/renovate_deps.rs @@ -0,0 +1,47 @@ +use std::path::Path; +use std::process::Stdio; +use tokio::process::Command; + +use crate::config::RenovateDepsConfig; + +const SCRIPT: &str = include_str!("../tasks/lint/renovate-deps.py"); + +pub async fn run(cfg: &RenovateDepsConfig, fix: bool, project_root: &Path) -> (bool, Vec, Vec) { + let pid = std::process::id(); + let tmp_path = format!("/tmp/flint-renovate-deps-{pid}.py"); + + if let Err(e) = std::fs::write(&tmp_path, SCRIPT) { + let stderr = format!("flint: renovate-deps: failed to write temp script: {e}\n").into_bytes(); + return (false, vec![], stderr); + } + + let mut cmd = Command::new("python3"); + cmd.arg(&tmp_path) + .current_dir(project_root) + .stdin(Stdio::null()) + .env("MISE_PROJECT_ROOT", project_root); + + if fix { + cmd.env("AUTOFIX", "true"); + } + + if !cfg.exclude_managers.is_empty() { + cmd.env("RENOVATE_TRACKED_DEPS_EXCLUDE", cfg.exclude_managers.join(",")); + } + + let result = cmd.output().await; + + // Remove temp file regardless of outcome + let _ = std::fs::remove_file(&tmp_path); + + match result { + Ok(out) => { + let ok = out.status.success(); + (ok, out.stdout, out.stderr) + } + Err(e) => { + let stderr = format!("flint: renovate-deps: failed to spawn python3: {e}\n").into_bytes(); + (false, vec![], stderr) + } + } +} diff --git a/src/runner.rs b/src/runner.rs index 932f472..7c9caaa 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -4,38 +4,116 @@ use std::process::Stdio; use tokio::process::Command; use tokio::task::JoinSet; +use crate::config::Config; use crate::files::FileList; -use crate::registry::{Check, Scope}; +use crate::registry::{Check, CheckKind, Scope, SpecialKind}; +use crate::{links, renovate_deps}; pub async fn run( checks: &[&Check], file_list: &FileList, fix: bool, + verbose: bool, project_root: &Path, + cfg: &Config, ) -> Result> { - let mut set: JoinSet<(String, bool)> = JoinSet::new(); + if fix { + // Serial execution in fix mode: print each check's output immediately as it finishes. + let mut results = vec![]; + for &check in checks { + let check_name = check.name.to_string(); + let (ok, stdout, stderr) = match &check.kind { + CheckKind::Template { .. } => { + let invocations = build_invocations(check, file_list, fix, project_root); + if invocations.is_empty() { + continue; + } + run_invocations(&check_name, &invocations, project_root).await + } + CheckKind::Special(SpecialKind::Links) => { + links::run(&cfg.checks.links, file_list, project_root).await + } + CheckKind::Special(SpecialKind::RenovateDeps) => { + renovate_deps::run(&cfg.checks.renovate_deps, fix, project_root).await + } + }; + if verbose || !ok { + flush_output(&stdout, &stderr); + } + results.push((check_name, ok)); + } + return Ok(results); + } + + // Parallel execution in check mode. + let mut set: JoinSet<(String, bool, Vec, Vec)> = JoinSet::new(); for &check in checks { - let invocations = build_invocations(check, file_list, fix, project_root); - if invocations.is_empty() { - continue; - } + let check_name = check.name.to_string(); + + match &check.kind { + CheckKind::Template { .. } => { + let invocations = build_invocations(check, file_list, fix, project_root); + if invocations.is_empty() { + continue; + } - let name = check.name.to_string(); - let root = project_root.to_path_buf(); + let root = project_root.to_path_buf(); + let name = check_name.clone(); + + set.spawn(async move { + let (ok, stdout, stderr) = run_invocations(&name, &invocations, &root).await; + if verbose { + flush_output(&stdout, &stderr); + } + (name, ok, stdout, stderr) + }); + } + CheckKind::Special(SpecialKind::Links) => { + let links_cfg = cfg.checks.links.clone(); + let fl = file_list.clone(); + let root = project_root.to_path_buf(); + let name = check_name.clone(); + + set.spawn(async move { + let (ok, stdout, stderr) = links::run(&links_cfg, &fl, &root).await; + if verbose { + flush_output(&stdout, &stderr); + } + (name, ok, stdout, stderr) + }); + } + CheckKind::Special(SpecialKind::RenovateDeps) => { + let renov_cfg = cfg.checks.renovate_deps.clone(); + let root = project_root.to_path_buf(); + let name = check_name.clone(); - set.spawn(async move { - let ok = run_invocations(&name, &invocations, &root).await; - (name, ok) - }); + set.spawn(async move { + let (ok, stdout, stderr) = renovate_deps::run(&renov_cfg, false, &root).await; + if verbose { + flush_output(&stdout, &stderr); + } + (name, ok, stdout, stderr) + }); + } + } } - let mut results = vec![]; + // Collect all results before printing in quiet mode to avoid interleaved output. + let mut collected = vec![]; while let Some(res) = set.join_next().await { - results.push(res?); + collected.push(res?); + } + + if !verbose { + for (_, ok, stdout, stderr) in &collected { + if !ok { + flush_output(stdout, stderr); + } + } } - Ok(results) + Ok(collected.into_iter().map(|(name, ok, _, _)| (name, ok)).collect()) } /// Returns the list of argv vectors to execute for a check. @@ -45,13 +123,17 @@ fn build_invocations( fix: bool, project_root: &Path, ) -> Vec> { + let CheckKind::Template { check_cmd, fix_cmd, scope } = &check.kind else { + return vec![]; + }; + let cmd_template = if fix && check.has_fix() { - check.fix_cmd + fix_cmd } else { - check.check_cmd + check_cmd }; - match check.scope { + match scope { Scope::Project => { let cmd = substitute_merge_base(cmd_template, file_list.merge_base.as_deref()); vec![shell_words(cmd)] @@ -86,30 +168,53 @@ fn build_invocations( } } -async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) -> bool { +/// Runs all invocations for one check, returning (ok, stdout, stderr). +/// Never prints — callers decide when and whether to flush output. +async fn run_invocations( + name: &str, + invocations: &[Vec], + root: &Path, +) -> (bool, Vec, Vec) { let mut all_ok = true; + let mut combined_stdout = Vec::new(); + let mut combined_stderr = Vec::new(); + for argv in invocations { if argv.is_empty() { continue; } - let status = Command::new(&argv[0]) + let result = Command::new(&argv[0]) .args(&argv[1..]) .current_dir(root) .stdin(Stdio::null()) - .status() + .output() .await; - match status { - Ok(s) if s.success() => {} - Ok(_) => { - all_ok = false; + match result { + Ok(out) => { + combined_stdout.extend_from_slice(&out.stdout); + combined_stderr.extend_from_slice(&out.stderr); + if !out.status.success() { + all_ok = false; + } } Err(e) => { - eprintln!("flint: {name}: failed to spawn: {e}"); + combined_stderr + .extend_from_slice(format!("flint: {name}: failed to spawn: {e}\n").as_bytes()); all_ok = false; } } } - all_ok + + (all_ok, combined_stdout, combined_stderr) +} + +fn flush_output(stdout: &[u8], stderr: &[u8]) { + if !stdout.is_empty() { + print!("{}", String::from_utf8_lossy(stdout)); + } + if !stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(stderr)); + } } fn match_files<'a>( From 5eb5c27d1ca9c90d9dc73d2e5af12adc11b3b3d3 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 14:47:40 +0000 Subject: [PATCH 003/141] feat: dogfood flint for linting flint itself (M4) - Add textlint, cargo-clippy, cargo-fmt to built-in registry - Fix Project-scope checks to respect patterns: skip when no matching files present (prevents cargo-clippy running in non-Rust repos) - Add flint.toml for self-config (links, renovate-deps, excludes) - Wire mise lint/fix/native-lint tasks to build-then-run local binary - Add linting tools to [tools] so mise installs them for CI - Remove bash-backed lint tasks (scripts stay until M5) - Fix clippy and fmt issues caught by dogfooding Signed-off-by: Gregor Zeitlinger --- flint.toml | 10 +++++ mise.toml | 58 +++++++++------------------ src/config.rs | 11 +----- src/links.rs | 94 +++++++++++++++++++++++++++++--------------- src/main.rs | 31 ++++++++++++--- src/registry.rs | 33 ++++++++++++++++ src/renovate_deps.rs | 17 ++++++-- src/runner.rs | 19 ++++++++- 8 files changed, 181 insertions(+), 92 deletions(-) create mode 100644 flint.toml diff --git a/flint.toml b/flint.toml new file mode 100644 index 0000000..7ebb016 --- /dev/null +++ b/flint.toml @@ -0,0 +1,10 @@ +[settings] +base_branch = "main" +exclude = "CHANGELOG\\.md|\\.github/renovate-tracked-deps\\.json" + +[checks.links] +config = ".github/config/lychee.toml" +check_all_local = true + +[checks.renovate-deps] +exclude_managers = ["github-actions", "github-runners"] diff --git a/mise.toml b/mise.toml index 01357d4..2a49bd4 100644 --- a/mise.toml +++ b/mise.toml @@ -2,17 +2,22 @@ lychee = "0.22.0" node = "24.14.1" "npm:renovate" = "43.92.1" +shellcheck = "v0.11.0" +shfmt = "v3.12.0" +actionlint = "1.7.10" +editorconfig-checker = "v3.6.1" +"npm:markdownlint-cli" = "0.47.0" +"npm:prettier" = "3.8.1" +"npm:@biomejs/biome" = "2.3.14" +"npm:textlint" = "15.5.1" +"npm:textlint-rule-terminology" = "5.2.16" +"pipx:ruff" = "0.15.0" +"pipx:codespell" = "2.4.1" [env] -RENOVATE_TRACKED_DEPS_EXCLUDE="github-actions,github-runners" # renovate: datasource=docker depName=ghcr.io/super-linter/super-linter SUPER_LINTER_VERSION="slim-v8.5.0@sha256:857dcc3f0bf5dd065fdeed1ace63394bb2004238a5ef02910ea23d9bcd8fd2b8" -# Dogfood our own lint tasks - use local file paths -[tasks."lint:super-linter"] -description = "Run Super-Linter on the repository" -file = "tasks/lint/super-linter.sh" - [tasks."setup:native-lint-tools"] description = "Install native lint tools matching the pinned super-linter version" file = "tasks/setup/native-lint-tools.sh" @@ -21,38 +26,17 @@ file = "tasks/setup/native-lint-tools.sh" description = "Generate super-linter version mapping from the super-linter repo" file = "tasks/setup/update-super-linter-versions.sh" -[tasks."lint:links"] -description = "Check for broken links in changed files + all local links" -file = "tasks/lint/links.sh" - -[tasks."lint:renovate-deps"] -description = "Verify renovate-tracked-deps.json is up to date" -file = "tasks/lint/renovate-deps.py" - -[tasks."lint:fast"] -description = "Run fast lints (no Renovate)" -depends = ["lint:super-linter", "lint:links"] - -[tasks."lint"] +[tasks.lint] description = "Run all lints" -depends = ["lint:fast", "lint:renovate-deps"] +run = "cargo build -q && ./target/debug/flint" [tasks.fix] -description = "Auto-fix lint issues and regenerate tracked deps" -run = "AUTOFIX=true mise run lint" +description = "Auto-fix lint issues" +run = "cargo build -q && ./target/debug/flint --fix" [tasks.native-lint] -description = "Run lints natively (no container)" -depends = ["lint:rust", "check-fmt"] - -[tasks.pre-commit] -description = "Pre-commit hook: native lint" -depends = ["setup:native-lint-tools"] -run = "NATIVE=true mise run lint:fast" - -[tasks."setup:pre-commit-hook"] -description = "Install git pre-commit hook that runs native linting" -run = "mise generate git-pre-commit --write --task=pre-commit" +description = "Run lints natively (fast, no renovate)" +run = "cargo build -q && ./target/debug/flint --fast" # Rust tasks [tasks.build] @@ -61,13 +45,7 @@ run = "cargo build" [tasks."lint:rust"] description = "Lint Rust code (clippy)" -run = """ -if [ "${AUTOFIX:-}" = "true" ]; then - cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings -else - cargo clippy -q -- -D warnings -fi -""" +run = "cargo clippy -q -- -D warnings" [tasks.fmt] description = "Format Rust code" diff --git a/src/config.rs b/src/config.rs index 7f6701f..7d0cef6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,22 +33,13 @@ pub struct ChecksConfig { pub renovate_deps: RenovateDepsConfig, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Default, Deserialize, Clone)] #[serde(default)] pub struct LinksConfig { pub config: Option, pub check_all_local: bool, } -impl Default for LinksConfig { - fn default() -> Self { - Self { - config: None, - check_all_local: false, - } - } -} - #[derive(Debug, Default, Deserialize, Clone)] #[serde(default)] pub struct RenovateDepsConfig { diff --git a/src/links.rs b/src/links.rs index bdeb356..812978a 100644 --- a/src/links.rs +++ b/src/links.rs @@ -5,7 +5,11 @@ use tokio::process::Command; use crate::config::LinksConfig; use crate::files::FileList; -pub async fn run(cfg: &LinksConfig, file_list: &FileList, project_root: &Path) -> (bool, Vec, Vec) { +pub async fn run( + cfg: &LinksConfig, + file_list: &FileList, + project_root: &Path, +) -> (bool, Vec, Vec) { let lychee_cfg = cfg .config .as_deref() @@ -16,7 +20,14 @@ pub async fn run(cfg: &LinksConfig, file_list: &FileList, project_root: &Path) - // Full mode: no merge base (shallow clone or --full flag) if file_list.merge_base.is_none() { - return run_lychee_cmd("Checking all links in all files", &lychee_cfg, &remap_args, &["."], false).await; + return run_lychee_cmd( + "Checking all links in all files", + &lychee_cfg, + &remap_args, + &["."], + false, + ) + .await; } // Check if lychee config is in the changed file list @@ -27,8 +38,14 @@ pub async fn run(cfg: &LinksConfig, file_list: &FileList, project_root: &Path) - if config_changed { let mut stderr = b"Config changes detected, falling back to full check.\n".to_vec(); - let (ok, stdout, extra_stderr) = - run_lychee_cmd("Checking all links in all files", &lychee_cfg, &remap_args, &["."], false).await; + let (ok, stdout, extra_stderr) = run_lychee_cmd( + "Checking all links in all files", + &lychee_cfg, + &remap_args, + &["."], + false, + ) + .await; stderr.extend_from_slice(&extra_stderr); return (ok, stdout, stderr); } @@ -52,8 +69,14 @@ pub async fn run(cfg: &LinksConfig, file_list: &FileList, project_root: &Path) - if !checkable.is_empty() { let file_refs: Vec<&str> = checkable.iter().map(String::as_str).collect(); - let (ok, stdout, stderr) = - run_lychee_cmd("Checking all links in modified files", &lychee_cfg, &remap_args, &file_refs, false).await; + let (ok, stdout, stderr) = run_lychee_cmd( + "Checking all links in modified files", + &lychee_cfg, + &remap_args, + &file_refs, + false, + ) + .await; if !ok { all_ok = false; } @@ -64,8 +87,14 @@ pub async fn run(cfg: &LinksConfig, file_list: &FileList, project_root: &Path) - } if cfg.check_all_local { - let (ok, stdout, stderr) = - run_lychee_cmd("Checking local links in all files", &lychee_cfg, &remap_args, &["."], true).await; + let (ok, stdout, stderr) = run_lychee_cmd( + "Checking local links in all files", + &lychee_cfg, + &remap_args, + &["."], + true, + ) + .await; if !ok { all_ok = false; } @@ -83,7 +112,11 @@ async fn run_lychee_cmd( files: &[&str], local_only: bool, ) -> (bool, Vec, Vec) { - let mut argv: Vec = vec!["lychee".to_string(), "--config".to_string(), lychee_cfg.to_string()]; + let mut argv: Vec = vec![ + "lychee".to_string(), + "--config".to_string(), + lychee_cfg.to_string(), + ]; if local_only { argv.push("--scheme".to_string()); @@ -206,10 +239,10 @@ fn build_branch_remap_args(project_root: &Path) -> Vec { } fn resolve_repo(project_root: &Path) -> Option { - if let Ok(repo) = std::env::var("GITHUB_REPOSITORY") { - if !repo.is_empty() { - return Some(repo); - } + if let Ok(repo) = std::env::var("GITHUB_REPOSITORY") + && !repo.is_empty() + { + return Some(repo); } let out = std::process::Command::new("git") @@ -245,10 +278,10 @@ fn parse_github_repo(url: &str) -> Option { } fn resolve_base_ref(project_root: &Path) -> String { - if let Ok(base) = std::env::var("GITHUB_BASE_REF") { - if !base.is_empty() { - return base; - } + if let Ok(base) = std::env::var("GITHUB_BASE_REF") + && !base.is_empty() + { + return base; } let out = std::process::Command::new("git") @@ -256,15 +289,15 @@ fn resolve_base_ref(project_root: &Path) -> String { .current_dir(project_root) .output(); - if let Ok(out) = out { - if out.status.success() { - let full = String::from_utf8_lossy(&out.stdout).trim().to_string(); - // refs/remotes/origin/main → main - if let Some(branch) = full.rsplit('/').next() { - if !branch.is_empty() { - return branch.to_string(); - } - } + if let Ok(out) = out + && out.status.success() + { + let full = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // refs/remotes/origin/main → main + if let Some(branch) = full.rsplit('/').next() + && !branch.is_empty() + { + return branch.to_string(); } } @@ -272,10 +305,10 @@ fn resolve_base_ref(project_root: &Path) -> String { } fn resolve_head_ref(project_root: &Path) -> Option { - if let Ok(head) = std::env::var("GITHUB_HEAD_REF") { - if !head.is_empty() { - return Some(head); - } + if let Ok(head) = std::env::var("GITHUB_HEAD_REF") + && !head.is_empty() + { + return Some(head); } let out = std::process::Command::new("git") @@ -315,4 +348,3 @@ fn is_link_checkable(path: &Path) -> bool { | "txt" ) } - diff --git a/src/main.rs b/src/main.rs index 1b1c64c..6c9d69a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,7 +100,15 @@ async fn main() -> Result<()> { cli.to_ref.as_deref(), )?; - let results = runner::run(&active, &file_list, cli.fix, cli.verbose, &project_root, &cfg).await?; + let results = runner::run( + &active, + &file_list, + cli.fix, + cli.verbose, + &project_root, + &cfg, + ) + .await?; let mut failed = false; for (name, ok) in &results { @@ -124,12 +132,25 @@ async fn main() -> Result<()> { fn print_list(registry: &[registry::Check]) { // Column widths. - let name_w = registry.iter().map(|c| c.name.len()).max().unwrap_or(4).max(4); - let bin_w = registry.iter().map(|c| c.bin().len()).max().unwrap_or(6).max(6); + let name_w = registry + .iter() + .map(|c| c.name.len()) + .max() + .unwrap_or(4) + .max(4); + let bin_w = registry + .iter() + .map(|c| c.bin().len()) + .max() + .unwrap_or(6) + .max(6); println!( - "{: Vec { scope: Scope::File, }, }, + Check { + name: "textlint", + bin_name: "textlint", + patterns: "*.md *.txt", + slow: false, + kind: CheckKind::Template { + check_cmd: "textlint {FILES}", + fix_cmd: "textlint --fix {FILES}", + scope: Scope::Files, + }, + }, + Check { + name: "cargo-clippy", + bin_name: "cargo-clippy", + patterns: "*.rs", + slow: false, + kind: CheckKind::Template { + check_cmd: "cargo clippy -q -- -D warnings", + fix_cmd: "cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings", + scope: Scope::Project, + }, + }, + Check { + name: "cargo-fmt", + bin_name: "cargo-fmt", + patterns: "*.rs", + slow: false, + kind: CheckKind::Template { + check_cmd: "cargo fmt -- --check", + fix_cmd: "cargo fmt", + scope: Scope::Project, + }, + }, Check { name: "links", bin_name: "lychee", diff --git a/src/renovate_deps.rs b/src/renovate_deps.rs index dd386ba..8031473 100644 --- a/src/renovate_deps.rs +++ b/src/renovate_deps.rs @@ -6,12 +6,17 @@ use crate::config::RenovateDepsConfig; const SCRIPT: &str = include_str!("../tasks/lint/renovate-deps.py"); -pub async fn run(cfg: &RenovateDepsConfig, fix: bool, project_root: &Path) -> (bool, Vec, Vec) { +pub async fn run( + cfg: &RenovateDepsConfig, + fix: bool, + project_root: &Path, +) -> (bool, Vec, Vec) { let pid = std::process::id(); let tmp_path = format!("/tmp/flint-renovate-deps-{pid}.py"); if let Err(e) = std::fs::write(&tmp_path, SCRIPT) { - let stderr = format!("flint: renovate-deps: failed to write temp script: {e}\n").into_bytes(); + let stderr = + format!("flint: renovate-deps: failed to write temp script: {e}\n").into_bytes(); return (false, vec![], stderr); } @@ -26,7 +31,10 @@ pub async fn run(cfg: &RenovateDepsConfig, fix: bool, project_root: &Path) -> (b } if !cfg.exclude_managers.is_empty() { - cmd.env("RENOVATE_TRACKED_DEPS_EXCLUDE", cfg.exclude_managers.join(",")); + cmd.env( + "RENOVATE_TRACKED_DEPS_EXCLUDE", + cfg.exclude_managers.join(","), + ); } let result = cmd.output().await; @@ -40,7 +48,8 @@ pub async fn run(cfg: &RenovateDepsConfig, fix: bool, project_root: &Path) -> (b (ok, out.stdout, out.stderr) } Err(e) => { - let stderr = format!("flint: renovate-deps: failed to spawn python3: {e}\n").into_bytes(); + let stderr = + format!("flint: renovate-deps: failed to spawn python3: {e}\n").into_bytes(); (false, vec![], stderr) } } diff --git a/src/runner.rs b/src/runner.rs index 7c9caaa..53f95ab 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -113,7 +113,10 @@ pub async fn run( } } - Ok(collected.into_iter().map(|(name, ok, _, _)| (name, ok)).collect()) + Ok(collected + .into_iter() + .map(|(name, ok, _, _)| (name, ok)) + .collect()) } /// Returns the list of argv vectors to execute for a check. @@ -123,7 +126,12 @@ fn build_invocations( fix: bool, project_root: &Path, ) -> Vec> { - let CheckKind::Template { check_cmd, fix_cmd, scope } = &check.kind else { + let CheckKind::Template { + check_cmd, + fix_cmd, + scope, + } = &check.kind + else { return vec![]; }; @@ -135,6 +143,13 @@ fn build_invocations( match scope { Scope::Project => { + // If patterns are set, only run when relevant files are present. + if !check.patterns.is_empty() { + let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); + if match_files(&file_list.files, &patterns, project_root).is_empty() { + return vec![]; + } + } let cmd = substitute_merge_base(cmd_template, file_list.merge_base.as_deref()); vec![shell_words(cmd)] } From f822b5d08a88263aedc02d9d49b9659b5aeca854 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 14:58:43 +0000 Subject: [PATCH 004/141] test: add unit tests for project-scope pattern filtering Signed-off-by: Gregor Zeitlinger --- src/runner.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/runner.rs b/src/runner.rs index 53f95ab..c4b8710 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -325,3 +325,58 @@ fn shell_words(cmd: String) -> Vec { } words } + +#[cfg(test)] +mod tests { + use super::*; + use crate::files::FileList; + use crate::registry::{Check, CheckKind, Scope}; + use std::path::PathBuf; + + fn project_check(patterns: &'static str) -> Check { + Check { + name: "test", + bin_name: "test-bin", + patterns, + slow: false, + kind: CheckKind::Template { + check_cmd: "run-it", + fix_cmd: "", + scope: Scope::Project, + }, + } + } + + fn file_list(paths: &[&str]) -> FileList { + FileList { + files: paths + .iter() + .map(|s| PathBuf::from(format!("/repo/{s}"))) + .collect(), + merge_base: Some("abc123".to_string()), + } + } + + #[test] + fn project_scope_skips_when_no_matching_files() { + let check = project_check("*.rs"); + let fl = file_list(&["foo.py", "bar.md"]); + assert!(build_invocations(&check, &fl, false, Path::new("/repo")).is_empty()); + } + + #[test] + fn project_scope_runs_when_matching_files_present() { + let check = project_check("*.rs"); + let fl = file_list(&["src/main.rs", "foo.py"]); + let inv = build_invocations(&check, &fl, false, Path::new("/repo")); + assert_eq!(inv, vec![vec!["run-it".to_string()]]); + } + + #[test] + fn project_scope_empty_patterns_always_runs() { + let check = project_check(""); + let fl = file_list(&["foo.py"]); + let inv = build_invocations(&check, &fl, false, Path::new("/repo")); + assert_eq!(inv, vec![vec!["run-it".to_string()]]); + } +} From b99606e17f021f1460d049f4c833d5b90270f6aa Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 15:48:30 +0000 Subject: [PATCH 005/141] chore: update renovate-tracked-deps for Cargo.toml and new mise tools Signed-off-by: Gregor Zeitlinger --- .github/renovate-tracked-deps.json | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 212d103..b36f04b 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -4,6 +4,17 @@ "mise" ] }, + "Cargo.toml": { + "cargo": [ + "anyhow", + "clap", + "regex", + "serde", + "tokio", + "toml", + "which" + ] + }, "README.md": { "regex": [ "grafana/flint" @@ -11,9 +22,20 @@ }, "mise.toml": { "mise": [ + "actionlint", + "editorconfig-checker", "lychee", "node", - "npm:renovate" + "npm:@biomejs/biome", + "npm:markdownlint-cli", + "npm:prettier", + "npm:renovate", + "npm:textlint", + "npm:textlint-rule-terminology", + "pipx:codespell", + "pipx:ruff", + "shellcheck", + "shfmt" ], "regex": [ "ghcr.io/super-linter/super-linter" From b6929da7b4bf775e1053e7c7f9b8b7b57afc6877 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 16:16:36 +0000 Subject: [PATCH 006/141] feat: prefix failed check output with [check-name] header Signed-off-by: Gregor Zeitlinger --- src/runner.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runner.rs b/src/runner.rs index c4b8710..cdcc6ff 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -38,6 +38,7 @@ pub async fn run( } }; if verbose || !ok { + eprintln!("[{check_name}]"); flush_output(&stdout, &stderr); } results.push((check_name, ok)); @@ -106,8 +107,9 @@ pub async fn run( } if !verbose { - for (_, ok, stdout, stderr) in &collected { + for (name, ok, stdout, stderr) in &collected { if !ok { + eprintln!("[{name}]"); flush_output(stdout, stderr); } } From bf932908629d539ce0dbe7cc9289af7dda3d6367 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 16:19:02 +0000 Subject: [PATCH 007/141] test: add e2e tests for shellcheck output format Signed-off-by: Gregor Zeitlinger --- Cargo.lock | 269 ++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 + tests/e2e.rs | 89 +++++++++++++++++ 3 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 tests/e2e.rs diff --git a/Cargo.lock b/Cargo.lock index b8519ac..6eef937 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,6 +159,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "flint" version = "0.1.0" @@ -167,11 +173,40 @@ dependencies = [ "clap", "regex", "serde", + "tempfile", "tokio", "toml", "which", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -184,6 +219,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.13.0" @@ -191,7 +232,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -200,6 +243,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.184" @@ -221,6 +276,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" version = "2.8.0" @@ -238,6 +299,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -273,6 +340,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -291,6 +368,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -348,6 +431,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -378,6 +467,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -430,6 +532,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tokio" version = "1.50.0" @@ -503,6 +618,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -515,6 +636,58 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "which" version = "7.0.3" @@ -553,3 +726,97 @@ name = "winsafe" version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index b0be4c5..510de38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ toml = "1.0" tokio = { version = "1", features = ["full"] } which = "7" regex = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..995f651 --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,89 @@ +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::TempDir; + +/// Runs the flint binary in the given directory with the given args. +fn flint(args: &[&str], cwd: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_flint")) + .args(args) + .env("MISE_PROJECT_ROOT", cwd) + .current_dir(cwd) + .output() + .expect("failed to spawn flint") +} + +/// Creates a temp directory initialised as a git repo. +fn git_repo() -> TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + for args in [ + vec!["init"], + vec!["config", "user.email", "test@test.com"], + vec!["config", "user.name", "Test"], + ] { + Command::new("git") + .args(&args) + .current_dir(dir.path()) + .output() + .expect("git failed"); + } + dir +} + +// Helper to stage a file so it appears in `git ls-files` (used by --full). +fn stage(path: &Path, content: &str, repo: &Path) { + std::fs::write(path, content).unwrap(); + Command::new("git") + .args(["add", path.to_str().unwrap()]) + .current_dir(repo) + .output() + .expect("git add failed"); +} + +#[test] +fn shellcheck_failure_shows_check_name_header() { + let repo = git_repo(); + + // SC2086: unquoted variable — reliable shellcheck violation. + stage( + &repo.path().join("bad.sh"), + "#!/bin/bash\necho $1\n", + repo.path(), + ); + + let out = flint(&["--full", "shellcheck"], repo.path()); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + + println!("=== stdout ===\n{stdout}"); + eprintln!("=== stderr ===\n{stderr}"); + + assert!(!out.status.success(), "flint should fail"); + assert!( + stderr.contains("[shellcheck]"), + "expected [shellcheck] header, got:\n{stderr}" + ); +} + +#[test] +fn shellcheck_clean_script_passes() { + let repo = git_repo(); + + // A well-formed shell script — no violations. + stage( + &repo.path().join("good.sh"), + "#!/bin/bash\necho \"$1\"\n", + repo.path(), + ); + + let out = flint(&["--full", "shellcheck"], repo.path()); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + + println!("=== stdout ===\n{stdout}"); + eprintln!("=== stderr ===\n{stderr}"); + + assert!( + out.status.success(), + "flint should pass, got:\n{stderr}" + ); +} From 25f9191cd4e5cb0571c55e9dded513d3e5fd3982 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 16:22:55 +0000 Subject: [PATCH 008/141] test: add e2e test for cargo-fmt diff output format Signed-off-by: Gregor Zeitlinger --- tests/e2e.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/e2e.rs b/tests/e2e.rs index 995f651..7e412ae 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -64,6 +64,39 @@ fn shellcheck_failure_shows_check_name_header() { ); } +#[test] +fn cargo_fmt_diff_shows_check_name_header() { + let repo = git_repo(); + + // Minimal Cargo project with a badly formatted Rust file. + std::fs::write( + repo.path().join("Cargo.toml"), + "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + let src = repo.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + // Poorly formatted: fields on one line, which rustfmt will expand. + stage( + &src.join("lib.rs"), + "pub struct Foo { pub a: u32, pub b: u32 }\n", + repo.path(), + ); + + let out = flint(&["--full", "cargo-fmt"], repo.path()); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + + println!("=== stdout ===\n{stdout}"); + eprintln!("=== stderr ===\n{stderr}"); + + assert!(!out.status.success(), "flint should fail"); + assert!( + stderr.contains("[cargo-fmt]"), + "expected [cargo-fmt] header, got:\n{stderr}" + ); +} + #[test] fn shellcheck_clean_script_passes() { let repo = git_repo(); From 8c878216fde4a85d2a501e7c1bf822858f08e927 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 16:40:02 +0000 Subject: [PATCH 009/141] chore: exclude cargo manager from renovate-deps linter Signed-off-by: Gregor Zeitlinger --- .github/renovate-tracked-deps.json | 11 ----------- flint.toml | 2 +- tests/e2e.rs | 5 +---- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index b36f04b..53dba1c 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -4,17 +4,6 @@ "mise" ] }, - "Cargo.toml": { - "cargo": [ - "anyhow", - "clap", - "regex", - "serde", - "tokio", - "toml", - "which" - ] - }, "README.md": { "regex": [ "grafana/flint" diff --git a/flint.toml b/flint.toml index 7ebb016..6587884 100644 --- a/flint.toml +++ b/flint.toml @@ -7,4 +7,4 @@ config = ".github/config/lychee.toml" check_all_local = true [checks.renovate-deps] -exclude_managers = ["github-actions", "github-runners"] +exclude_managers = ["github-actions", "github-runners", "cargo"] diff --git a/tests/e2e.rs b/tests/e2e.rs index 7e412ae..59ae249 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -115,8 +115,5 @@ fn shellcheck_clean_script_passes() { println!("=== stdout ===\n{stdout}"); eprintln!("=== stderr ===\n{stderr}"); - assert!( - out.status.success(), - "flint should pass, got:\n{stderr}" - ); + assert!(out.status.success(), "flint should pass, got:\n{stderr}"); } From 145084834ef9afffa4b71184c6e71e140456d8c1 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 16:48:58 +0000 Subject: [PATCH 010/141] =?UTF-8?q?docs:=20add=20FLINT-V2.md=20=E2=80=94?= =?UTF-8?q?=20usage=20guide=20for=20the=20Rust=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gregor Zeitlinger --- FLINT-V2.md | 220 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 FLINT-V2.md diff --git a/FLINT-V2.md b/FLINT-V2.md new file mode 100644 index 0000000..df5c637 --- /dev/null +++ b/FLINT-V2.md @@ -0,0 +1,220 @@ +# flint v2 + +A single Rust binary that replaces the bash task scripts. +Discovers linting tools from PATH, runs them against changed files in parallel, +and produces identical output locally and in CI. + +> **Status**: in development on the `feat/flint-v2` branch. +> The bash task scripts (v1) remain the stable option until v2 is released. + +## Why + +The bash task scripts (v1) have two problems: + +**Local ≠ CI**: `--native` runs a subset of linters; CI runs full super-linter +in Docker. Different tools, different behavior. Passing locally does not mean +passing in CI. + +**Bash has limits**: the registry pattern was already at the edge of what bash +does cleanly. Adding built-in checks (links, renovate) would make it worse. + +### Why not pre-commit? + +pre-commit adds a parallel tool management system on top of mise. Consuming repos +already declare their tools in `mise.toml` — pre-commit would require maintaining +a second inventory of the same tools in `.pre-commit-config.yaml`, with its own +versioning and install lifecycle. That's friction without benefit for repos that +are already mise-first. + +### Why not MegaLinter / super-linter? + +Container-based linters (super-linter, MegaLinter) ship their own tool versions, +independent of what the repo pins in `mise.toml`. This breaks the "declare once, +use everywhere" promise of mise. Container startup also adds latency to every run. + +## Principles + +1. **mise-based** — `flint` distributed via mise. Tools managed by the consuming + repo's `mise.toml`. No separate tool installation. +2. **Fast** — native execution only (no Docker). Linters run in parallel. +3. **Local same as CI** — one binary, one config, identical behavior. +4. **AI-friendly** — structured output for AI tools; works headless in agentic pipelines. +5. **Opt-in via tool install** — checks auto-enable when their binary is in PATH. + `flint.toml` adds detail but is not required to activate anything. +6. **Changed files by default** — git-aware diff detection. `--from-ref`/`--to-ref` + for CI. `--full` to check everything. +7. **Autofix where possible** — `--fix` flag (or `AUTOFIX=true`). Fix mode runs + serially to avoid concurrent writes to the same file. + +## Installation + +Add `flint` to your repo's `mise.toml` (once published): + +```toml +[tools] +flint = "0.x.y" +``` + +Until the first release, build from source: + +```bash +git clone https://github.com/grafana/flint +cd flint +cargo build --release +# Binary at target/release/flint +``` + +## Usage + +``` +flint [OPTIONS] [LINTERS...] +flint list +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `--fix` | Auto-fix issues instead of checking (also: `AUTOFIX=true`) | +| `--full` | Lint all files instead of only changed files | +| `--fast` | Skip slow checks (e.g. `renovate-deps`) | +| `--verbose` | Show all linter output, not just failures | +| `--from-ref REF` | Changed-files diff base (default: merge base with base branch) | +| `--to-ref REF` | Changed-files diff head (default: HEAD) | + +Pass one or more linter names to run only those: + +```bash +flint shellcheck shfmt # run only shellcheck and shfmt +flint --fix prettier # fix only prettier +``` + +`flint list` shows every check with its status: + +``` +NAME BINARY STATUS SPEED PATTERNS +------------------------------------------------------------------- +shellcheck shellcheck installed fast *.sh *.bash *.bats +cargo-fmt cargo-fmt missing fast *.rs +renovate-deps renovate installed slow +... +``` + +## Config (`flint.toml`) + +Optional. Place in the repo root. All settings have defaults. + +```toml +[settings] +base_branch = "main" # branch to diff against +exclude = "CHANGELOG\\.md|vendor/.*" # regex — exclude matching files + +[checks.links] +config = ".github/config/lychee.toml" # lychee config path +check_all_local = true # second pass: local links in all files + +[checks.renovate-deps] +exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers +``` + +## mise.toml wiring + +```toml +[tools] +flint = "0.x.y" + +[tasks.lint] +description = "Run all lints" +run = "flint" + +[tasks."native-lint"] +description = "Run fast lints (skip slow checks)" +run = "flint --fast" + +[tasks.fix] +description = "Auto-fix lint issues" +run = "flint --fix" +``` + +## Built-in linter registry + +Checks auto-enable when their binary is found in PATH. Install tools via `mise.toml`. + +| Name | Binary | Patterns | Fix | Scope | +|------|--------|----------|-----|-------| +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | +| `markdownlint` | `markdownlint` | `*.md` | yes | file | +| `prettier` | `prettier` | `*.md *.json *.yml *.yaml` | yes | files | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | +| `codespell` | `codespell` | `*` | yes | files | +| `ec` | `ec` | `*` | no | files | +| `golangci-lint` | `golangci-lint` | `*.go` | no | project | +| `ruff` | `ruff` | `*.py` | yes | file | +| `ruff-format` | `ruff` | `*.py` | yes | file | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | +| `textlint` | `textlint` | `*.md *.txt` | yes | files | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | +| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | +| `links` | `lychee` | (all files) | no | special | +| `renovate-deps` | `renovate` | (all files) | yes | special | + +**Scopes:** + +- `file` — invoked once per matched file +- `files` — invoked once with all matched files as args +- `project` — invoked once with no file args; for checks with patterns set + (e.g. `cargo-clippy`), skipped entirely if no matching files changed + +**Slow checks** (`renovate-deps`) are skipped by `--fast`. Use `--fast` for +local/pre-push feedback and the full set in CI. + +## Special checks + +### links + +Orchestrates [lychee](https://lychee.cli.rs/) for link checking. Requires +`lychee` in PATH (install via `mise.toml`). + +Default behavior: checks all links in changed files. When `check_all_local = true` +in `flint.toml`, adds a second pass over local links in all files — useful when +broken internal links from unchanged files also matter. + +Configure via `flint.toml`: + +```toml +[checks.links] +config = ".github/config/lychee.toml" +check_all_local = true +``` + +### renovate-deps + +Verifies `.github/renovate-tracked-deps.json` is up to date by running Renovate +locally and comparing its output against the committed snapshot. Same purpose as +the v1 `lint:renovate-deps` task. Requires `renovate` in PATH (install via `mise.toml`). + +Tagged `slow = true` — skipped by `--fast`. With `--fix`, automatically regenerates +and commits the snapshot. + +Configure via `flint.toml`: + +```toml +[checks.renovate-deps] +exclude_managers = ["github-actions", "github-runners"] +``` + +## CI example + +```yaml +- name: Install tools + run: mise install + +- name: Lint + run: mise run lint # or: flint --from-ref origin/main --to-ref HEAD +``` + +`--from-ref`/`--to-ref` is optional in CI — flint detects the merge base +automatically when running in a PR context. From 505b0120032c877b379ec29d08b304812a9741f8 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:21:56 +0000 Subject: [PATCH 011/141] chore: remove textlint and super-linter from dogfood config - Drop npm:textlint and npm:textlint-rule-terminology (mise npm isolation incompatible with textlint plugin architecture; see design doc) - Drop SUPER_LINTER_VERSION and setup:native-lint-tools (superseded by flint binary in M4) - Remove textlint from built-in registry - Add README link to FLINT-V2.md - Regenerate renovate-tracked-deps.json Signed-off-by: Gregor Zeitlinger --- .github/renovate-tracked-deps.json | 5 ----- README.md | 3 +++ mise.toml | 12 +----------- src/registry.rs | 11 ----------- 4 files changed, 4 insertions(+), 27 deletions(-) diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 53dba1c..96a5178 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -19,15 +19,10 @@ "npm:markdownlint-cli", "npm:prettier", "npm:renovate", - "npm:textlint", - "npm:textlint-rule-terminology", "pipx:codespell", "pipx:ruff", "shellcheck", "shfmt" - ], - "regex": [ - "ghcr.io/super-linter/super-linter" ] } } diff --git a/README.md b/README.md index b66bcb7..fe7b8a2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ A toolbox of reusable [mise](https://mise.jdx.dev/) lint task scripts. Pick the ones you need — each task is independent and can be adopted on its own. +> **v2 in development**: a single Rust binary is replacing these bash +> scripts. See [FLINT-V2.md](FLINT-V2.md) for details. + **Available tasks:** | Task | Tool | diff --git a/mise.toml b/mise.toml index 2a49bd4..7f313fb 100644 --- a/mise.toml +++ b/mise.toml @@ -9,19 +9,9 @@ editorconfig-checker = "v3.6.1" "npm:markdownlint-cli" = "0.47.0" "npm:prettier" = "3.8.1" "npm:@biomejs/biome" = "2.3.14" -"npm:textlint" = "15.5.1" -"npm:textlint-rule-terminology" = "5.2.16" "pipx:ruff" = "0.15.0" "pipx:codespell" = "2.4.1" -[env] -# renovate: datasource=docker depName=ghcr.io/super-linter/super-linter -SUPER_LINTER_VERSION="slim-v8.5.0@sha256:857dcc3f0bf5dd065fdeed1ace63394bb2004238a5ef02910ea23d9bcd8fd2b8" - -[tasks."setup:native-lint-tools"] -description = "Install native lint tools matching the pinned super-linter version" -file = "tasks/setup/native-lint-tools.sh" - [tasks."setup:update-super-linter-versions"] description = "Generate super-linter version mapping from the super-linter repo" file = "tasks/setup/update-super-linter-versions.sh" @@ -36,7 +26,7 @@ run = "cargo build -q && ./target/debug/flint --fix" [tasks.native-lint] description = "Run lints natively (fast, no renovate)" -run = "cargo build -q && ./target/debug/flint --fast" +run = "cargo build -q && ./target/debug/flint --fast --short" # Rust tasks [tasks.build] diff --git a/src/registry.rs b/src/registry.rs index 6f449bb..d89edd1 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -197,17 +197,6 @@ pub fn builtin() -> Vec { scope: Scope::File, }, }, - Check { - name: "textlint", - bin_name: "textlint", - patterns: "*.md *.txt", - slow: false, - kind: CheckKind::Template { - check_cmd: "textlint {FILES}", - fix_cmd: "textlint --fix {FILES}", - scope: Scope::Files, - }, - }, Check { name: "cargo-clippy", bin_name: "cargo-clippy", From aaf1c46e1fb80d13216b0fd9e897f8a1024f5878 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:22:03 +0000 Subject: [PATCH 012/141] feat: add --short flag for token-efficient AI output Suppresses per-check output and prints only the summary line: flint: 2 checks failed (shellcheck, prettier) Enabled via --short or FLINT_SHORT=true. Native-lint task now uses --short by default since it is the target called by the CC pre-push hook. Signed-off-by: Gregor Zeitlinger --- FLINT-V2.md | 68 ++++++++++++++++++++++++++++----------------------- src/main.rs | 30 +++++++++++++++-------- src/runner.rs | 5 ++-- 3 files changed, 60 insertions(+), 43 deletions(-) diff --git a/FLINT-V2.md b/FLINT-V2.md index df5c637..906615f 100644 --- a/FLINT-V2.md +++ b/FLINT-V2.md @@ -66,21 +66,24 @@ cargo build --release ## Usage -``` +```text flint [OPTIONS] [LINTERS...] flint list ``` **Options:** -| Flag | Description | -|------|-------------| -| `--fix` | Auto-fix issues instead of checking (also: `AUTOFIX=true`) | -| `--full` | Lint all files instead of only changed files | -| `--fast` | Skip slow checks (e.g. `renovate-deps`) | -| `--verbose` | Show all linter output, not just failures | -| `--from-ref REF` | Changed-files diff base (default: merge base with base branch) | -| `--to-ref REF` | Changed-files diff head (default: HEAD) | +| Flag | Description | +| ---------------- | ------------------------------------------------ | +| `--fix` | Auto-fix issues instead of checking | +| `--full` | Lint all files instead of only changed files | +| `--fast` | Skip slow checks (e.g. `renovate-deps`) | +| `--short` | Suppress per-check output, print summary only | +| `--verbose` | Show all linter output, not just failures | +| `--from-ref REF` | Diff base (default: merge base with base branch) | +| `--to-ref REF` | Diff head (default: HEAD) | + +Env var equivalents: `AUTOFIX=true` for `--fix`, `FLINT_SHORT=true` for `--short`. Pass one or more linter names to run only those: @@ -91,7 +94,7 @@ flint --fix prettier # fix only prettier `flint list` shows every check with its status: -``` +```text NAME BINARY STATUS SPEED PATTERNS ------------------------------------------------------------------- shellcheck shellcheck installed fast *.sh *.bash *.bats @@ -140,26 +143,29 @@ run = "flint --fix" Checks auto-enable when their binary is found in PATH. Install tools via `mise.toml`. -| Name | Binary | Patterns | Fix | Scope | -|------|--------|----------|-----|-------| -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | -| `markdownlint` | `markdownlint` | `*.md` | yes | file | -| `prettier` | `prettier` | `*.md *.json *.yml *.yaml` | yes | files | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | -| `codespell` | `codespell` | `*` | yes | files | -| `ec` | `ec` | `*` | no | files | -| `golangci-lint` | `golangci-lint` | `*.go` | no | project | -| `ruff` | `ruff` | `*.py` | yes | file | -| `ruff-format` | `ruff` | `*.py` | yes | file | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | -| `textlint` | `textlint` | `*.md *.txt` | yes | files | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | -| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | -| `links` | `lychee` | (all files) | no | special | -| `renovate-deps` | `renovate` | (all files) | yes | special | + + +| Name | Binary | Patterns | Fix | Scope | +| --------------- | --------------- | -------------------------------------------------- | --- | ------- | +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | +| `markdownlint` | `markdownlint` | `*.md` | yes | file | +| `prettier` | `prettier` | `*.md *.json *.yml *.yaml` | yes | files | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | +| `codespell` | `codespell` | `*` | yes | files | +| `ec` | `ec` | `*` | no | files | +| `golangci-lint` | `golangci-lint` | `*.go` | no | project | +| `ruff` | `ruff` | `*.py` | yes | file | +| `ruff-format` | `ruff` | `*.py` | yes | file | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | +| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | +| `links` | `lychee` | (all files) | no | special | +| `renovate-deps` | `renovate` | (all files) | yes | special | + + **Scopes:** @@ -213,7 +219,7 @@ exclude_managers = ["github-actions", "github-runners"] run: mise install - name: Lint - run: mise run lint # or: flint --from-ref origin/main --to-ref HEAD + run: mise run lint # or: flint --from-ref origin/main --to-ref HEAD ``` `--from-ref`/`--to-ref` is optional in CI — flint detects the merge base diff --git a/src/main.rs b/src/main.rs index 6c9d69a..08a082c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,10 @@ struct Cli { #[arg(long)] verbose: bool, + /// Suppress per-check output; print only the summary line (useful for AI/token-constrained callers) + #[arg(long, env = "FLINT_SHORT")] + short: bool, + /// Compare changed files from this ref (default: merge base with base branch) #[arg(long)] from_ref: Option, @@ -105,23 +109,29 @@ async fn main() -> Result<()> { &file_list, cli.fix, cli.verbose, + cli.short, &project_root, &cfg, ) .await?; - let mut failed = false; - for (name, ok) in &results { - if !ok { - eprintln!("flint: {name} failed"); - failed = true; - } - } + let failed: Vec<&str> = results + .iter() + .filter(|(_, ok)| !ok) + .map(|(name, _)| name.as_str()) + .collect(); - if failed { - if !cli.fix { + if !failed.is_empty() { + let n = failed.len(); + let noun = if n == 1 { "check" } else { "checks" }; + let prefix = if cli.short { "" } else { "\n" }; + eprintln!( + "{prefix}flint: {n} {noun} failed ({names})", + names = failed.join(", ") + ); + if !cli.fix && !cli.short { eprintln!( - "\nšŸ’” Try `mise run fix` to auto-fix lint issues, then re-run `mise run lint` to verify." + "šŸ’” Try `mise run fix` to auto-fix lint issues, then re-run `mise run lint` to verify." ); } std::process::exit(1); diff --git a/src/runner.rs b/src/runner.rs index cdcc6ff..0bdaaf6 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -14,6 +14,7 @@ pub async fn run( file_list: &FileList, fix: bool, verbose: bool, + short: bool, project_root: &Path, cfg: &Config, ) -> Result> { @@ -37,7 +38,7 @@ pub async fn run( renovate_deps::run(&cfg.checks.renovate_deps, fix, project_root).await } }; - if verbose || !ok { + if !short && (verbose || !ok) { eprintln!("[{check_name}]"); flush_output(&stdout, &stderr); } @@ -106,7 +107,7 @@ pub async fn run( collected.push(res?); } - if !verbose { + if !verbose && !short { for (name, ok, stdout, stderr) in &collected { if !ok { eprintln!("[{name}]"); From 7a9770b2bf18ebbce641e40af1a99bb6a75ab940 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:43:48 +0000 Subject: [PATCH 013/141] feat: improve --short summary to emit actionable fix command In --short mode, partition failed checks by fixability and emit the exact command for fixable ones: `flint --fix prettier shfmt` instead of `fix: prettier, shfmt`. AI callers can execute the command directly without a reasoning step. Non-fixable checks remain under `review:`. Signed-off-by: Gregor Zeitlinger --- FLINT-V2.md | 26 +++++++++++++++++++++----- src/main.rs | 30 +++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/FLINT-V2.md b/FLINT-V2.md index 906615f..db3abbf 100644 --- a/FLINT-V2.md +++ b/FLINT-V2.md @@ -35,16 +35,32 @@ use everywhere" promise of mise. Container startup also adds latency to every ru ## Principles 1. **mise-based** — `flint` distributed via mise. Tools managed by the consuming - repo's `mise.toml`. No separate tool installation. + repo's `mise.toml`. No separate tool installation step. + 2. **Fast** — native execution only (no Docker). Linters run in parallel. + Designed to be the default `mise run lint`, not a slow fallback. + Slow checks (e.g. `renovate-deps`) can be skipped with `--fast`. + 3. **Local same as CI** — one binary, one config, identical behavior. -4. **AI-friendly** — structured output for AI tools; works headless in agentic pipelines. + No "native mode subset" distinction. If it passes locally, it passes in CI. + +4. **AI-friendly** — `--short` suppresses per-check output and emits a single + structured summary line (`flint --fix prettier | review: shellcheck`) for + token-efficient AI consumption. Fixable checks are expressed as the exact + command to run — no reasoning step required. Also runnable containerised — + no host tool dependencies required. + 5. **Opt-in via tool install** — checks auto-enable when their binary is in PATH. - `flint.toml` adds detail but is not required to activate anything. + Installing a tool in `mise.toml` is the opt-in. `flint.toml` adds detail + (config paths, exclusions) but is not required to activate anything. + 6. **Changed files by default** — git-aware diff detection. `--from-ref`/`--to-ref` - for CI. `--full` to check everything. + for CI. `--full` to check everything. Falls back to all files when no merge + base is found. + 7. **Autofix where possible** — `--fix` flag (or `AUTOFIX=true`). Fix mode runs - serially to avoid concurrent writes to the same file. + serially to avoid concurrent writes to the same file. Pass specific linter + names to limit which fixers run (`flint --fix prettier shfmt`). ## Installation diff --git a/src/main.rs b/src/main.rs index 08a082c..544859b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -124,15 +124,31 @@ async fn main() -> Result<()> { if !failed.is_empty() { let n = failed.len(); let noun = if n == 1 { "check" } else { "checks" }; - let prefix = if cli.short { "" } else { "\n" }; - eprintln!( - "{prefix}flint: {n} {noun} failed ({names})", - names = failed.join(", ") - ); - if !cli.fix && !cli.short { + if cli.short { + // Partition by fixability. Emit the exact command for fixable checks + // so AI callers can act without a reasoning step. + let (fixable, reviewable): (Vec<&str>, Vec<&str>) = failed + .iter() + .copied() + .partition(|name| active.iter().any(|c| c.name == *name && c.has_fix())); + let mut segments = vec![]; + if !fixable.is_empty() { + segments.push(format!("flint --fix {}", fixable.join(" "))); + } + if !reviewable.is_empty() { + segments.push(format!("review: {}", reviewable.join(", "))); + } + eprintln!("flint: {n} {noun} failed — {}", segments.join(" | ")); + } else { eprintln!( - "šŸ’” Try `mise run fix` to auto-fix lint issues, then re-run `mise run lint` to verify." + "\nflint: {n} {noun} failed ({names})", + names = failed.join(", ") ); + if !cli.fix { + eprintln!( + "šŸ’” Try `mise run fix` to auto-fix lint issues, then re-run `mise run lint` to verify." + ); + } } std::process::exit(1); } From 27cde78662dae1aee00814e8f69e23539ede4d52 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:43:51 +0000 Subject: [PATCH 014/141] chore: fix pre-existing prettier formatting violations Signed-off-by: Gregor Zeitlinger --- .github/config/.release-please-manifest.json | 2 +- .github/config/release-please-config.json | 16 +-- .markdownlint.json | 2 +- biome.json | 12 +- default.json | 111 +++++++++---------- 5 files changed, 65 insertions(+), 78 deletions(-) diff --git a/.github/config/.release-please-manifest.json b/.github/config/.release-please-manifest.json index 02dba1b..7e08ec6 100644 --- a/.github/config/.release-please-manifest.json +++ b/.github/config/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.9.2" + ".": "0.9.2" } diff --git a/.github/config/release-please-config.json b/.github/config/release-please-config.json index 1f0c4e0..b6c9ea1 100644 --- a/.github/config/release-please-config.json +++ b/.github/config/release-please-config.json @@ -1,10 +1,10 @@ { - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "simple", - "pull-request-footer": "> [!IMPORTANT]\n> Close and reopen this PR to trigger CI checks.", - "packages": { - ".": { - "changelog-path": "CHANGELOG.md" - } - } + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "pull-request-footer": "> [!IMPORTANT]\n> Close and reopen this PR to trigger CI checks.", + "packages": { + ".": { + "changelog-path": "CHANGELOG.md" + } + } } diff --git a/.markdownlint.json b/.markdownlint.json index 87e137e..67d2ae5 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,3 +1,3 @@ { - "MD013": false + "MD013": false } diff --git a/biome.json b/biome.json index 7d64c0a..35373b0 100644 --- a/biome.json +++ b/biome.json @@ -1,10 +1,6 @@ { - "files": { - "ignoreUnknown": true, - "includes": [ - "**", - "!!**/.github/renovate-tracked-deps.json", - "!!**/.claude" - ] - } + "files": { + "ignoreUnknown": true, + "includes": ["**", "!!**/.github/renovate-tracked-deps.json", "!!**/.claude"] + } } diff --git a/default.json b/default.json index 000ef9a..3c095aa 100644 --- a/default.json +++ b/default.json @@ -1,62 +1,53 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "description": "Renovate custom managers for repositories that use flint mise tasks", - "customManagers": [ - { - "customType": "regex", - "description": "Update _VERSION variables in mise.toml", - "managerFilePatterns": ["/^mise\\.toml$/"], - "matchStrings": [ - "# renovate: datasource=(?[a-z-]+?)(?: depName=(?.+?))?(?: packageName=(?.+?))?(?: versioning=(?[a-z-]+?))?\\s.+?_VERSION=\"?(?[^@\"]+?)(?:@(?sha256:[a-f0-9]+))?\"?\\s" - ] - }, - { - "customType": "regex", - "description": "Update raw.githubusercontent.com URLs pinned to SHA with version comment", - "managerFilePatterns": ["/^mise\\.toml$/"], - "matchStrings": [ - "https://raw\\.githubusercontent\\.com/(?[^/]+/[^/]+)/(?[a-f0-9]{40})/.*#\\s*(?v\\S+)" - ], - "datasourceTemplate": "github-tags" - }, - { - "customType": "regex", - "description": "Update mise version in GitHub Actions workflows", - "managerFilePatterns": [ - "/(^|/)(workflow-templates|\\.(?:github|gitea|forgejo)/(?:workflows|actions))/.+\\.ya?ml$/", - "/(^|/)action\\.ya?ml$/" - ], - "datasourceTemplate": "github-release-attachments", - "packageNameTemplate": "jdx/mise", - "depNameTemplate": "mise", - "matchStrings": [ - "jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?" - ] - } - ], - "packageRules": [ - { - "matchPackageNames": ["renovate"], - "description": "Only update renovate once a week", - "schedule": ["before 6am on Monday"] - }, - { - "matchPackageNames": ["jdx/mise"], - "groupName": "mise", - "description": "Only update mise once a week", - "schedule": ["before 4am on Monday"] - }, - { - "matchPackageNames": ["grafana/flint"], - "groupName": "flint", - "description": "Only update flint once a week", - "schedule": ["before 4am on Monday"] - }, - { - "matchPackageNames": ["ghcr.io/super-linter/super-linter"], - "matchCurrentValue": "/^slim-/", - "description": "Use regex versioning for slim Super-Linter tags (slim-v8.4.0)", - "versioning": "regex:^slim-v(?\\d+)\\.(?\\d+)\\.(?\\d+)$" - } - ] + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "description": "Renovate custom managers for repositories that use flint mise tasks", + "customManagers": [ + { + "customType": "regex", + "description": "Update _VERSION variables in mise.toml", + "managerFilePatterns": ["/^mise\\.toml$/"], + "matchStrings": ["# renovate: datasource=(?[a-z-]+?)(?: depName=(?.+?))?(?: packageName=(?.+?))?(?: versioning=(?[a-z-]+?))?\\s.+?_VERSION=\"?(?[^@\"]+?)(?:@(?sha256:[a-f0-9]+))?\"?\\s"] + }, + { + "customType": "regex", + "description": "Update raw.githubusercontent.com URLs pinned to SHA with version comment", + "managerFilePatterns": ["/^mise\\.toml$/"], + "matchStrings": ["https://raw\\.githubusercontent\\.com/(?[^/]+/[^/]+)/(?[a-f0-9]{40})/.*#\\s*(?v\\S+)"], + "datasourceTemplate": "github-tags" + }, + { + "customType": "regex", + "description": "Update mise version in GitHub Actions workflows", + "managerFilePatterns": ["/(^|/)(workflow-templates|\\.(?:github|gitea|forgejo)/(?:workflows|actions))/.+\\.ya?ml$/", "/(^|/)action\\.ya?ml$/"], + "datasourceTemplate": "github-release-attachments", + "packageNameTemplate": "jdx/mise", + "depNameTemplate": "mise", + "matchStrings": ["jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?"] + } + ], + "packageRules": [ + { + "matchPackageNames": ["renovate"], + "description": "Only update renovate once a week", + "schedule": ["before 6am on Monday"] + }, + { + "matchPackageNames": ["jdx/mise"], + "groupName": "mise", + "description": "Only update mise once a week", + "schedule": ["before 4am on Monday"] + }, + { + "matchPackageNames": ["grafana/flint"], + "groupName": "flint", + "description": "Only update flint once a week", + "schedule": ["before 4am on Monday"] + }, + { + "matchPackageNames": ["ghcr.io/super-linter/super-linter"], + "matchCurrentValue": "/^slim-/", + "description": "Use regex versioning for slim Super-Linter tags (slim-v8.4.0)", + "versioning": "regex:^slim-v(?\\d+)\\.(?\\d+)\\.(?\\d+)$" + } + ] } From 696af802da19ff1a07346535c032ba2bfcd4ca85 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:45:48 +0000 Subject: [PATCH 015/141] Revert "chore: fix pre-existing prettier formatting violations" This reverts commit 27cde78662dae1aee00814e8f69e23539ede4d52. --- .github/config/.release-please-manifest.json | 2 +- .github/config/release-please-config.json | 16 +-- .markdownlint.json | 2 +- biome.json | 12 +- default.json | 111 ++++++++++--------- 5 files changed, 78 insertions(+), 65 deletions(-) diff --git a/.github/config/.release-please-manifest.json b/.github/config/.release-please-manifest.json index 7e08ec6..02dba1b 100644 --- a/.github/config/.release-please-manifest.json +++ b/.github/config/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.9.2" + ".": "0.9.2" } diff --git a/.github/config/release-please-config.json b/.github/config/release-please-config.json index b6c9ea1..1f0c4e0 100644 --- a/.github/config/release-please-config.json +++ b/.github/config/release-please-config.json @@ -1,10 +1,10 @@ { - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "simple", - "pull-request-footer": "> [!IMPORTANT]\n> Close and reopen this PR to trigger CI checks.", - "packages": { - ".": { - "changelog-path": "CHANGELOG.md" - } - } + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "pull-request-footer": "> [!IMPORTANT]\n> Close and reopen this PR to trigger CI checks.", + "packages": { + ".": { + "changelog-path": "CHANGELOG.md" + } + } } diff --git a/.markdownlint.json b/.markdownlint.json index 67d2ae5..87e137e 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,3 +1,3 @@ { - "MD013": false + "MD013": false } diff --git a/biome.json b/biome.json index 35373b0..7d64c0a 100644 --- a/biome.json +++ b/biome.json @@ -1,6 +1,10 @@ { - "files": { - "ignoreUnknown": true, - "includes": ["**", "!!**/.github/renovate-tracked-deps.json", "!!**/.claude"] - } + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!!**/.github/renovate-tracked-deps.json", + "!!**/.claude" + ] + } } diff --git a/default.json b/default.json index 3c095aa..000ef9a 100644 --- a/default.json +++ b/default.json @@ -1,53 +1,62 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "description": "Renovate custom managers for repositories that use flint mise tasks", - "customManagers": [ - { - "customType": "regex", - "description": "Update _VERSION variables in mise.toml", - "managerFilePatterns": ["/^mise\\.toml$/"], - "matchStrings": ["# renovate: datasource=(?[a-z-]+?)(?: depName=(?.+?))?(?: packageName=(?.+?))?(?: versioning=(?[a-z-]+?))?\\s.+?_VERSION=\"?(?[^@\"]+?)(?:@(?sha256:[a-f0-9]+))?\"?\\s"] - }, - { - "customType": "regex", - "description": "Update raw.githubusercontent.com URLs pinned to SHA with version comment", - "managerFilePatterns": ["/^mise\\.toml$/"], - "matchStrings": ["https://raw\\.githubusercontent\\.com/(?[^/]+/[^/]+)/(?[a-f0-9]{40})/.*#\\s*(?v\\S+)"], - "datasourceTemplate": "github-tags" - }, - { - "customType": "regex", - "description": "Update mise version in GitHub Actions workflows", - "managerFilePatterns": ["/(^|/)(workflow-templates|\\.(?:github|gitea|forgejo)/(?:workflows|actions))/.+\\.ya?ml$/", "/(^|/)action\\.ya?ml$/"], - "datasourceTemplate": "github-release-attachments", - "packageNameTemplate": "jdx/mise", - "depNameTemplate": "mise", - "matchStrings": ["jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?"] - } - ], - "packageRules": [ - { - "matchPackageNames": ["renovate"], - "description": "Only update renovate once a week", - "schedule": ["before 6am on Monday"] - }, - { - "matchPackageNames": ["jdx/mise"], - "groupName": "mise", - "description": "Only update mise once a week", - "schedule": ["before 4am on Monday"] - }, - { - "matchPackageNames": ["grafana/flint"], - "groupName": "flint", - "description": "Only update flint once a week", - "schedule": ["before 4am on Monday"] - }, - { - "matchPackageNames": ["ghcr.io/super-linter/super-linter"], - "matchCurrentValue": "/^slim-/", - "description": "Use regex versioning for slim Super-Linter tags (slim-v8.4.0)", - "versioning": "regex:^slim-v(?\\d+)\\.(?\\d+)\\.(?\\d+)$" - } - ] + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "description": "Renovate custom managers for repositories that use flint mise tasks", + "customManagers": [ + { + "customType": "regex", + "description": "Update _VERSION variables in mise.toml", + "managerFilePatterns": ["/^mise\\.toml$/"], + "matchStrings": [ + "# renovate: datasource=(?[a-z-]+?)(?: depName=(?.+?))?(?: packageName=(?.+?))?(?: versioning=(?[a-z-]+?))?\\s.+?_VERSION=\"?(?[^@\"]+?)(?:@(?sha256:[a-f0-9]+))?\"?\\s" + ] + }, + { + "customType": "regex", + "description": "Update raw.githubusercontent.com URLs pinned to SHA with version comment", + "managerFilePatterns": ["/^mise\\.toml$/"], + "matchStrings": [ + "https://raw\\.githubusercontent\\.com/(?[^/]+/[^/]+)/(?[a-f0-9]{40})/.*#\\s*(?v\\S+)" + ], + "datasourceTemplate": "github-tags" + }, + { + "customType": "regex", + "description": "Update mise version in GitHub Actions workflows", + "managerFilePatterns": [ + "/(^|/)(workflow-templates|\\.(?:github|gitea|forgejo)/(?:workflows|actions))/.+\\.ya?ml$/", + "/(^|/)action\\.ya?ml$/" + ], + "datasourceTemplate": "github-release-attachments", + "packageNameTemplate": "jdx/mise", + "depNameTemplate": "mise", + "matchStrings": [ + "jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?" + ] + } + ], + "packageRules": [ + { + "matchPackageNames": ["renovate"], + "description": "Only update renovate once a week", + "schedule": ["before 6am on Monday"] + }, + { + "matchPackageNames": ["jdx/mise"], + "groupName": "mise", + "description": "Only update mise once a week", + "schedule": ["before 4am on Monday"] + }, + { + "matchPackageNames": ["grafana/flint"], + "groupName": "flint", + "description": "Only update flint once a week", + "schedule": ["before 4am on Monday"] + }, + { + "matchPackageNames": ["ghcr.io/super-linter/super-linter"], + "matchCurrentValue": "/^slim-/", + "description": "Use regex versioning for slim Super-Linter tags (slim-v8.4.0)", + "versioning": "regex:^slim-v(?\\d+)\\.(?\\d+)\\.(?\\d+)$" + } + ] } From e49f123c42415d2f2b81a56dfcd4ef3c64ef2302 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:46:58 +0000 Subject: [PATCH 016/141] =?UTF-8?q?fix:=20remove=20*.json=20from=20prettie?= =?UTF-8?q?r=20scope=20=E2=80=94=20biome=20owns=20JSON=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gregor Zeitlinger --- FLINT-V2.md | 2 +- src/registry.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/FLINT-V2.md b/FLINT-V2.md index db3abbf..9e85303 100644 --- a/FLINT-V2.md +++ b/FLINT-V2.md @@ -166,7 +166,7 @@ Checks auto-enable when their binary is found in PATH. Install tools via `mise.t | `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | | `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | | `markdownlint` | `markdownlint` | `*.md` | yes | file | -| `prettier` | `prettier` | `*.md *.json *.yml *.yaml` | yes | files | +| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | files | | `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | | `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | | `codespell` | `codespell` | `*` | yes | files | diff --git a/src/registry.rs b/src/registry.rs index d89edd1..3ca7edb 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -90,7 +90,7 @@ pub fn builtin() -> Vec { Check { name: "prettier", bin_name: "prettier", - patterns: "*.md *.json *.yml *.yaml", + patterns: "*.md *.yml *.yaml", slow: false, kind: CheckKind::Template { check_cmd: "prettier --check {FILES}", From e2eb6a957e184021b39e869150fe679107808bbe Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:50:47 +0000 Subject: [PATCH 017/141] fix: exclude formatter-owned file types from ec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ec (editorconfig-checker) was running on all files including *.rs, *.py, *.go, *.sh, *.json etc. — types that already have dedicated formatters. Add exclude_patterns to the Check registry and set it on ec to defer to those formatters for the types they own. Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 20 ++++++++++++++++++++ src/runner.rs | 18 +++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 3ca7edb..5552a8d 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -32,6 +32,8 @@ pub struct Check { pub bin_name: &'static str, /// Glob patterns (space-separated) for matching files. pub patterns: &'static str, + /// Glob patterns (space-separated) to exclude from the file list. + pub exclude_patterns: &'static str, /// Slow checks are skipped when `--fast` is passed. pub slow: bool, pub kind: CheckKind, @@ -58,6 +60,7 @@ pub fn builtin() -> Vec { name: "shellcheck", bin_name: "shellcheck", patterns: "*.sh *.bash *.bats", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "shellcheck {FILE}", @@ -69,6 +72,7 @@ pub fn builtin() -> Vec { name: "shfmt", bin_name: "shfmt", patterns: "*.sh *.bash", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "shfmt -d {FILE}", @@ -80,6 +84,7 @@ pub fn builtin() -> Vec { name: "markdownlint", bin_name: "markdownlint", patterns: "*.md", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "markdownlint {FILE}", @@ -91,6 +96,7 @@ pub fn builtin() -> Vec { name: "prettier", bin_name: "prettier", patterns: "*.md *.yml *.yaml", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "prettier --check {FILES}", @@ -102,6 +108,7 @@ pub fn builtin() -> Vec { name: "actionlint", bin_name: "actionlint", patterns: ".github/workflows/*.yml .github/workflows/*.yaml", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "actionlint {FILE}", @@ -113,6 +120,7 @@ pub fn builtin() -> Vec { name: "hadolint", bin_name: "hadolint", patterns: "Dockerfile Dockerfile.* *.dockerfile", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "hadolint {FILE}", @@ -124,6 +132,7 @@ pub fn builtin() -> Vec { name: "codespell", bin_name: "codespell", patterns: "*", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "codespell {FILES}", @@ -135,6 +144,8 @@ pub fn builtin() -> Vec { name: "ec", bin_name: "ec", patterns: "*", + // Defer to dedicated formatters for the types they own. + exclude_patterns: "*.rs *.py *.go *.sh *.bash *.bats *.json *.jsonc *.js *.ts *.jsx *.tsx *.md", slow: false, kind: CheckKind::Template { check_cmd: "ec {FILES}", @@ -146,6 +157,7 @@ pub fn builtin() -> Vec { name: "golangci-lint", bin_name: "golangci-lint", patterns: "*.go", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "golangci-lint run --new-from-rev={MERGE_BASE}", @@ -157,6 +169,7 @@ pub fn builtin() -> Vec { name: "ruff", bin_name: "ruff", patterns: "*.py", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "ruff check {FILE}", @@ -168,6 +181,7 @@ pub fn builtin() -> Vec { name: "ruff-format", bin_name: "ruff", patterns: "*.py", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "ruff format --check {FILE}", @@ -179,6 +193,7 @@ pub fn builtin() -> Vec { name: "biome", bin_name: "biome", patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "biome check {FILE}", @@ -190,6 +205,7 @@ pub fn builtin() -> Vec { name: "biome-format", bin_name: "biome", patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "biome format {FILE}", @@ -201,6 +217,7 @@ pub fn builtin() -> Vec { name: "cargo-clippy", bin_name: "cargo-clippy", patterns: "*.rs", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "cargo clippy -q -- -D warnings", @@ -212,6 +229,7 @@ pub fn builtin() -> Vec { name: "cargo-fmt", bin_name: "cargo-fmt", patterns: "*.rs", + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "cargo fmt -- --check", @@ -223,6 +241,7 @@ pub fn builtin() -> Vec { name: "links", bin_name: "lychee", patterns: "", + exclude_patterns: "", slow: false, kind: CheckKind::Special(SpecialKind::Links), }, @@ -230,6 +249,7 @@ pub fn builtin() -> Vec { name: "renovate-deps", bin_name: "renovate", patterns: "", + exclude_patterns: "", slow: true, kind: CheckKind::Special(SpecialKind::RenovateDeps), }, diff --git a/src/runner.rs b/src/runner.rs index 0bdaaf6..9adf236 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -144,12 +144,14 @@ fn build_invocations( check_cmd }; + let excludes: Vec<&str> = check.exclude_patterns.split_whitespace().collect(); + match scope { Scope::Project => { // If patterns are set, only run when relevant files are present. if !check.patterns.is_empty() { let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); - if match_files(&file_list.files, &patterns, project_root).is_empty() { + if match_files(&file_list.files, &patterns, &excludes, project_root).is_empty() { return vec![]; } } @@ -159,7 +161,7 @@ fn build_invocations( Scope::File => { let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); - let matched = match_files(&file_list.files, &patterns, project_root); + let matched = match_files(&file_list.files, &patterns, &excludes, project_root); matched .iter() .map(|f| { @@ -171,7 +173,7 @@ fn build_invocations( Scope::Files => { let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); - let matched = match_files(&file_list.files, &patterns, project_root); + let matched = match_files(&file_list.files, &patterns, &excludes, project_root); if matched.is_empty() { return vec![]; } @@ -238,6 +240,7 @@ fn flush_output(stdout: &[u8], stderr: &[u8]) { fn match_files<'a>( files: &'a [PathBuf], patterns: &[&str], + exclude_patterns: &[&str], project_root: &Path, ) -> Vec<&'a PathBuf> { files @@ -249,12 +252,16 @@ fn match_files<'a>( .file_name() .map(|n| n.to_string_lossy()) .unwrap_or_default(); - patterns.iter().any(|pat| { + let included = patterns.iter().any(|pat| { if *pat == "*" { return true; } glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) - }) + }); + let excluded = exclude_patterns.iter().any(|pat| { + glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) + }); + included && !excluded }) .collect() } @@ -341,6 +348,7 @@ mod tests { name: "test", bin_name: "test-bin", patterns, + exclude_patterns: "", slow: false, kind: CheckKind::Template { check_cmd: "run-it", From 934cc7c46457dc0b1eb91bab9db4f68f8e6be6ca Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:53:44 +0000 Subject: [PATCH 018/141] fix: make ec exclusions conditional on other formatters being active Static exclude_patterns always skipped e.g. *.rs from ec even when cargo-fmt wasn't installed. Replace with excludes_if_active: a list of check names that, when active, cause their patterns to be excluded from ec's file list. ec falls back to checking those files when the dedicated formatter isn't present. Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 50 +++++++++++++++++++++++++++++-------------------- src/runner.rs | 21 ++++++++++++++------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 5552a8d..2898294 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -32,8 +32,10 @@ pub struct Check { pub bin_name: &'static str, /// Glob patterns (space-separated) for matching files. pub patterns: &'static str, - /// Glob patterns (space-separated) to exclude from the file list. - pub exclude_patterns: &'static str, + /// When any of these named checks are active, exclude their patterns from + /// this check's file list. Used to avoid double-checking files that a + /// dedicated formatter already owns. + pub excludes_if_active: &'static [&'static str], /// Slow checks are skipped when `--fast` is passed. pub slow: bool, pub kind: CheckKind, @@ -60,7 +62,7 @@ pub fn builtin() -> Vec { name: "shellcheck", bin_name: "shellcheck", patterns: "*.sh *.bash *.bats", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "shellcheck {FILE}", @@ -72,7 +74,7 @@ pub fn builtin() -> Vec { name: "shfmt", bin_name: "shfmt", patterns: "*.sh *.bash", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "shfmt -d {FILE}", @@ -84,7 +86,7 @@ pub fn builtin() -> Vec { name: "markdownlint", bin_name: "markdownlint", patterns: "*.md", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "markdownlint {FILE}", @@ -96,7 +98,7 @@ pub fn builtin() -> Vec { name: "prettier", bin_name: "prettier", patterns: "*.md *.yml *.yaml", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "prettier --check {FILES}", @@ -108,7 +110,7 @@ pub fn builtin() -> Vec { name: "actionlint", bin_name: "actionlint", patterns: ".github/workflows/*.yml .github/workflows/*.yaml", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "actionlint {FILE}", @@ -120,7 +122,7 @@ pub fn builtin() -> Vec { name: "hadolint", bin_name: "hadolint", patterns: "Dockerfile Dockerfile.* *.dockerfile", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "hadolint {FILE}", @@ -132,7 +134,7 @@ pub fn builtin() -> Vec { name: "codespell", bin_name: "codespell", patterns: "*", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "codespell {FILES}", @@ -144,8 +146,16 @@ pub fn builtin() -> Vec { name: "ec", bin_name: "ec", patterns: "*", - // Defer to dedicated formatters for the types they own. - exclude_patterns: "*.rs *.py *.go *.sh *.bash *.bats *.json *.jsonc *.js *.ts *.jsx *.tsx *.md", + // Defer to dedicated formatters when they are active. + excludes_if_active: &[ + "cargo-fmt", + "ruff-format", + "golangci-lint", + "shfmt", + "biome-format", + "markdownlint", + "prettier", + ], slow: false, kind: CheckKind::Template { check_cmd: "ec {FILES}", @@ -157,7 +167,7 @@ pub fn builtin() -> Vec { name: "golangci-lint", bin_name: "golangci-lint", patterns: "*.go", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "golangci-lint run --new-from-rev={MERGE_BASE}", @@ -169,7 +179,7 @@ pub fn builtin() -> Vec { name: "ruff", bin_name: "ruff", patterns: "*.py", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "ruff check {FILE}", @@ -181,7 +191,7 @@ pub fn builtin() -> Vec { name: "ruff-format", bin_name: "ruff", patterns: "*.py", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "ruff format --check {FILE}", @@ -193,7 +203,7 @@ pub fn builtin() -> Vec { name: "biome", bin_name: "biome", patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "biome check {FILE}", @@ -205,7 +215,7 @@ pub fn builtin() -> Vec { name: "biome-format", bin_name: "biome", patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "biome format {FILE}", @@ -217,7 +227,7 @@ pub fn builtin() -> Vec { name: "cargo-clippy", bin_name: "cargo-clippy", patterns: "*.rs", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "cargo clippy -q -- -D warnings", @@ -229,7 +239,7 @@ pub fn builtin() -> Vec { name: "cargo-fmt", bin_name: "cargo-fmt", patterns: "*.rs", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "cargo fmt -- --check", @@ -241,7 +251,7 @@ pub fn builtin() -> Vec { name: "links", bin_name: "lychee", patterns: "", - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Special(SpecialKind::Links), }, @@ -249,7 +259,7 @@ pub fn builtin() -> Vec { name: "renovate-deps", bin_name: "renovate", patterns: "", - exclude_patterns: "", + excludes_if_active: &[], slow: true, kind: CheckKind::Special(SpecialKind::RenovateDeps), }, diff --git a/src/runner.rs b/src/runner.rs index 9adf236..0538d59 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -25,7 +25,8 @@ pub async fn run( let check_name = check.name.to_string(); let (ok, stdout, stderr) = match &check.kind { CheckKind::Template { .. } => { - let invocations = build_invocations(check, file_list, fix, project_root); + let invocations = + build_invocations(check, file_list, fix, project_root, checks); if invocations.is_empty() { continue; } @@ -55,7 +56,7 @@ pub async fn run( match &check.kind { CheckKind::Template { .. } => { - let invocations = build_invocations(check, file_list, fix, project_root); + let invocations = build_invocations(check, file_list, fix, project_root, checks); if invocations.is_empty() { continue; } @@ -128,6 +129,7 @@ fn build_invocations( file_list: &FileList, fix: bool, project_root: &Path, + active_checks: &[&Check], ) -> Vec> { let CheckKind::Template { check_cmd, @@ -144,7 +146,12 @@ fn build_invocations( check_cmd }; - let excludes: Vec<&str> = check.exclude_patterns.split_whitespace().collect(); + // Collect patterns from checks that are active and listed in excludes_if_active. + let excludes: Vec<&str> = active_checks + .iter() + .filter(|c| check.excludes_if_active.contains(&c.name)) + .flat_map(|c| c.patterns.split_whitespace()) + .collect(); match scope { Scope::Project => { @@ -348,7 +355,7 @@ mod tests { name: "test", bin_name: "test-bin", patterns, - exclude_patterns: "", + excludes_if_active: &[], slow: false, kind: CheckKind::Template { check_cmd: "run-it", @@ -372,14 +379,14 @@ mod tests { fn project_scope_skips_when_no_matching_files() { let check = project_check("*.rs"); let fl = file_list(&["foo.py", "bar.md"]); - assert!(build_invocations(&check, &fl, false, Path::new("/repo")).is_empty()); + assert!(build_invocations(&check, &fl, false, Path::new("/repo"), &[]).is_empty()); } #[test] fn project_scope_runs_when_matching_files_present() { let check = project_check("*.rs"); let fl = file_list(&["src/main.rs", "foo.py"]); - let inv = build_invocations(&check, &fl, false, Path::new("/repo")); + let inv = build_invocations(&check, &fl, false, Path::new("/repo"), &[]); assert_eq!(inv, vec![vec!["run-it".to_string()]]); } @@ -387,7 +394,7 @@ mod tests { fn project_scope_empty_patterns_always_runs() { let check = project_check(""); let fl = file_list(&["foo.py"]); - let inv = build_invocations(&check, &fl, false, Path::new("/repo")); + let inv = build_invocations(&check, &fl, false, Path::new("/repo"), &[]); assert_eq!(inv, vec![vec!["run-it".to_string()]]); } } From 69be30bc390d7096d941bc4ee7a2e056eab086c4 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:56:36 +0000 Subject: [PATCH 019/141] fix: narrow ec exclusions to formatters that enforce line length shfmt, golangci-lint, and markdownlint don't enforce line length so they don't conflict with ec's max_line_length check. Only exclude from ec when cargo-fmt, ruff-format, biome-format, or prettier are active. Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 2898294..8875f6f 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -146,16 +146,9 @@ pub fn builtin() -> Vec { name: "ec", bin_name: "ec", patterns: "*", - // Defer to dedicated formatters when they are active. - excludes_if_active: &[ - "cargo-fmt", - "ruff-format", - "golangci-lint", - "shfmt", - "biome-format", - "markdownlint", - "prettier", - ], + // Defer to formatters that enforce line length — those are the ones + // that conflict with ec's max_line_length editorconfig check. + excludes_if_active: &["cargo-fmt", "ruff-format", "biome-format", "prettier"], slow: false, kind: CheckKind::Template { check_cmd: "ec {FILES}", From e830ba36a4c177fc0e0c5173d4ed1835797845ca Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 17:59:03 +0000 Subject: [PATCH 020/141] docs: document ec deference behavior and --short output format Signed-off-by: Gregor Zeitlinger --- FLINT-V2.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/FLINT-V2.md b/FLINT-V2.md index 9e85303..f2b4d9e 100644 --- a/FLINT-V2.md +++ b/FLINT-V2.md @@ -101,6 +101,13 @@ flint list Env var equivalents: `AUTOFIX=true` for `--fix`, `FLINT_SHORT=true` for `--short`. +In `--short` mode, failed checks are partitioned by fixability and emitted +as a single line. Fixable checks are expressed as the exact command to run: + +```text +flint: 2 checks failed — flint --fix prettier cargo-fmt | review: shellcheck +``` + Pass one or more linter names to run only those: ```bash @@ -193,6 +200,14 @@ Checks auto-enable when their binary is found in PATH. Install tools via `mise.t **Slow checks** (`renovate-deps`) are skipped by `--fast`. Use `--fast` for local/pre-push feedback and the full set in CI. +**`ec` deference**: `ec` (editorconfig-checker) runs on all files, but +automatically skips file types owned by an active line-length-enforcing +formatter. When `cargo-fmt`, `ruff-format`, `biome-format`, or `prettier` +are active, their file types are excluded from `ec` — those formatters +already enforce line length and would conflict with `ec`'s +`max_line_length` editorconfig check. If none of those formatters are +installed, `ec` checks those files itself. + ## Special checks ### links From 1698833f086a25290d2668d2a1c39141ab6ea0d2 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 18:24:00 +0000 Subject: [PATCH 021/141] feat: add --auto mode for autonomous agents --auto fixes all fixable checks, reports outcome, and exits 0 only if everything passed or was fixed. Non-fixable failures (and failed fix attempts) are surfaced under 'review:'. Intended for pre-push hooks and agentic pipelines that have write access. flint: fixed: prettier cargo-fmt | review: shellcheck Update native-lint to use --auto --fast so the CC hook auto-fixes rather than round-tripping through the agent. Docs updated with intended-use-by-context table. Signed-off-by: Gregor Zeitlinger --- FLINT-V2.md | 39 ++++++++++++++++++-------- mise.toml | 2 +- src/main.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++- tests/e2e.rs | 65 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 13 deletions(-) diff --git a/FLINT-V2.md b/FLINT-V2.md index f2b4d9e..67a4ab0 100644 --- a/FLINT-V2.md +++ b/FLINT-V2.md @@ -89,25 +89,42 @@ flint list **Options:** -| Flag | Description | -| ---------------- | ------------------------------------------------ | -| `--fix` | Auto-fix issues instead of checking | -| `--full` | Lint all files instead of only changed files | -| `--fast` | Skip slow checks (e.g. `renovate-deps`) | -| `--short` | Suppress per-check output, print summary only | -| `--verbose` | Show all linter output, not just failures | -| `--from-ref REF` | Diff base (default: merge base with base branch) | -| `--to-ref REF` | Diff head (default: HEAD) | +| Flag | Description | +| ---------------- | -------------------------------------------------- | +| `--fix` | Auto-fix issues instead of checking | +| `--auto` | Fix what's fixable, report what still needs review | +| `--full` | Lint all files instead of only changed files | +| `--fast` | Skip slow checks (e.g. `renovate-deps`) | +| `--short` | Compact summary output, no per-check noise | +| `--verbose` | Show all linter output, not just failures | +| `--from-ref REF` | Diff base (default: merge base with base branch) | +| `--to-ref REF` | Diff head (default: HEAD) | Env var equivalents: `AUTOFIX=true` for `--fix`, `FLINT_SHORT=true` for `--short`. -In `--short` mode, failed checks are partitioned by fixability and emitted -as a single line. Fixable checks are expressed as the exact command to run: +### Intended use by context + +| Context | Command | Why | +| ---------------------------- | ------------------------- | ----------------------------------------------------------------- | +| Interactive development | `flint` or `flint --fast` | Full output so you can read the details | +| Human wanting a summary | `flint --short` | Compact output, no per-check noise | +| Pre-push hook (CC / agentic) | `flint --auto --fast` | Fixes what it can silently, surfaces only what needs human review | +| CI | `flint` | Full output for humans reading CI logs | + +**`--short` output** — failed checks partitioned by fixability, fixable ones +expressed as the exact command to run: ```text flint: 2 checks failed — flint --fix prettier cargo-fmt | review: shellcheck ``` +**`--auto` output** — fixes what's fixable, reports the outcome. Exits 0 if +everything passed or was fixed; exits 1 only if something still needs review: + +```text +flint: fixed: prettier cargo-fmt | review: shellcheck +``` + Pass one or more linter names to run only those: ```bash diff --git a/mise.toml b/mise.toml index 7f313fb..5ebfd16 100644 --- a/mise.toml +++ b/mise.toml @@ -26,7 +26,7 @@ run = "cargo build -q && ./target/debug/flint --fix" [tasks.native-lint] description = "Run lints natively (fast, no renovate)" -run = "cargo build -q && ./target/debug/flint --fast --short" +run = "cargo build -q && ./target/debug/flint --auto --fast" # Rust tasks [tasks.build] diff --git a/src/main.rs b/src/main.rs index 544859b..814938e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,10 +31,16 @@ struct Cli { #[arg(long)] verbose: bool, - /// Suppress per-check output; print only the summary line (useful for AI/token-constrained callers) + /// Compact summary output — no per-check noise (human) or read-only AI review #[arg(long, env = "FLINT_SHORT")] short: bool, + /// Autonomous mode: fix what's fixable, report what still needs review. + /// Exits 0 if everything passed or was fixed. Intended for pre-push hooks + /// and agentic pipelines that have write access. + #[arg(long)] + auto: bool, + /// Compare changed files from this ref (default: merge base with base branch) #[arg(long)] from_ref: Option, @@ -104,6 +110,76 @@ async fn main() -> Result<()> { cli.to_ref.as_deref(), )?; + if cli.auto { + // Run checks, fix what's fixable, report outcome. + // Exits 0 if everything passed or was fixed; 1 if anything still needs review. + let check_results = runner::run( + &active, + &file_list, + false, + false, + true, // suppress per-check output + &project_root, + &cfg, + ) + .await?; + + let (fixable_names, reviewable): (Vec<&str>, Vec<&str>) = check_results + .iter() + .filter(|(_, ok)| !ok) + .map(|(name, _)| name.as_str()) + .partition(|name| active.iter().any(|c| c.name == *name && c.has_fix())); + + let mut fixed = vec![]; + let mut fix_failed = vec![]; + if !fixable_names.is_empty() { + let to_fix: Vec<®istry::Check> = active + .iter() + .filter(|c| fixable_names.contains(&c.name)) + .copied() + .collect(); + let fix_results = runner::run( + &to_fix, + &file_list, + true, + false, + true, // suppress per-check output + &project_root, + &cfg, + ) + .await?; + for (name, ok) in fix_results { + if ok { + fixed.push(name); + } else { + fix_failed.push(name); + } + } + } + + let remaining: Vec<&str> = reviewable + .iter() + .copied() + .chain(fix_failed.iter().map(String::as_str)) + .collect(); + + let mut segments = vec![]; + if !fixed.is_empty() { + segments.push(format!("fixed: {}", fixed.join(", "))); + } + if !remaining.is_empty() { + segments.push(format!("review: {}", remaining.join(", "))); + } + if !segments.is_empty() { + eprintln!("flint: {}", segments.join(" | ")); + } + + if !remaining.is_empty() { + std::process::exit(1); + } + return Ok(()); + } + let results = runner::run( &active, &file_list, diff --git a/tests/e2e.rs b/tests/e2e.rs index 59ae249..285f3d6 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -97,6 +97,71 @@ fn cargo_fmt_diff_shows_check_name_header() { ); } +#[test] +fn auto_fixes_and_reports_summary() { + let repo = git_repo(); + + // Poorly formatted Rust — cargo-fmt is fixable. + std::fs::write( + repo.path().join("Cargo.toml"), + "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + let src = repo.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + stage( + &src.join("lib.rs"), + "pub struct Foo { pub a: u32, pub b: u32 }\n", + repo.path(), + ); + + let out = flint(&["--full", "--auto", "cargo-fmt"], repo.path()); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + + println!("=== stdout ===\n{stdout}"); + eprintln!("=== stderr ===\n{stderr}"); + + // --auto should fix cargo-fmt and exit 0. + assert!( + out.status.success(), + "flint --auto should exit 0 after fixing, got:\n{stderr}" + ); + assert!( + stderr.contains("fixed: cargo-fmt"), + "expected 'fixed: cargo-fmt' in summary, got:\n{stderr}" + ); +} + +#[test] +fn auto_reports_unfixable_as_review() { + let repo = git_repo(); + + // SC2086: unquoted variable — shellcheck violation with no auto-fix. + stage( + &repo.path().join("bad.sh"), + "#!/bin/bash\necho $1\n", + repo.path(), + ); + + let out = flint(&["--full", "--auto", "shellcheck"], repo.path()); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + + println!("=== stdout ===\n{stdout}"); + eprintln!("=== stderr ===\n{stderr}"); + + // --auto should exit 1 for non-fixable failures and surface them under review:. + assert!( + !out.status.success(), + "flint --auto should exit 1 for unfixable checks" + ); + assert!( + stderr.contains("review: shellcheck"), + "expected 'review: shellcheck' in summary, got:\n{stderr}" + ); +} + #[test] fn shellcheck_clean_script_passes() { let repo = git_repo(); From 14d2f9e5fb21f20a5f8eec490fcf4c0a452684fa Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 18:28:21 +0000 Subject: [PATCH 022/141] fix: --auto exits 1 when fixes applied so caller commits before pushing Fixes a bug where --auto exiting 0 after fixing would let the push proceed with unfixed commits. Now exits 1 with 'commit before pushing' whenever fixes were applied, so the agent knows to stage and commit the changes first. Only exits 0 if everything was already clean. Signed-off-by: Gregor Zeitlinger --- FLINT-V2.md | 7 ++++--- src/main.rs | 10 ++++++---- tests/e2e.rs | 10 +++++++--- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/FLINT-V2.md b/FLINT-V2.md index 67a4ab0..c400c22 100644 --- a/FLINT-V2.md +++ b/FLINT-V2.md @@ -118,11 +118,12 @@ expressed as the exact command to run: flint: 2 checks failed — flint --fix prettier cargo-fmt | review: shellcheck ``` -**`--auto` output** — fixes what's fixable, reports the outcome. Exits 0 if -everything passed or was fixed; exits 1 only if something still needs review: +**`--auto` output** — fixes what's fixable, reports the outcome. Exits 1 if +anything was fixed (so the caller commits the fixes before pushing) or if +anything still needs review. Exits 0 only if everything was already clean: ```text -flint: fixed: prettier cargo-fmt | review: shellcheck +flint: fixed: prettier cargo-fmt — commit before pushing | review: shellcheck ``` Pass one or more linter names to run only those: diff --git a/src/main.rs b/src/main.rs index 814938e..2e849d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -165,16 +165,18 @@ async fn main() -> Result<()> { let mut segments = vec![]; if !fixed.is_empty() { - segments.push(format!("fixed: {}", fixed.join(", "))); + // Exit 1 even when fixes were applied: in a pre-push context the + // fixed files are uncommitted. The caller must commit them first. + segments.push(format!( + "fixed: {} — commit before pushing", + fixed.join(", ") + )); } if !remaining.is_empty() { segments.push(format!("review: {}", remaining.join(", "))); } if !segments.is_empty() { eprintln!("flint: {}", segments.join(" | ")); - } - - if !remaining.is_empty() { std::process::exit(1); } return Ok(()); diff --git a/tests/e2e.rs b/tests/e2e.rs index 285f3d6..f32bc4c 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -122,15 +122,19 @@ fn auto_fixes_and_reports_summary() { println!("=== stdout ===\n{stdout}"); eprintln!("=== stderr ===\n{stderr}"); - // --auto should fix cargo-fmt and exit 0. + // --auto fixes cargo-fmt but exits 1 — fixed files must be committed before pushing. assert!( - out.status.success(), - "flint --auto should exit 0 after fixing, got:\n{stderr}" + !out.status.success(), + "flint --auto should exit 1 when fixes were applied" ); assert!( stderr.contains("fixed: cargo-fmt"), "expected 'fixed: cargo-fmt' in summary, got:\n{stderr}" ); + assert!( + stderr.contains("commit before pushing"), + "expected 'commit before pushing' hint, got:\n{stderr}" + ); } #[test] From b5a90bf754823914ecfd33fc1648bb190ba0ce62 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 20:19:22 +0000 Subject: [PATCH 023/141] feat: use mise.toml as tool availability source, add version_range support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace PATH-based availability checks (which) with mise.toml lookups. Tools not declared in the consuming repo's mise.toml are silently skipped. Adds version_range field to Check for future split registrations (e.g. two lychee entries with non-overlapping semver ranges), and mise_tool_name for tools that ship as part of a toolchain — cargo-fmt and cargo-clippy now activate when rust is declared in mise.toml. Registry test enforces that mixed ranged/unranged entries for the same binary are rejected at test time. Signed-off-by: Gregor Zeitlinger --- Cargo.lock | 32 +------------------- Cargo.toml | 2 +- src/main.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++---- src/registry.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++++- src/runner.rs | 2 ++ tests/e2e.rs | 19 ++++++++++++ 6 files changed, 173 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6eef937..1c36966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,18 +131,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "equivalent" version = "1.0.2" @@ -172,11 +160,11 @@ dependencies = [ "anyhow", "clap", "regex", + "semver", "serde", "tempfile", "tokio", "toml", - "which", ] [[package]] @@ -688,18 +676,6 @@ dependencies = [ "semver", ] -[[package]] -name = "which" -version = "7.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" -dependencies = [ - "either", - "env_home", - "rustix", - "winsafe", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -721,12 +697,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 510de38..0b82d5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ clap = { version = "4", features = ["derive", "env"] } serde = { version = "1", features = ["derive"] } toml = "1.0" tokio = { version = "1", features = ["full"] } -which = "7" +semver = "1" regex = "1" [dev-dependencies] diff --git a/src/main.rs b/src/main.rs index 2e849d3..0d9208b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod runner; use anyhow::Result; use clap::{Parser, Subcommand}; +use std::collections::HashMap; #[derive(Parser, Debug)] #[command(name = "flint", about = "mise-native lint orchestrator")] @@ -72,7 +73,8 @@ async fn main() -> Result<()> { let registry = registry::builtin(); if let Some(SubCommand::List) = cli.command { - print_list(®istry); + let mise_tools = read_mise_tools(&project_root); + print_list(®istry, &mise_tools); return Ok(()); } @@ -95,10 +97,12 @@ async fn main() -> Result<()> { out }; - // Discover which checks have their tool available in PATH, and apply --fast filter. + // Discover which checks are declared in the consuming repo's mise.toml, and apply + // --fast filter. mise guarantees declared tools are on PATH, so no PATH check needed. + let mise_tools = read_mise_tools(&project_root); let active: Vec<®istry::Check> = checks .into_iter() - .filter(|c| which::which(c.bin()).is_ok()) + .filter(|c| check_version_matches(c, &mise_tools)) .filter(|c| !cli.fast || !c.slow) .collect(); @@ -234,7 +238,69 @@ async fn main() -> Result<()> { Ok(()) } -fn print_list(registry: &[registry::Check]) { +/// Reads `[tools]` from the consuming repo's mise.toml and returns a map of +/// tool name → declared version string. +fn read_mise_tools(project_root: &std::path::Path) -> HashMap { + let path = project_root.join("mise.toml"); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + let value: toml::Value = match toml::from_str(&content) { + Ok(v) => v, + Err(_) => return HashMap::new(), + }; + let mut tools = HashMap::new(); + if let Some(table) = value.get("tools").and_then(|v| v.as_table()) { + for (name, val) in table { + let version = match val { + toml::Value::String(s) => Some(s.clone()), + toml::Value::Table(t) => { + t.get("version").and_then(|v| v.as_str()).map(String::from) + } + _ => None, + }; + if let Some(v) = version { + tools.insert(name.clone(), v); + } + } + } + tools +} + +/// Returns true if the check's tool is declared in mise.toml and its version +/// satisfies the check's version_range (if any). +fn check_version_matches( + check: ®istry::Check, + mise_tools: &HashMap, +) -> bool { + let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); + let Some(declared) = mise_tools.get(lookup_key) else { + return false; + }; + let Some(range_str) = check.version_range else { + return true; + }; + let Ok(req) = semver::VersionReq::parse(range_str) else { + return false; + }; + coerce_version(declared).is_some_and(|v| req.matches(&v)) +} + +/// Parses a version string, padding with `.0` components if needed to satisfy +/// semver's three-part requirement (e.g. `"20"` → `20.0.0`, `"3.12"` → `3.12.0`). +fn coerce_version(s: &str) -> Option { + semver::Version::parse(s).ok().or_else(|| { + let parts = s.split('.').count(); + match parts { + 1 => semver::Version::parse(&format!("{s}.0.0")).ok(), + 2 => semver::Version::parse(&format!("{s}.0")).ok(), + _ => None, + } + }) +} + +fn print_list(registry: &[registry::Check], mise_tools: &HashMap) { // Column widths. let name_w = registry .iter() @@ -261,8 +327,10 @@ fn print_list(registry: &[registry::Check]) { println!("{}", "-".repeat(name_w + bin_w + 35)); for check in registry { - let status = if which::which(check.bin()).is_ok() { - "installed" + let status = if check_version_matches(check, mise_tools) { + "active" + } else if mise_tools.contains_key(check.bin_name) { + "wrong version" } else { "missing" }; diff --git a/src/registry.rs b/src/registry.rs index 8875f6f..9be00ae 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -28,8 +28,16 @@ pub enum CheckKind { #[derive(Debug, Clone)] pub struct Check { pub name: &'static str, - /// Binary name to check in PATH. + /// Binary name used to invoke the tool. pub bin_name: &'static str, + /// mise.toml tool key to look up for availability. When `None`, falls back to + /// `bin_name`. Use this when the binary comes from a toolchain entry rather than + /// its own tool entry (e.g. `cargo-fmt` ships with `rust`). + pub mise_tool_name: Option<&'static str>, + /// Semver requirement string (e.g. `">=1.0.0"`). When `None`, any version matches. + /// When multiple registry entries share a `bin_name`, each must have a `version_range` + /// and the ranges must be non-overlapping and collectively exhaustive. + pub version_range: Option<&'static str>, /// Glob patterns (space-separated) for matching files. pub patterns: &'static str, /// When any of these named checks are active, exclude their patterns from @@ -61,7 +69,9 @@ pub fn builtin() -> Vec { Check { name: "shellcheck", bin_name: "shellcheck", + mise_tool_name: None, patterns: "*.sh *.bash *.bats", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -73,7 +83,9 @@ pub fn builtin() -> Vec { Check { name: "shfmt", bin_name: "shfmt", + mise_tool_name: None, patterns: "*.sh *.bash", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -85,7 +97,9 @@ pub fn builtin() -> Vec { Check { name: "markdownlint", bin_name: "markdownlint", + mise_tool_name: None, patterns: "*.md", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -97,7 +111,9 @@ pub fn builtin() -> Vec { Check { name: "prettier", bin_name: "prettier", + mise_tool_name: None, patterns: "*.md *.yml *.yaml", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -109,7 +125,9 @@ pub fn builtin() -> Vec { Check { name: "actionlint", bin_name: "actionlint", + mise_tool_name: None, patterns: ".github/workflows/*.yml .github/workflows/*.yaml", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -121,7 +139,9 @@ pub fn builtin() -> Vec { Check { name: "hadolint", bin_name: "hadolint", + mise_tool_name: None, patterns: "Dockerfile Dockerfile.* *.dockerfile", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -133,7 +153,9 @@ pub fn builtin() -> Vec { Check { name: "codespell", bin_name: "codespell", + mise_tool_name: None, patterns: "*", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -145,6 +167,8 @@ pub fn builtin() -> Vec { Check { name: "ec", bin_name: "ec", + mise_tool_name: None, + version_range: None, patterns: "*", // Defer to formatters that enforce line length — those are the ones // that conflict with ec's max_line_length editorconfig check. @@ -159,7 +183,9 @@ pub fn builtin() -> Vec { Check { name: "golangci-lint", bin_name: "golangci-lint", + mise_tool_name: None, patterns: "*.go", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -171,7 +197,9 @@ pub fn builtin() -> Vec { Check { name: "ruff", bin_name: "ruff", + mise_tool_name: None, patterns: "*.py", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -183,7 +211,9 @@ pub fn builtin() -> Vec { Check { name: "ruff-format", bin_name: "ruff", + mise_tool_name: None, patterns: "*.py", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -195,7 +225,9 @@ pub fn builtin() -> Vec { Check { name: "biome", bin_name: "biome", + mise_tool_name: None, patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -207,7 +239,9 @@ pub fn builtin() -> Vec { Check { name: "biome-format", bin_name: "biome", + mise_tool_name: None, patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", + version_range: None, excludes_if_active: &[], slow: false, kind: CheckKind::Template { @@ -219,6 +253,8 @@ pub fn builtin() -> Vec { Check { name: "cargo-clippy", bin_name: "cargo-clippy", + mise_tool_name: Some("rust"), + version_range: None, patterns: "*.rs", excludes_if_active: &[], slow: false, @@ -231,6 +267,8 @@ pub fn builtin() -> Vec { Check { name: "cargo-fmt", bin_name: "cargo-fmt", + mise_tool_name: Some("rust"), + version_range: None, patterns: "*.rs", excludes_if_active: &[], slow: false, @@ -243,6 +281,8 @@ pub fn builtin() -> Vec { Check { name: "links", bin_name: "lychee", + mise_tool_name: None, + version_range: None, patterns: "", excludes_if_active: &[], slow: false, @@ -251,6 +291,8 @@ pub fn builtin() -> Vec { Check { name: "renovate-deps", bin_name: "renovate", + mise_tool_name: None, + version_range: None, patterns: "", excludes_if_active: &[], slow: true, @@ -258,3 +300,36 @@ pub fn builtin() -> Vec { }, ] } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// If any entry for a bin_name declares a version_range, every entry for that + /// bin_name must declare one. A mix of ranged and unranged entries for the same + /// binary is ambiguous — it would be impossible to guarantee exactly one activates. + /// (Multiple unranged entries for the same binary are fine: they're different + /// subcommand invocations of the same tool, e.g. `biome check` vs `biome format`.) + #[test] + fn version_ranges_must_not_be_mixed_with_unranged_entries() { + let registry = builtin(); + let mut by_bin: HashMap<&str, Vec<&Check>> = HashMap::new(); + for check in ®istry { + by_bin.entry(check.bin_name).or_default().push(check); + } + for (bin, checks) in &by_bin { + let any_ranged = checks.iter().any(|c| c.version_range.is_some()); + if any_ranged { + for check in checks { + assert!( + check.version_range.is_some(), + "check '{}' shares bin_name '{}' with version-ranged entries but has no version_range", + check.name, + bin, + ); + } + } + } + } +} diff --git a/src/runner.rs b/src/runner.rs index 0538d59..3eb0581 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -354,6 +354,8 @@ mod tests { Check { name: "test", bin_name: "test-bin", + mise_tool_name: None, + version_range: None, patterns, excludes_if_active: &[], slow: false, diff --git a/tests/e2e.rs b/tests/e2e.rs index f32bc4c..7f1d0e3 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -29,6 +29,20 @@ fn git_repo() -> TempDir { dir } +/// Writes a minimal mise.toml declaring the given tools so flint's availability +/// check passes. Version strings are arbitrary — these checks have no version_range. +fn write_mise_toml(repo: &TempDir, tools: &[&str]) { + let entries: String = tools + .iter() + .map(|t| format!("{t} = \"latest\"\n")) + .collect(); + std::fs::write( + repo.path().join("mise.toml"), + format!("[tools]\n{entries}"), + ) + .unwrap(); +} + // Helper to stage a file so it appears in `git ls-files` (used by --full). fn stage(path: &Path, content: &str, repo: &Path) { std::fs::write(path, content).unwrap(); @@ -42,6 +56,7 @@ fn stage(path: &Path, content: &str, repo: &Path) { #[test] fn shellcheck_failure_shows_check_name_header() { let repo = git_repo(); + write_mise_toml(&repo, &["shellcheck"]); // SC2086: unquoted variable — reliable shellcheck violation. stage( @@ -67,6 +82,7 @@ fn shellcheck_failure_shows_check_name_header() { #[test] fn cargo_fmt_diff_shows_check_name_header() { let repo = git_repo(); + write_mise_toml(&repo, &["rust"]); // Minimal Cargo project with a badly formatted Rust file. std::fs::write( @@ -100,6 +116,7 @@ fn cargo_fmt_diff_shows_check_name_header() { #[test] fn auto_fixes_and_reports_summary() { let repo = git_repo(); + write_mise_toml(&repo, &["rust"]); // Poorly formatted Rust — cargo-fmt is fixable. std::fs::write( @@ -140,6 +157,7 @@ fn auto_fixes_and_reports_summary() { #[test] fn auto_reports_unfixable_as_review() { let repo = git_repo(); + write_mise_toml(&repo, &["shellcheck"]); // SC2086: unquoted variable — shellcheck violation with no auto-fix. stage( @@ -169,6 +187,7 @@ fn auto_reports_unfixable_as_review() { #[test] fn shellcheck_clean_script_passes() { let repo = git_repo(); + write_mise_toml(&repo, &["shellcheck"]); // A well-formed shell script — no violations. stage( From fc540f1b9414045bf959c3ad0a152344c5862a98 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 20:34:32 +0000 Subject: [PATCH 024/141] refactor: builder pattern for registry, move linters to src/linters/, rename to lychee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Builder API for Check: Check::file/files/project/special constructors with .fix(), .bin(), .mise_tool(), .excludes(), .slow() modifiers. builtin() shrinks from 235 lines to 17. - patterns changed from space-separated &str to &[&str] - src/links.rs → src/linters/lychee.rs - src/renovate_deps.rs → src/linters/renovate_deps.rs - "links" check renamed to "lychee"; [checks.links] → [checks.lychee] in flint.toml - LinksConfig renamed to LycheeConfig - Slogan updated to "flint — fast lint" Signed-off-by: Gregor Zeitlinger --- flint.toml | 2 +- src/config.rs | 4 +- src/{links.rs => linters/lychee.rs} | 4 +- src/linters/mod.rs | 2 + src/{ => linters}/renovate_deps.rs | 2 +- src/main.rs | 11 +- src/registry.rs | 341 +++++++++------------------- src/runner.rs | 27 +-- 8 files changed, 137 insertions(+), 256 deletions(-) rename src/{links.rs => linters/lychee.rs} (99%) create mode 100644 src/linters/mod.rs rename src/{ => linters}/renovate_deps.rs (95%) diff --git a/flint.toml b/flint.toml index 6587884..08fc83b 100644 --- a/flint.toml +++ b/flint.toml @@ -2,7 +2,7 @@ base_branch = "main" exclude = "CHANGELOG\\.md|\\.github/renovate-tracked-deps\\.json" -[checks.links] +[checks.lychee] config = ".github/config/lychee.toml" check_all_local = true diff --git a/src/config.rs b/src/config.rs index 7d0cef6..05fcb85 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,14 +28,14 @@ impl Default for Settings { #[derive(Debug, Default, Deserialize, Clone)] #[serde(default)] pub struct ChecksConfig { - pub links: LinksConfig, + pub lychee: LycheeConfig, #[serde(rename = "renovate-deps")] pub renovate_deps: RenovateDepsConfig, } #[derive(Debug, Default, Deserialize, Clone)] #[serde(default)] -pub struct LinksConfig { +pub struct LycheeConfig { pub config: Option, pub check_all_local: bool, } diff --git a/src/links.rs b/src/linters/lychee.rs similarity index 99% rename from src/links.rs rename to src/linters/lychee.rs index 812978a..9278f40 100644 --- a/src/links.rs +++ b/src/linters/lychee.rs @@ -2,11 +2,11 @@ use std::path::Path; use std::process::Stdio; use tokio::process::Command; -use crate::config::LinksConfig; +use crate::config::LycheeConfig; use crate::files::FileList; pub async fn run( - cfg: &LinksConfig, + cfg: &LycheeConfig, file_list: &FileList, project_root: &Path, ) -> (bool, Vec, Vec) { diff --git a/src/linters/mod.rs b/src/linters/mod.rs new file mode 100644 index 0000000..325b766 --- /dev/null +++ b/src/linters/mod.rs @@ -0,0 +1,2 @@ +pub mod lychee; +pub mod renovate_deps; diff --git a/src/renovate_deps.rs b/src/linters/renovate_deps.rs similarity index 95% rename from src/renovate_deps.rs rename to src/linters/renovate_deps.rs index 8031473..5c62c69 100644 --- a/src/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -4,7 +4,7 @@ use tokio::process::Command; use crate::config::RenovateDepsConfig; -const SCRIPT: &str = include_str!("../tasks/lint/renovate-deps.py"); +const SCRIPT: &str = include_str!("../../tasks/lint/renovate-deps.py"); pub async fn run( cfg: &RenovateDepsConfig, diff --git a/src/main.rs b/src/main.rs index 0d9208b..e8aaca2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,7 @@ +mod linters; mod config; mod files; -mod links; mod registry; -mod renovate_deps; mod runner; use anyhow::Result; @@ -10,7 +9,7 @@ use clap::{Parser, Subcommand}; use std::collections::HashMap; #[derive(Parser, Debug)] -#[command(name = "flint", about = "mise-native lint orchestrator")] +#[command(name = "flint", about = "flint — fast lint")] #[command(args_conflicts_with_subcommands = true)] struct Cli { #[command(subcommand)] @@ -310,7 +309,7 @@ fn print_list(registry: &[registry::Check], mise_tools: &HashMap .max(4); let bin_w = registry .iter() - .map(|c| c.bin().len()) + .map(|c| c.bin_name.len()) .max() .unwrap_or(6) .max(6); @@ -338,10 +337,10 @@ fn print_list(registry: &[registry::Check], mise_tools: &HashMap println!( "{:, - /// Glob patterns (space-separated) for matching files. - pub patterns: &'static str, + /// Glob patterns for matching files. + pub patterns: &'static [&'static str], /// When any of these named checks are active, exclude their patterns from /// this check's file list. Used to avoid double-checking files that a /// dedicated formatter already owns. @@ -50,11 +50,6 @@ pub struct Check { } impl Check { - /// The binary name used to check PATH availability. - pub fn bin(&self) -> &str { - self.bin_name - } - pub fn has_fix(&self) -> bool { match &self.kind { CheckKind::Template { fix_cmd, .. } => !fix_cmd.is_empty(), @@ -62,242 +57,130 @@ impl Check { CheckKind::Special(SpecialKind::RenovateDeps) => true, } } -} -pub fn builtin() -> Vec { - vec![ - Check { - name: "shellcheck", - bin_name: "shellcheck", - mise_tool_name: None, - patterns: "*.sh *.bash *.bats", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "shellcheck {FILE}", - fix_cmd: "", - scope: Scope::File, - }, - }, - Check { - name: "shfmt", - bin_name: "shfmt", - mise_tool_name: None, - patterns: "*.sh *.bash", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "shfmt -d {FILE}", - fix_cmd: "shfmt -w {FILE}", - scope: Scope::File, - }, - }, - Check { - name: "markdownlint", - bin_name: "markdownlint", - mise_tool_name: None, - patterns: "*.md", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "markdownlint {FILE}", - fix_cmd: "markdownlint --fix {FILE}", - scope: Scope::File, - }, - }, - Check { - name: "prettier", - bin_name: "prettier", - mise_tool_name: None, - patterns: "*.md *.yml *.yaml", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "prettier --check {FILES}", - fix_cmd: "prettier --write {FILES}", - scope: Scope::Files, - }, - }, - Check { - name: "actionlint", - bin_name: "actionlint", - mise_tool_name: None, - patterns: ".github/workflows/*.yml .github/workflows/*.yaml", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "actionlint {FILE}", - fix_cmd: "", - scope: Scope::File, - }, - }, - Check { - name: "hadolint", - bin_name: "hadolint", - mise_tool_name: None, - patterns: "Dockerfile Dockerfile.* *.dockerfile", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "hadolint {FILE}", - fix_cmd: "", - scope: Scope::File, - }, - }, - Check { - name: "codespell", - bin_name: "codespell", - mise_tool_name: None, - patterns: "*", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "codespell {FILES}", - fix_cmd: "codespell --write-changes {FILES}", - scope: Scope::Files, - }, - }, - Check { - name: "ec", - bin_name: "ec", - mise_tool_name: None, - version_range: None, - patterns: "*", - // Defer to formatters that enforce line length — those are the ones - // that conflict with ec's max_line_length editorconfig check. - excludes_if_active: &["cargo-fmt", "ruff-format", "biome-format", "prettier"], - slow: false, - kind: CheckKind::Template { - check_cmd: "ec {FILES}", - fix_cmd: "", - scope: Scope::Files, - }, - }, + // --- Constructors --- + + /// Check invoked once per matched file (`{FILE}`). `name` is also used as `bin_name`. + pub fn file(name: &'static str, check_cmd: &'static str, patterns: &'static [&'static str]) -> Self { + Self::template(name, patterns, check_cmd, Scope::File) + } + + /// Check invoked once with all matched files (`{FILES}`). `name` is also used as `bin_name`. + pub fn files(name: &'static str, check_cmd: &'static str, patterns: &'static [&'static str]) -> Self { + Self::template(name, patterns, check_cmd, Scope::Files) + } + + /// Check invoked once per project (no file args). `name` is also used as `bin_name`. + pub fn project(name: &'static str, check_cmd: &'static str, patterns: &'static [&'static str]) -> Self { + Self::template(name, patterns, check_cmd, Scope::Project) + } + + fn template( + name: &'static str, + patterns: &'static [&'static str], + check_cmd: &'static str, + scope: Scope, + ) -> Self { Check { - name: "golangci-lint", - bin_name: "golangci-lint", + name, + bin_name: name, mise_tool_name: None, - patterns: "*.go", version_range: None, + patterns, excludes_if_active: &[], slow: false, kind: CheckKind::Template { - check_cmd: "golangci-lint run --new-from-rev={MERGE_BASE}", + check_cmd, fix_cmd: "", - scope: Scope::Project, - }, - }, - Check { - name: "ruff", - bin_name: "ruff", - mise_tool_name: None, - patterns: "*.py", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "ruff check {FILE}", - fix_cmd: "ruff check --fix {FILE}", - scope: Scope::File, - }, - }, - Check { - name: "ruff-format", - bin_name: "ruff", - mise_tool_name: None, - patterns: "*.py", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "ruff format --check {FILE}", - fix_cmd: "ruff format {FILE}", - scope: Scope::File, - }, - }, - Check { - name: "biome", - bin_name: "biome", - mise_tool_name: None, - patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "biome check {FILE}", - fix_cmd: "biome check --fix {FILE}", - scope: Scope::File, - }, - }, - Check { - name: "biome-format", - bin_name: "biome", - mise_tool_name: None, - patterns: "*.json *.jsonc *.js *.ts *.jsx *.tsx", - version_range: None, - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "biome format {FILE}", - fix_cmd: "biome format --write {FILE}", - scope: Scope::File, - }, - }, - Check { - name: "cargo-clippy", - bin_name: "cargo-clippy", - mise_tool_name: Some("rust"), - version_range: None, - patterns: "*.rs", - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "cargo clippy -q -- -D warnings", - fix_cmd: "cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings", - scope: Scope::Project, + scope, }, - }, - Check { - name: "cargo-fmt", - bin_name: "cargo-fmt", - mise_tool_name: Some("rust"), - version_range: None, - patterns: "*.rs", - excludes_if_active: &[], - slow: false, - kind: CheckKind::Template { - check_cmd: "cargo fmt -- --check", - fix_cmd: "cargo fmt", - scope: Scope::Project, - }, - }, + } + } + + /// Special check with custom logic (not a simple command template). + pub fn special(name: &'static str, bin_name: &'static str, kind: SpecialKind) -> Self { Check { - name: "links", - bin_name: "lychee", + name, + bin_name, mise_tool_name: None, version_range: None, - patterns: "", + patterns: &[], excludes_if_active: &[], slow: false, - kind: CheckKind::Special(SpecialKind::Links), - }, - Check { - name: "renovate-deps", - bin_name: "renovate", - mise_tool_name: None, - version_range: None, - patterns: "", - excludes_if_active: &[], - slow: true, - kind: CheckKind::Special(SpecialKind::RenovateDeps), - }, + kind: CheckKind::Special(kind), + } + } + + // --- Modifiers --- + + /// Override `bin_name` when the binary name differs from the check name + /// (e.g. `ruff-format` invokes `ruff`). + pub fn bin(mut self, bin_name: &'static str) -> Self { + self.bin_name = bin_name; + self + } + + /// Set the mise.toml tool key when the binary ships as part of a toolchain + /// (e.g. `cargo-fmt` ships with `rust`). + pub fn mise_tool(mut self, name: &'static str) -> Self { + self.mise_tool_name = Some(name); + self + } + + /// Add a fix command (auto-fix mode). + pub fn fix(mut self, fix_cmd: &'static str) -> Self { + if let CheckKind::Template { fix_cmd: ref mut f, .. } = self.kind { + *f = fix_cmd; + } + self + } + + /// Restrict activation to a semver range of the declared tool version. + #[allow(dead_code)] + pub fn version_req(mut self, range: &'static str) -> Self { + self.version_range = Some(range); + self + } + + /// Skip files already owned by the named checks (avoids double-checking). + pub fn excludes(mut self, names: &'static [&'static str]) -> Self { + self.excludes_if_active = names; + self + } + + /// Mark as slow — skipped when `--fast` is passed. + pub fn slow(mut self) -> Self { + self.slow = true; + self + } +} + +pub fn builtin() -> Vec { + vec![ + Check::file("shellcheck", "shellcheck {FILE}", &["*.sh", "*.bash", "*.bats"]), + Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]).fix("shfmt -w {FILE}"), + Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]).fix("markdownlint --fix {FILE}"), + Check::files("prettier", "prettier --check {FILES}", &["*.md", "*.yml", "*.yaml"]).fix("prettier --write {FILES}"), + Check::file("actionlint", "actionlint {FILE}", &[".github/workflows/*.yml", ".github/workflows/*.yaml"]), + Check::file("hadolint", "hadolint {FILE}", &["Dockerfile", "Dockerfile.*", "*.dockerfile"]), + Check::files("codespell", "codespell {FILES}", &["*"]).fix("codespell --write-changes {FILES}"), + // Defer to formatters that enforce line length — those are the ones + // that conflict with ec's max_line_length editorconfig check. + Check::files("ec", "ec {FILES}", &["*"]) + .excludes(&["cargo-fmt", "ruff-format", "biome-format", "prettier"]), + Check::project("golangci-lint", "golangci-lint run --new-from-rev={MERGE_BASE}", &["*.go"]), + Check::file("ruff", "ruff check {FILE}", &["*.py"]).fix("ruff check --fix {FILE}"), + Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]).bin("ruff").fix("ruff format {FILE}"), + Check::file("biome", "biome check {FILE}", &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"]).fix("biome check --fix {FILE}"), + Check::file("biome-format", "biome format {FILE}", &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"]).bin("biome").fix("biome format --write {FILE}"), + Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) + .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") + .mise_tool("rust"), + Check::project("cargo-fmt", "cargo fmt -- --check", &["*.rs"]) + .fix("cargo fmt") + .mise_tool("rust"), + Check::special("lychee", "lychee", SpecialKind::Links), + Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps).slow(), ] } diff --git a/src/runner.rs b/src/runner.rs index 3eb0581..2924bf6 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -4,10 +4,10 @@ use std::process::Stdio; use tokio::process::Command; use tokio::task::JoinSet; +use crate::linters::{lychee, renovate_deps}; use crate::config::Config; use crate::files::FileList; use crate::registry::{Check, CheckKind, Scope, SpecialKind}; -use crate::{links, renovate_deps}; pub async fn run( checks: &[&Check], @@ -33,7 +33,7 @@ pub async fn run( run_invocations(&check_name, &invocations, project_root).await } CheckKind::Special(SpecialKind::Links) => { - links::run(&cfg.checks.links, file_list, project_root).await + lychee::run(&cfg.checks.lychee, file_list, project_root).await } CheckKind::Special(SpecialKind::RenovateDeps) => { renovate_deps::run(&cfg.checks.renovate_deps, fix, project_root).await @@ -73,13 +73,13 @@ pub async fn run( }); } CheckKind::Special(SpecialKind::Links) => { - let links_cfg = cfg.checks.links.clone(); + let links_cfg = cfg.checks.lychee.clone(); let fl = file_list.clone(); let root = project_root.to_path_buf(); let name = check_name.clone(); set.spawn(async move { - let (ok, stdout, stderr) = links::run(&links_cfg, &fl, &root).await; + let (ok, stdout, stderr) = lychee::run(&links_cfg, &fl, &root).await; if verbose { flush_output(&stdout, &stderr); } @@ -150,15 +150,14 @@ fn build_invocations( let excludes: Vec<&str> = active_checks .iter() .filter(|c| check.excludes_if_active.contains(&c.name)) - .flat_map(|c| c.patterns.split_whitespace()) + .flat_map(|c| c.patterns.iter().copied()) .collect(); match scope { Scope::Project => { // If patterns are set, only run when relevant files are present. if !check.patterns.is_empty() { - let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); - if match_files(&file_list.files, &patterns, &excludes, project_root).is_empty() { + if match_files(&file_list.files, check.patterns, &excludes, project_root).is_empty() { return vec![]; } } @@ -167,8 +166,7 @@ fn build_invocations( } Scope::File => { - let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); - let matched = match_files(&file_list.files, &patterns, &excludes, project_root); + let matched = match_files(&file_list.files, check.patterns, &excludes, project_root); matched .iter() .map(|f| { @@ -179,8 +177,7 @@ fn build_invocations( } Scope::Files => { - let patterns: Vec<&str> = check.patterns.split_whitespace().collect(); - let matched = match_files(&file_list.files, &patterns, &excludes, project_root); + let matched = match_files(&file_list.files, check.patterns, &excludes, project_root); if matched.is_empty() { return vec![]; } @@ -350,7 +347,7 @@ mod tests { use crate::registry::{Check, CheckKind, Scope}; use std::path::PathBuf; - fn project_check(patterns: &'static str) -> Check { + fn project_check(patterns: &'static [&'static str]) -> Check { Check { name: "test", bin_name: "test-bin", @@ -379,14 +376,14 @@ mod tests { #[test] fn project_scope_skips_when_no_matching_files() { - let check = project_check("*.rs"); + let check = project_check(&["*.rs"]); let fl = file_list(&["foo.py", "bar.md"]); assert!(build_invocations(&check, &fl, false, Path::new("/repo"), &[]).is_empty()); } #[test] fn project_scope_runs_when_matching_files_present() { - let check = project_check("*.rs"); + let check = project_check(&["*.rs"]); let fl = file_list(&["src/main.rs", "foo.py"]); let inv = build_invocations(&check, &fl, false, Path::new("/repo"), &[]); assert_eq!(inv, vec![vec!["run-it".to_string()]]); @@ -394,7 +391,7 @@ mod tests { #[test] fn project_scope_empty_patterns_always_runs() { - let check = project_check(""); + let check = project_check(&[]); let fl = file_list(&["foo.py"]); let inv = build_invocations(&check, &fl, false, Path::new("/repo"), &[]); assert_eq!(inv, vec![vec!["run-it".to_string()]]); From f61520e0a7746446b056f2bfd1fa7ec83bec9c8d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 20:47:53 +0000 Subject: [PATCH 025/141] docs: split AGENTS.md into v1/v2, add v2 architecture guide AGENTS.md becomes a short hub. v1 content moves to AGENTS-V1.md unchanged. AGENTS-V2.md covers the Rust module map, builder pattern for adding linters, key design decisions (ec deference, markdownlint+prettier MD013 conflict), and e2e testing approach. Signed-off-by: Gregor Zeitlinger --- AGENTS-V1.md | 158 +++++++++++++++++++++++++++++++++++++++++++++++++++ AGENTS-V2.md | 151 ++++++++++++++++++++++++++++++++++++++++++++++++ AGENTS.md | 158 ++------------------------------------------------- 3 files changed, 315 insertions(+), 152 deletions(-) create mode 100644 AGENTS-V1.md create mode 100644 AGENTS-V2.md diff --git a/AGENTS-V1.md b/AGENTS-V1.md new file mode 100644 index 0000000..e414eab --- /dev/null +++ b/AGENTS-V1.md @@ -0,0 +1,158 @@ +# AGENTS-V1.md + +Guidance for working on flint v1 — the bash task scripts. +For v2 (Rust binary), see [AGENTS-V2.md](AGENTS-V2.md). + +## Repository Overview + +The v1 scripts live under `tasks/lint/`. They are designed to +be consumed as HTTP remote tasks in other repositories' +`mise.toml` files, not run directly in this repository. + +## Architecture + +### Task Script Design Pattern + +All task scripts follow these conventions: + +- **Environment**: Scripts expect `MISE_PROJECT_ROOT` to be + set (automatically provided by mise) +- **Metadata**: Shell scripts use `#MISE` comments for + metadata; Python scripts use `# [MISE]` comments +- **Usage args**: Shell scripts use `#USAGE` comments to + define CLI arguments that mise parses +- **Exit behavior**: Scripts exit with non-zero on errors + for CI integration +- **AUTOFIX mode**: All lint scripts check the `AUTOFIX` + environment variable. When `AUTOFIX=true`, linters that + support fixing issues will automatically apply fixes; + linters without fix capabilities silently ignore it. + This allows consuming repos to run all lints with + `AUTOFIX=true` via a single task (e.g., `mise run fix`) + without needing per-linter configuration + +### Script Categories + +**`tasks/lint/`** - Linting validators: + +- `super-linter.sh`: Runs Super-Linter via Docker/Podman, + auto-detects runtime, handles SELinux on Fedora. + `--native` flag runs a **subset** of linters directly + on the host for fast local feedback (not a full + replacement for the container — CI uses the full set). + `--full` flag lints all files instead of only changed + files (applies to both native and container modes) +- `links.sh`: Runs lychee link checker with two default + checks (all links in modified files + local links in all + files) and a `--full` flag for comprehensive checking +- `renovate-deps.py`: Verifies + `.github/renovate-tracked-deps.json` is up to date by + running Renovate locally and parsing its debug logs. + With `AUTOFIX=true`, automatically regenerates and + updates the file + +### Key Design Decisions + +1. **Container runtime detection**: `super-linter.sh` tries + podman first (with SELinux "z" mount flag), + falls back to Docker. With `--native`, the container + runtime is bypassed entirely and linters run directly + on the host +2. **AUTOFIX mode**: Lint scripts that support fixing accept + `--autofix` flag and `AUTOFIX` env var for unified fix + workflows: + - `super-linter.sh`: Filters out `FIX_*` env vars + unless autofix is enabled + - `renovate-deps.py`: Automatically regenerates and + updates `.github/renovate-tracked-deps.json` + when autofix is enabled + - `links.sh`: Silently ignores the `AUTOFIX` env var + (lychee has no autofix capability; + no `--autofix` flag is exposed) + - The `AUTOFIX` env var is how the `fix` meta-task + propagates autofix through the dependency chain +3. **Diff-based link checking**: `links.sh` runs two checks + by default (all links in modified files + local links in + all files), use `--full` to check all links in all files; + falls back to `--full` when config changes +4. **Renovate exclusions**: `RENOVATE_TRACKED_DEPS_EXCLUDE` + allows skipping managers like + `github-actions,github-runners` +5. **Consuming repos provide config**: Scripts reference + config files (`.github/config/super-linter.env`, + `.github/config/lychee.toml`) that consuming repos + must provide + +## Testing Changes + +Since these are remote task scripts consumed by other repos: + +1. Test changes by pointing a consuming repo's `mise.toml` + to a local file path or Git branch +2. Verify scripts work with both Docker and Podman +3. Test with and without `AUTOFIX=true`: + - `super-linter.sh`: Verify `FIX_*` vars are filtered + correctly + - `renovate-deps.py`: Verify it regenerates and updates + the file + - Link linters: Verify they run normally and don't + output warnings +4. For Renovate scripts, ensure they handle missing deps + gracefully + +## Native Mode Tips + +For faster native linting, consider switching +`super-linter.env` from a deny-list +(`VALIDATE_X=false` for each unwanted linter) to an +allow-list (only `VALIDATE_X=true` for linters you +want). Super-linter's logic — and native mode — treats +any explicit `VALIDATE_*=true` as "only run these". +This avoids noise from linters like `golangci-lint` +running on non-Go repos. + +After updating the super-linter version in `mise.toml`, +run `mise run setup:native-lint-tools` on the host to +install matching tool versions. Native mode fails if +enabled tools are missing. + +**Config files:** Native mode requires linter configs at +standard locations (project root), not in +`.github/linters/` (super-linter's convention). The +script errors if `.github/linters/` exists. All +supported linters auto-discover their config: +`textlint`→`.textlintrc`, +`shellcheck`→`.shellcheckrc`, +`markdownlint`→`.markdownlint.json`, +`ec` (editorconfig-checker)→`.ecrc`, +`actionlint`→`.github/actionlint.yml`, +`hadolint`→`.hadolint.yaml`, +`golangci-lint`→`.golangci.yml`, +`ruff`→`ruff.toml`/`pyproject.toml`, +`codespell`→`.codespellrc`/`pyproject.toml`, +`biome`→`biome.json`, +`prettier`→`.prettierrc`, +`shfmt`→`.editorconfig`. + +## Adding New Linters + +When adding new lint scripts, follow these patterns: + +1. **Add AUTOFIX support**: Check for `AUTOFIX` env var and + implement fix behavior if the underlying tool supports it +2. **Silent fallback**: If the tool doesn't support autofix, + silently ignore `AUTOFIX` (no warnings or errors) +3. **Consistent behavior**: Ensure the script works the same + whether `AUTOFIX` is set or not for check-only tools +4. **Document support**: Update README.md table to show + whether AUTOFIX is supported + +## Script Conventions + +- Shell scripts use `set -euo pipefail` for safety +- Python scripts check for `MISE_PROJECT_ROOT` and exit + with clear error if missing +- Use `# shellcheck disable=` with justification when + intentionally violating shellcheck rules +- Python scripts use `sys.exit(1)` on errors, print errors + to stderr diff --git a/AGENTS-V2.md b/AGENTS-V2.md new file mode 100644 index 0000000..730fb8c --- /dev/null +++ b/AGENTS-V2.md @@ -0,0 +1,151 @@ +# AGENTS-V2.md + +Guidance for working on flint v2 — the Rust binary. +For v1 (bash task scripts), see [AGENTS-V1.md](AGENTS-V1.md). + +## Repository Overview + +v2 is a single Rust binary (`flint`) that discovers linting +tools from the consuming repo's `mise.toml`, runs them +against changed files in parallel, and produces identical +output locally and in CI. + +See [FLINT-V2.md](FLINT-V2.md) for usage documentation. + +## Architecture + +### Module Map + +- **`src/registry.rs`**: Static linter registry. Defines + `Check` (builder pattern) and `builtin()` which returns + the full list of built-in checks. This is where new + linters are added. +- **`src/runner.rs`**: Executes checks against a file list. + Handles parallel execution (check mode) and serial + execution (fix mode, to avoid concurrent writes). +- **`src/config.rs`**: Loads `flint.toml` from the project + root. All fields have defaults — the file is optional. +- **`src/files.rs`**: Git-aware file discovery. Returns + changed files relative to the merge base, or all files + with `--full`. +- **`src/linters/`**: Custom logic for special checks that + can't be expressed as a simple command template: + - `lychee.rs`: Link checking orchestration + - `renovate_deps.rs`: Renovate snapshot verification +- **`src/main.rs`**: CLI parsing (clap), orchestration, + output formatting. +- **`tests/e2e.rs`**: End-to-end tests. Spin up a temp git + repo, write files, run the flint binary, assert on + stdout/stderr and exit code. + +### Check Kinds + +A `Check` is either a `Template` (a command string with +`{FILE}`, `{FILES}`, or `{MERGE_BASE}` placeholders) or a +`Special` (custom Rust logic in `src/linters/`). + +Template scopes: + +- `File` — invoked once per matched file (`{FILE}`) +- `Files` — invoked once with all matched files (`{FILES}`) +- `Project` — invoked once with no file args; skipped + entirely if no matching files changed + +### Adding a New Linter + +Add an entry to `builtin()` in `src/registry.rs` using the +builder pattern: + +```rust +// File scope — invoked per file +Check::file("mytool", "mytool --check {FILE}", &["*.ext"]) + .fix("mytool --fix {FILE}"), + +// Files scope — invoked once with all matched files +Check::files("mytool", "mytool {FILES}", &["*.ext"]) + .fix("mytool --fix {FILES}"), + +// Project scope — invoked once, skipped if no *.ext changed +Check::project("mytool", "mytool run", &["*.ext"]), +``` + +Available builder modifiers: + +| Method | Purpose | +|---|---| +| `.fix(cmd)` | Enable `--fix` mode with this command | +| `.bin(name)` | Override binary name (when check name ≠ binary) | +| `.mise_tool(name)` | Look up availability under a different mise key (e.g. `rust` for `cargo-fmt`) | +| `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | +| `.excludes(names)` | Skip files already owned by these active checks | +| `.slow()` | Mark as slow — skipped by `--fast` | + +For checks that need custom logic (not a simple command +template), add a module under `src/linters/` and use +`CheckKind::Special`. + +### Key Design Decisions + +1. **Activation via `mise.toml`**: A check is active when + its tool (or `mise_tool_name` override) is declared in + the consuming repo's `mise.toml`. No PATH probing — + mise guarantees declared tools are on PATH. + +2. **`ec` deference**: `ec` (editorconfig-checker) runs on + all files but skips file types owned by active + line-length-enforcing formatters (`cargo-fmt`, + `ruff-format`, `biome-format`, `prettier`). Implemented + via `.excludes(&[...])` on the `ec` entry. This avoids + `ec`'s `max_line_length` check conflicting with + formatter output. + +3. **markdownlint + prettier on `*.md`**: Both checkers are + active when their tools are installed. They cover + different concerns (markdownlint: structural rules; + prettier: formatting). To avoid MD013 (line length) + conflicting with prettier's line wrapping, consuming + repos must disable MD013 in `.markdownlint.json`: + ```json + { "MD013": false } + ``` + +4. **Fix mode runs serially**: `runner.rs` runs checks in + parallel in check mode, but serially in fix mode to + avoid concurrent writes to the same file. + +5. **Version ranges**: When a `bin_name` has any + `version_range` entries, every entry for that binary + must have one (enforced by a registry unit test). This + prevents ambiguous activation when ranges don't cover + all versions. + +6. **Special checks**: `links` and `renovate-deps` have + custom orchestration logic that doesn't fit the command + template model. Their implementations live in + `src/linters/`. + +## Testing + +### Unit tests + +`src/registry.rs` has a unit test that enforces the +version-range consistency invariant. Run with: + +```bash +cargo test +``` + +### End-to-end tests + +`tests/e2e.rs` tests the full binary. Each test: + +1. Creates a temp directory initialised as a git repo + (`git_repo()`) +2. Writes a minimal `mise.toml` declaring the tools under + test (`write_mise_toml()`) +3. Writes and stages test files (`stage()`) +4. Runs `flint` via `Command` and asserts on output/exit + +When adding a new linter, add an e2e test that covers at +least: check mode failure output format, and fix mode if +the linter supports it. diff --git a/AGENTS.md b/AGENTS.md index 446aac8..1567489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,103 +3,14 @@ This file provides guidance to AI coding agents when working with code in this repository. -## Repository Overview +## Versions -This repository contains reusable mise task scripts for -linting. These scripts are designed to be consumed as HTTP -remote tasks in other repositories' `mise.toml` files, not -run directly in this repository. +This repository contains two generations of flint: -## Architecture - -### Task Script Design Pattern - -All task scripts follow these conventions: - -- **Environment**: Scripts expect `MISE_PROJECT_ROOT` to be - set (automatically provided by mise) -- **Metadata**: Shell scripts use `#MISE` comments for - metadata; Python scripts use `# [MISE]` comments -- **Usage args**: Shell scripts use `#USAGE` comments to - define CLI arguments that mise parses -- **Exit behavior**: Scripts exit with non-zero on errors - for CI integration -- **AUTOFIX mode**: All lint scripts check the `AUTOFIX` - environment variable. When `AUTOFIX=true`, linters that - support fixing issues will automatically apply fixes; - linters without fix capabilities silently ignore it. - This allows consuming repos to run all lints with - `AUTOFIX=true` via a single task (e.g., `mise run fix`) - without needing per-linter configuration - -### Script Categories - -**`tasks/lint/`** - Linting validators: - -- `super-linter.sh`: Runs Super-Linter via Docker/Podman, - auto-detects runtime, handles SELinux on Fedora. - `--native` flag runs a **subset** of linters directly - on the host for fast local feedback (not a full - replacement for the container — CI uses the full set). - `--full` flag lints all files instead of only changed - files (applies to both native and container modes) -- `links.sh`: Runs lychee link checker with two default - checks (all links in modified files + local links in all - files) and a `--full` flag for comprehensive checking -- `renovate-deps.py`: Verifies - `.github/renovate-tracked-deps.json` is up to date by - running Renovate locally and parsing its debug logs. - With `AUTOFIX=true`, automatically regenerates and - updates the file - -### Key Design Decisions - -1. **Container runtime detection**: `super-linter.sh` tries - podman first (with SELinux "z" mount flag), - falls back to Docker. With `--native`, the container - runtime is bypassed entirely and linters run directly - on the host -2. **AUTOFIX mode**: Lint scripts that support fixing accept - `--autofix` flag and `AUTOFIX` env var for unified fix - workflows: - - `super-linter.sh`: Filters out `FIX_*` env vars - unless autofix is enabled - - `renovate-deps.py`: Automatically regenerates and - updates `.github/renovate-tracked-deps.json` - when autofix is enabled - - `links.sh`: Silently ignores the `AUTOFIX` env var - (lychee has no autofix capability; - no `--autofix` flag is exposed) - - The `AUTOFIX` env var is how the `fix` meta-task - propagates autofix through the dependency chain -3. **Diff-based link checking**: `links.sh` runs two checks - by default (all links in modified files + local links in - all files), use `--full` to check all links in all files; - falls back to `--full` when config changes -4. **Renovate exclusions**: `RENOVATE_TRACKED_DEPS_EXCLUDE` - allows skipping managers like - `github-actions,github-runners` -5. **Consuming repos provide config**: Scripts reference - config files (`.github/config/super-linter.env`, - `.github/config/lychee.toml`) that consuming repos - must provide - -## Testing Changes - -Since these are remote task scripts consumed by other repos: - -1. Test changes by pointing a consuming repo's `mise.toml` - to a local file path or Git branch -2. Verify scripts work with both Docker and Podman -3. Test with and without `AUTOFIX=true`: - - `super-linter.sh`: Verify `FIX_*` vars are filtered - correctly - - `renovate-deps.py`: Verify it regenerates and updates - the file - - Link linters: Verify they run normally and don't - output warnings -4. For Renovate scripts, ensure they handle missing deps - gracefully +- **v1** (stable): reusable bash task scripts consumed as + HTTP remote tasks. See [AGENTS-V1.md](AGENTS-V1.md). +- **v2** (in development, `feat/flint-v2` branch): a single + Rust binary. See [AGENTS-V2.md](AGENTS-V2.md). ## Linting @@ -124,53 +35,6 @@ mise run lint mise run setup:pre-commit-hook ``` -## Native Mode Tips - -For faster native linting, consider switching -`super-linter.env` from a deny-list -(`VALIDATE_X=false` for each unwanted linter) to an -allow-list (only `VALIDATE_X=true` for linters you -want). Super-linter's logic — and native mode — treats -any explicit `VALIDATE_*=true` as "only run these". -This avoids noise from linters like `golangci-lint` -running on non-Go repos. - -After updating the super-linter version in `mise.toml`, -run `mise run setup:native-lint-tools` on the host to -install matching tool versions. Native mode fails if -enabled tools are missing. - -**Config files:** Native mode requires linter configs at -standard locations (project root), not in -`.github/linters/` (super-linter's convention). The -script errors if `.github/linters/` exists. All -supported linters auto-discover their config: -`textlint`→`.textlintrc`, -`shellcheck`→`.shellcheckrc`, -`markdownlint`→`.markdownlint.json`, -`ec` (editorconfig-checker)→`.ecrc`, -`actionlint`→`.github/actionlint.yml`, -`hadolint`→`.hadolint.yaml`, -`golangci-lint`→`.golangci.yml`, -`ruff`→`ruff.toml`/`pyproject.toml`, -`codespell`→`.codespellrc`/`pyproject.toml`, -`biome`→`biome.json`, -`prettier`→`.prettierrc`, -`shfmt`→`.editorconfig`. - -## Adding New Linters - -When adding new lint scripts, follow these patterns: - -1. **Add AUTOFIX support**: Check for `AUTOFIX` env var and - implement fix behavior if the underlying tool supports it -2. **Silent fallback**: If the tool doesn't support autofix, - silently ignore `AUTOFIX` (no warnings or errors) -3. **Consistent behavior**: Ensure the script works the same - whether `AUTOFIX` is set or not for check-only tools -4. **Document support**: Update README.md table to show - whether AUTOFIX is supported - ## Commit Messages This repository uses @@ -196,13 +60,3 @@ affect consumers (documentation, CI workflows, repository config). Misusing `fix:` for non-functional changes creates unnecessary releases. - -## Script Conventions - -- Shell scripts use `set -euo pipefail` for safety -- Python scripts check for `MISE_PROJECT_ROOT` and exit - with clear error if missing -- Use `# shellcheck disable=` with justification when - intentionally violating shellcheck rules -- Python scripts use `sys.exit(1)` on errors, print errors - to stderr From a2d482ca9039a7348dd0130685b93db0dccbc866 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 2 Apr 2026 21:02:04 +0000 Subject: [PATCH 026/141] =?UTF-8?q?refactor:=20simplify=20Rust=20source=20?= =?UTF-8?q?=E2=80=94=20PreparedCheck,=20async=20lychee=20helpers,=20regex?= =?UTF-8?q?=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runner.rs: extract PreparedCheck enum + prepare() to eliminate the duplicated CheckKind match across serial fix and parallel check paths - lychee.rs: make resolve_* and build_remap_args async (tokio::process::Command) to avoid blocking the executor; consolidate via run_git_output helper; push_remap() eliminates 10x repeated "--remap"/value pair pattern - files.rs: compile exclude regex once in changed(); remove p.exists() TOCTOU check (git --diff-filter=d already excludes deleted files) - registry.rs: move read_mise_tools/check_active/coerce_version here from main.rs; rename check_version_matches → check_active - main.rs: is_fixable() deduplicates the fixable/reviewable partition predicate Signed-off-by: Gregor Zeitlinger --- src/files.rs | 42 ++++----- src/linters/lychee.rs | 209 ++++++++++++++++++------------------------ src/main.rs | 74 ++------------- src/registry.rs | 63 ++++++++++++- src/runner.rs | 158 ++++++++++++++++--------------- 5 files changed, 257 insertions(+), 289 deletions(-) diff --git a/src/files.rs b/src/files.rs index 4d454df..344b576 100644 --- a/src/files.rs +++ b/src/files.rs @@ -18,24 +18,29 @@ pub fn changed( from_ref: Option<&str>, to_ref: Option<&str>, ) -> Result { + let exclude_re = compile_exclude_re(cfg); + if full { - return all_files(project_root, cfg); + return all_files(project_root, exclude_re.as_ref()); } - // Determine merge base. let merge_base = resolve_merge_base(project_root, cfg, from_ref)?; let files = if let Some(ref base) = merge_base { let to = to_ref.unwrap_or("HEAD"); - collect_changed_files(project_root, cfg, base, to)? + collect_changed_files(project_root, exclude_re.as_ref(), base, to)? } else { // No merge base (shallow clone etc.) — fall back to all files. - return all_files(project_root, cfg); + return all_files(project_root, exclude_re.as_ref()); }; Ok(FileList { files, merge_base }) } +fn compile_exclude_re(cfg: &Config) -> Option { + cfg.settings.exclude.as_deref().and_then(|pat| regex::Regex::new(pat).ok()) +} + fn resolve_merge_base( project_root: &Path, cfg: &Config, @@ -62,7 +67,7 @@ fn resolve_merge_base( fn collect_changed_files( project_root: &Path, - cfg: &Config, + exclude_re: Option<®ex::Regex>, base: &str, to: &str, ) -> Result> { @@ -82,10 +87,10 @@ fn collect_changed_files( names.insert(line); } - Ok(filter_existing(project_root, cfg, names)) + Ok(filter_names(project_root, exclude_re, names)) } -fn all_files(project_root: &Path, cfg: &Config) -> Result { +fn all_files(project_root: &Path, exclude_re: Option<®ex::Regex>) -> Result { let out = Command::new("git") .args(["ls-files"]) .current_dir(project_root) @@ -98,7 +103,7 @@ fn all_files(project_root: &Path, cfg: &Config) -> Result { .collect(); Ok(FileList { - files: filter_existing(project_root, cfg, names), + files: filter_names(project_root, exclude_re, names), merge_base: None, }) } @@ -117,27 +122,14 @@ fn git_diff_names(project_root: &Path, extra_args: &[&str]) -> Result, names: std::collections::BTreeSet, ) -> Vec { - let exclude_re: Option = cfg - .settings - .exclude - .as_deref() - .and_then(|pat| regex::Regex::new(pat).ok()); - names .into_iter() - .filter(|name| { - if let Some(re) = &exclude_re { - !re.is_match(name) - } else { - true - } - }) - .map(|name| project_root.join(&name)) - .filter(|p| p.exists()) + .filter(|name| exclude_re.map_or(true, |re| !re.is_match(name))) + .map(|name| project_root.join(name)) .collect() } diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 9278f40..5f1f51b 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -16,7 +16,7 @@ pub async fn run( .unwrap_or(".github/config/lychee.toml") .to_string(); - let remap_args = build_remap_args(project_root); + let remap_args = build_remap_args(project_root).await; // Full mode: no merge base (shallow clone or --full flag) if file_list.merge_base.is_none() { @@ -149,184 +149,149 @@ async fn run_lychee_cmd( } } -fn build_remap_args(project_root: &Path) -> Vec { +async fn build_remap_args(project_root: &Path) -> Vec { if std::env::var("LYCHEE_SKIP_GITHUB_REMAPS").as_deref() == Ok("true") { return vec![]; } - let mut args = build_global_github_args(); - args.extend(build_branch_remap_args(project_root)); + args.extend(build_branch_remap_args(project_root).await); args } fn build_global_github_args() -> Vec { - vec![ - "--remap".to_string(), - r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#L[0-9]+.*$ https://raw.githubusercontent.com/$1/$2/$3".to_string(), - "--remap".to_string(), - r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#:~:text=.*$ https://raw.githubusercontent.com/$1/$2/$3".to_string(), - "--remap".to_string(), - r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*)$ https://raw.githubusercontent.com/$1/$2/$3".to_string(), - "--remap".to_string(), - r"^https://github.com/([^/]+/[^/]+)/(issues|pull)/([0-9]+)#issuecomment-.*$ https://github.com/$1/$2/$3".to_string(), - ] + let mut args = Vec::new(); + push_remap( + &mut args, + r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#L[0-9]+.*$ https://raw.githubusercontent.com/$1/$2/$3", + ); + push_remap( + &mut args, + r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#:~:text=.*$ https://raw.githubusercontent.com/$1/$2/$3", + ); + push_remap( + &mut args, + r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*)$ https://raw.githubusercontent.com/$1/$2/$3", + ); + push_remap( + &mut args, + r"^https://github.com/([^/]+/[^/]+)/(issues|pull)/([0-9]+)#issuecomment-.*$ https://github.com/$1/$2/$3", + ); + args } -fn build_branch_remap_args(project_root: &Path) -> Vec { - let repo = match resolve_repo(project_root) { - Some(r) => r, - None => return vec![], +async fn build_branch_remap_args(project_root: &Path) -> Vec { + let Some(repo) = resolve_repo(project_root).await else { + return vec![]; }; - - let base_ref = resolve_base_ref(project_root); - - let head_ref = match resolve_head_ref(project_root) { - Some(r) => r, - None => return vec![], + let base_ref = resolve_base_ref(project_root).await; + let Some(head_ref) = resolve_head_ref(project_root).await else { + return vec![]; }; - // Skip if on the base branch if head_ref == base_ref { return vec![]; } let head_repo = std::env::var("PR_HEAD_REPO").unwrap_or_else(|_| repo.clone()); - let base_url = format!("https://github.com/{repo}"); + let mut args = Vec::new(); if head_repo == repo { - // Same-repo PR: remap to local file paths let pwd = project_root.to_string_lossy(); - vec![ - // /blob/ rule 1: line-number anchors - "--remap".to_string(), - format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1"), - // /blob/ rule 2: scroll to text fragments - "--remap".to_string(), - format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ file://{pwd}/$1"), - // /blob/ rule 3: all other blob URLs - "--remap".to_string(), - format!("^{base_url}/blob/{base_ref}/(.*)$ file://{pwd}/$1"), - // /tree/ rule 4: line-number anchors on tree URLs - "--remap".to_string(), - format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1"), - // /tree/ rule 5: non-fragment tree URLs - "--remap".to_string(), - format!("^{base_url}/tree/{base_ref}/(.*)$ file://{pwd}/$1"), - ] + push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1")); + push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ file://{pwd}/$1")); + push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*)$ file://{pwd}/$1")); + push_remap(&mut args, format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1")); + push_remap(&mut args, format!("^{base_url}/tree/{base_ref}/(.*)$ file://{pwd}/$1")); } else { - // Fork PR: remap to raw.githubusercontent.com and github.com head branch let raw_head = format!("https://raw.githubusercontent.com/{head_repo}/{head_ref}"); let head_url = format!("https://github.com/{head_repo}"); - vec![ - // /blob/ rule 1: line-number anchors - "--remap".to_string(), - format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ {raw_head}/$1"), - // /blob/ rule 2: scroll to text fragments - "--remap".to_string(), - format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ {raw_head}/$1"), - // /blob/ rule 3: all other blob URLs - "--remap".to_string(), - format!("^{base_url}/blob/{base_ref}/(.*)$ {raw_head}/$1"), - // /tree/ rule 4: line-number anchors on tree URLs - "--remap".to_string(), + push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ {raw_head}/$1")); + push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ {raw_head}/$1")); + push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*)$ {raw_head}/$1")); + push_remap( + &mut args, format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ {head_url}/tree/{head_ref}/$1"), - // /tree/ rule 5: non-fragment tree URLs - "--remap".to_string(), + ); + push_remap( + &mut args, format!("^{base_url}/tree/{base_ref}/(.*)$ {head_url}/tree/{head_ref}/$1"), - ] + ); } + + args } -fn resolve_repo(project_root: &Path) -> Option { - if let Ok(repo) = std::env::var("GITHUB_REPOSITORY") - && !repo.is_empty() - { - return Some(repo); - } +fn push_remap(args: &mut Vec, pattern: impl Into) { + args.push("--remap".to_string()); + args.push(pattern.into()); +} - let out = std::process::Command::new("git") - .args(["config", "--get", "remote.origin.url"]) +/// Runs a git command and returns its trimmed stdout, or `None` if it fails or is empty. +async fn run_git_output(project_root: &Path, args: &[&str]) -> Option { + let out = Command::new("git") + .args(args) .current_dir(project_root) .output() + .await .ok()?; - if !out.status.success() { return None; } - - let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); - parse_github_repo(&url) + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } } -fn parse_github_repo(url: &str) -> Option { - // HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo - if let Some(rest) = url.strip_prefix("https://github.com/") { - let repo = rest.trim_end_matches(".git"); - if !repo.is_empty() { - return Some(repo.to_string()); - } - } - // SSH: git@github.com:owner/repo.git or git@github.com:owner/repo - if let Some(rest) = url.strip_prefix("git@github.com:") { - let repo = rest.trim_end_matches(".git"); - if !repo.is_empty() { - return Some(repo.to_string()); - } +async fn resolve_repo(project_root: &Path) -> Option { + if let Ok(repo) = std::env::var("GITHUB_REPOSITORY") + && !repo.is_empty() + { + return Some(repo); } - None + run_git_output(project_root, &["config", "--get", "remote.origin.url"]) + .await + .and_then(|url| parse_github_repo(&url)) } -fn resolve_base_ref(project_root: &Path) -> String { +async fn resolve_base_ref(project_root: &Path) -> String { if let Ok(base) = std::env::var("GITHUB_BASE_REF") && !base.is_empty() { return base; } - - let out = std::process::Command::new("git") - .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) - .current_dir(project_root) - .output(); - - if let Ok(out) = out - && out.status.success() - { - let full = String::from_utf8_lossy(&out.stdout).trim().to_string(); - // refs/remotes/origin/main → main - if let Some(branch) = full.rsplit('/').next() - && !branch.is_empty() - { - return branch.to_string(); - } - } - - "main".to_string() + run_git_output(project_root, &["symbolic-ref", "refs/remotes/origin/HEAD"]) + .await + .as_deref() + .and_then(|s| s.rsplit('/').next()) + .map(String::from) + .unwrap_or_else(|| "main".to_string()) } -fn resolve_head_ref(project_root: &Path) -> Option { +async fn resolve_head_ref(project_root: &Path) -> Option { if let Ok(head) = std::env::var("GITHUB_HEAD_REF") && !head.is_empty() { return Some(head); } + run_git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]).await +} - let out = std::process::Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(project_root) - .output() - .ok()?; - - if !out.status.success() { - return None; +fn parse_github_repo(url: &str) -> Option { + // HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo + if let Some(rest) = url.strip_prefix("https://github.com/") { + let repo = rest.trim_end_matches(".git"); + if !repo.is_empty() { + return Some(repo.to_string()); + } } - - let branch = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if branch.is_empty() { - None - } else { - Some(branch) + // SSH: git@github.com:owner/repo.git or git@github.com:owner/repo + if let Some(rest) = url.strip_prefix("git@github.com:") { + let repo = rest.trim_end_matches(".git"); + if !repo.is_empty() { + return Some(repo.to_string()); + } } + None } fn is_link_checkable(path: &Path) -> bool { diff --git a/src/main.rs b/src/main.rs index e8aaca2..358b0b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,7 +72,7 @@ async fn main() -> Result<()> { let registry = registry::builtin(); if let Some(SubCommand::List) = cli.command { - let mise_tools = read_mise_tools(&project_root); + let mise_tools = registry::read_mise_tools(&project_root); print_list(®istry, &mise_tools); return Ok(()); } @@ -98,10 +98,10 @@ async fn main() -> Result<()> { // Discover which checks are declared in the consuming repo's mise.toml, and apply // --fast filter. mise guarantees declared tools are on PATH, so no PATH check needed. - let mise_tools = read_mise_tools(&project_root); + let mise_tools = registry::read_mise_tools(&project_root); let active: Vec<®istry::Check> = checks .into_iter() - .filter(|c| check_version_matches(c, &mise_tools)) + .filter(|c| registry::check_active(c, &mise_tools)) .filter(|c| !cli.fast || !c.slow) .collect(); @@ -131,7 +131,7 @@ async fn main() -> Result<()> { .iter() .filter(|(_, ok)| !ok) .map(|(name, _)| name.as_str()) - .partition(|name| active.iter().any(|c| c.name == *name && c.has_fix())); + .partition(|name| is_fixable(name, &active)); let mut fixed = vec![]; let mut fix_failed = vec![]; @@ -211,7 +211,7 @@ async fn main() -> Result<()> { let (fixable, reviewable): (Vec<&str>, Vec<&str>) = failed .iter() .copied() - .partition(|name| active.iter().any(|c| c.name == *name && c.has_fix())); + .partition(|name| is_fixable(name, &active)); let mut segments = vec![]; if !fixable.is_empty() { segments.push(format!("flint --fix {}", fixable.join(" "))); @@ -237,66 +237,8 @@ async fn main() -> Result<()> { Ok(()) } -/// Reads `[tools]` from the consuming repo's mise.toml and returns a map of -/// tool name → declared version string. -fn read_mise_tools(project_root: &std::path::Path) -> HashMap { - let path = project_root.join("mise.toml"); - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return HashMap::new(), - }; - let value: toml::Value = match toml::from_str(&content) { - Ok(v) => v, - Err(_) => return HashMap::new(), - }; - let mut tools = HashMap::new(); - if let Some(table) = value.get("tools").and_then(|v| v.as_table()) { - for (name, val) in table { - let version = match val { - toml::Value::String(s) => Some(s.clone()), - toml::Value::Table(t) => { - t.get("version").and_then(|v| v.as_str()).map(String::from) - } - _ => None, - }; - if let Some(v) = version { - tools.insert(name.clone(), v); - } - } - } - tools -} - -/// Returns true if the check's tool is declared in mise.toml and its version -/// satisfies the check's version_range (if any). -fn check_version_matches( - check: ®istry::Check, - mise_tools: &HashMap, -) -> bool { - let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); - let Some(declared) = mise_tools.get(lookup_key) else { - return false; - }; - let Some(range_str) = check.version_range else { - return true; - }; - let Ok(req) = semver::VersionReq::parse(range_str) else { - return false; - }; - coerce_version(declared).is_some_and(|v| req.matches(&v)) -} - -/// Parses a version string, padding with `.0` components if needed to satisfy -/// semver's three-part requirement (e.g. `"20"` → `20.0.0`, `"3.12"` → `3.12.0`). -fn coerce_version(s: &str) -> Option { - semver::Version::parse(s).ok().or_else(|| { - let parts = s.split('.').count(); - match parts { - 1 => semver::Version::parse(&format!("{s}.0.0")).ok(), - 2 => semver::Version::parse(&format!("{s}.0")).ok(), - _ => None, - } - }) +fn is_fixable(name: &str, active: &[®istry::Check]) -> bool { + active.iter().any(|c| c.name == name && c.has_fix()) } fn print_list(registry: &[registry::Check], mise_tools: &HashMap) { @@ -326,7 +268,7 @@ fn print_list(registry: &[registry::Check], mise_tools: &HashMap println!("{}", "-".repeat(name_w + bin_w + 35)); for check in registry { - let status = if check_version_matches(check, mise_tools) { + let status = if registry::check_active(check, mise_tools) { "active" } else if mise_tools.contains_key(check.bin_name) { "wrong version" diff --git a/src/registry.rs b/src/registry.rs index 21bf55a..3b13742 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::path::Path; + /// How a check is invoked relative to the file list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Scope { @@ -184,10 +187,68 @@ pub fn builtin() -> Vec { ] } +/// Reads `[tools]` from the consuming repo's mise.toml and returns a map of +/// tool name → declared version string. +pub fn read_mise_tools(project_root: &Path) -> HashMap { + let path = project_root.join("mise.toml"); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + let value: toml::Value = match toml::from_str(&content) { + Ok(v) => v, + Err(_) => return HashMap::new(), + }; + let mut tools = HashMap::new(); + if let Some(table) = value.get("tools").and_then(|v| v.as_table()) { + for (name, val) in table { + let version = match val { + toml::Value::String(s) => Some(s.clone()), + toml::Value::Table(t) => { + t.get("version").and_then(|v| v.as_str()).map(String::from) + } + _ => None, + }; + if let Some(v) = version { + tools.insert(name.clone(), v); + } + } + } + tools +} + +/// Returns true if the check's tool is declared in mise.toml and its version +/// satisfies the check's version_range (if any). +pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool { + let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); + let Some(declared) = mise_tools.get(lookup_key) else { + return false; + }; + let Some(range_str) = check.version_range else { + return true; + }; + let Ok(req) = semver::VersionReq::parse(range_str) else { + return false; + }; + coerce_version(declared).is_some_and(|v| req.matches(&v)) +} + +/// Parses a version string, padding with `.0` components if needed to satisfy +/// semver's three-part requirement (e.g. `"20"` → `20.0.0`, `"3.12"` → `3.12.0`). +fn coerce_version(s: &str) -> Option { + semver::Version::parse(s).ok().or_else(|| { + let parts = s.split('.').count(); + match parts { + 1 => semver::Version::parse(&format!("{s}.0.0")).ok(), + 2 => semver::Version::parse(&format!("{s}.0")).ok(), + _ => None, + } + }) +} + #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; /// If any entry for a bin_name declares a version_range, every entry for that /// bin_name must declare one. A mix of ranged and unranged entries for the same diff --git a/src/runner.rs b/src/runner.rs index 2924bf6..b0bfdf1 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -4,11 +4,45 @@ use std::process::Stdio; use tokio::process::Command; use tokio::task::JoinSet; -use crate::linters::{lychee, renovate_deps}; -use crate::config::Config; +use crate::config::{Config, LycheeConfig, RenovateDepsConfig}; use crate::files::FileList; +use crate::linters::{lychee, renovate_deps}; use crate::registry::{Check, CheckKind, Scope, SpecialKind}; +/// A check with all inputs pre-resolved, ready to execute without borrowing +/// the registry or config. Built by `prepare()` before the fix/check split. +enum PreparedCheck { + Invocations { name: String, argv_list: Vec> }, + Links { name: String, cfg: LycheeConfig, file_list: FileList }, + RenovateDeps { name: String, cfg: RenovateDepsConfig }, +} + +impl PreparedCheck { + fn name(&self) -> &str { + match self { + Self::Invocations { name, .. } + | Self::Links { name, .. } + | Self::RenovateDeps { name, .. } => name, + } + } + + async fn execute(self, fix: bool, project_root: &Path) -> (String, bool, Vec, Vec) { + let name = self.name().to_string(); + let (ok, stdout, stderr) = match self { + Self::Invocations { argv_list, .. } => { + run_invocations(&name, &argv_list, project_root).await + } + Self::Links { cfg, file_list, .. } => { + lychee::run(&cfg, &file_list, project_root).await + } + Self::RenovateDeps { cfg, .. } => { + renovate_deps::run(&cfg, fix, project_root).await + } + }; + (name, ok, stdout, stderr) + } +} + pub async fn run( checks: &[&Check], file_list: &FileList, @@ -18,91 +52,38 @@ pub async fn run( project_root: &Path, cfg: &Config, ) -> Result> { + let prepared: Vec = checks + .iter() + .filter_map(|&check| prepare(check, file_list, fix, project_root, checks, cfg)) + .collect(); + if fix { - // Serial execution in fix mode: print each check's output immediately as it finishes. let mut results = vec![]; - for &check in checks { - let check_name = check.name.to_string(); - let (ok, stdout, stderr) = match &check.kind { - CheckKind::Template { .. } => { - let invocations = - build_invocations(check, file_list, fix, project_root, checks); - if invocations.is_empty() { - continue; - } - run_invocations(&check_name, &invocations, project_root).await - } - CheckKind::Special(SpecialKind::Links) => { - lychee::run(&cfg.checks.lychee, file_list, project_root).await - } - CheckKind::Special(SpecialKind::RenovateDeps) => { - renovate_deps::run(&cfg.checks.renovate_deps, fix, project_root).await - } - }; + for task in prepared { + let name = task.name().to_string(); + let (_, ok, stdout, stderr) = task.execute(fix, project_root).await; if !short && (verbose || !ok) { - eprintln!("[{check_name}]"); + eprintln!("[{name}]"); flush_output(&stdout, &stderr); } - results.push((check_name, ok)); + results.push((name, ok)); } return Ok(results); } - // Parallel execution in check mode. let mut set: JoinSet<(String, bool, Vec, Vec)> = JoinSet::new(); - - for &check in checks { - let check_name = check.name.to_string(); - - match &check.kind { - CheckKind::Template { .. } => { - let invocations = build_invocations(check, file_list, fix, project_root, checks); - if invocations.is_empty() { - continue; - } - - let root = project_root.to_path_buf(); - let name = check_name.clone(); - - set.spawn(async move { - let (ok, stdout, stderr) = run_invocations(&name, &invocations, &root).await; - if verbose { - flush_output(&stdout, &stderr); - } - (name, ok, stdout, stderr) - }); - } - CheckKind::Special(SpecialKind::Links) => { - let links_cfg = cfg.checks.lychee.clone(); - let fl = file_list.clone(); - let root = project_root.to_path_buf(); - let name = check_name.clone(); - - set.spawn(async move { - let (ok, stdout, stderr) = lychee::run(&links_cfg, &fl, &root).await; - if verbose { - flush_output(&stdout, &stderr); - } - (name, ok, stdout, stderr) - }); - } - CheckKind::Special(SpecialKind::RenovateDeps) => { - let renov_cfg = cfg.checks.renovate_deps.clone(); - let root = project_root.to_path_buf(); - let name = check_name.clone(); - - set.spawn(async move { - let (ok, stdout, stderr) = renovate_deps::run(&renov_cfg, false, &root).await; - if verbose { - flush_output(&stdout, &stderr); - } - (name, ok, stdout, stderr) - }); + for task in prepared { + let root = project_root.to_path_buf(); + set.spawn(async move { + let (name, ok, stdout, stderr) = task.execute(false, &root).await; + if verbose { + flush_output(&stdout, &stderr); } - } + (name, ok, stdout, stderr) + }); } - // Collect all results before printing in quiet mode to avoid interleaved output. + // Collect all results before printing to avoid interleaved output. let mut collected = vec![]; while let Some(res) = set.join_next().await { collected.push(res?); @@ -123,6 +104,34 @@ pub async fn run( .collect()) } +fn prepare( + check: &Check, + file_list: &FileList, + fix: bool, + project_root: &Path, + active_checks: &[&Check], + cfg: &Config, +) -> Option { + let name = check.name.to_string(); + match &check.kind { + CheckKind::Template { .. } => { + let argv_list = build_invocations(check, file_list, fix, project_root, active_checks); + if argv_list.is_empty() { + return None; + } + Some(PreparedCheck::Invocations { name, argv_list }) + } + CheckKind::Special(SpecialKind::Links) => Some(PreparedCheck::Links { + name, + cfg: cfg.checks.lychee.clone(), + file_list: file_list.clone(), + }), + CheckKind::Special(SpecialKind::RenovateDeps) => { + Some(PreparedCheck::RenovateDeps { name, cfg: cfg.checks.renovate_deps.clone() }) + } + } +} + /// Returns the list of argv vectors to execute for a check. fn build_invocations( check: &Check, @@ -306,7 +315,6 @@ fn substitute_merge_base(cmd: &str, merge_base: Option<&str>) -> String { fn quote_path(p: &Path) -> String { let s = p.to_string_lossy(); - // Simple single-quote escaping. format!("'{}'", s.replace('\'', "'\\''")) } From 326f7d474611ae899c8c6b8e61823798f30948fe Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 12:00:58 +0200 Subject: [PATCH 027/141] feat: add FLINT_CONFIG_DIR env var to override config file directory When set, flint reads flint.toml and linter configs (e.g. lychee.toml) from the specified directory instead of the project root. Lychee config defaults to lychee.toml relative to the config dir (dropping the hardcoded .github/config/ prefix). Set FLINT_CONFIG_DIR=.github/config in mise.toml [env] to preserve existing behavior. Signed-off-by: Gregor Zeitlinger --- flint.toml | 1 - mise.toml | 3 +++ src/config.rs | 4 ++-- src/linters/lychee.rs | 17 ++++++++--------- src/main.rs | 9 ++++++++- src/runner.rs | 11 +++++++---- 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/flint.toml b/flint.toml index 08fc83b..9e8e911 100644 --- a/flint.toml +++ b/flint.toml @@ -3,7 +3,6 @@ base_branch = "main" exclude = "CHANGELOG\\.md|\\.github/renovate-tracked-deps\\.json" [checks.lychee] -config = ".github/config/lychee.toml" check_all_local = true [checks.renovate-deps] diff --git a/mise.toml b/mise.toml index 5ebfd16..a90a17e 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,6 @@ +[env] +FLINT_CONFIG_DIR = ".github/config" + [tools] lychee = "0.22.0" node = "24.14.1" diff --git a/src/config.rs b/src/config.rs index 05fcb85..432dcb7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,8 +46,8 @@ pub struct RenovateDepsConfig { pub exclude_managers: Vec, } -pub fn load(project_root: &Path) -> Result { - let path = project_root.join("flint.toml"); +pub fn load(config_dir: &Path) -> Result { + let path = config_dir.join("flint.toml"); if !path.exists() { return Ok(Config::default()); } diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 5f1f51b..63f308c 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -9,12 +9,14 @@ pub async fn run( cfg: &LycheeConfig, file_list: &FileList, project_root: &Path, + config_dir: &Path, ) -> (bool, Vec, Vec) { - let lychee_cfg = cfg - .config - .as_deref() - .unwrap_or(".github/config/lychee.toml") - .to_string(); + let lychee_cfg_raw = cfg.config.as_deref().unwrap_or("lychee.toml"); + let lychee_cfg = if Path::new(lychee_cfg_raw).is_relative() { + config_dir.join(lychee_cfg_raw).to_string_lossy().into_owned() + } else { + lychee_cfg_raw.to_string() + }; let remap_args = build_remap_args(project_root).await; @@ -31,10 +33,7 @@ pub async fn run( } // Check if lychee config is in the changed file list - let config_changed = file_list.files.iter().any(|f| { - let rel = f.strip_prefix(project_root).unwrap_or(f); - rel == Path::new(&lychee_cfg) - }); + let config_changed = file_list.files.iter().any(|f| f.as_path() == Path::new(&lychee_cfg)); if config_changed { let mut stderr = b"Config changes detected, falling back to full check.\n".to_vec(); diff --git a/src/main.rs b/src/main.rs index 358b0b1..556bae8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,10 @@ async fn main() -> Result<()> { .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::current_dir().expect("cannot determine working directory")); + let config_dir = std::env::var("FLINT_CONFIG_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| project_root.clone()); + std::env::set_current_dir(&project_root)?; let registry = registry::builtin(); @@ -77,7 +81,7 @@ async fn main() -> Result<()> { return Ok(()); } - let cfg = config::load(&project_root)?; + let cfg = config::load(&config_dir)?; // Filter registry to requested linters (or all if none specified). let checks: Vec<®istry::Check> = if cli.linters.is_empty() { @@ -124,6 +128,7 @@ async fn main() -> Result<()> { true, // suppress per-check output &project_root, &cfg, + &config_dir, ) .await?; @@ -149,6 +154,7 @@ async fn main() -> Result<()> { true, // suppress per-check output &project_root, &cfg, + &config_dir, ) .await?; for (name, ok) in fix_results { @@ -193,6 +199,7 @@ async fn main() -> Result<()> { cli.short, &project_root, &cfg, + &config_dir, ) .await?; diff --git a/src/runner.rs b/src/runner.rs index b0bfdf1..20d63b9 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -13,7 +13,7 @@ use crate::registry::{Check, CheckKind, Scope, SpecialKind}; /// the registry or config. Built by `prepare()` before the fix/check split. enum PreparedCheck { Invocations { name: String, argv_list: Vec> }, - Links { name: String, cfg: LycheeConfig, file_list: FileList }, + Links { name: String, cfg: LycheeConfig, file_list: FileList, config_dir: PathBuf }, RenovateDeps { name: String, cfg: RenovateDepsConfig }, } @@ -32,8 +32,8 @@ impl PreparedCheck { Self::Invocations { argv_list, .. } => { run_invocations(&name, &argv_list, project_root).await } - Self::Links { cfg, file_list, .. } => { - lychee::run(&cfg, &file_list, project_root).await + Self::Links { cfg, file_list, config_dir, .. } => { + lychee::run(&cfg, &file_list, project_root, &config_dir).await } Self::RenovateDeps { cfg, .. } => { renovate_deps::run(&cfg, fix, project_root).await @@ -51,10 +51,11 @@ pub async fn run( short: bool, project_root: &Path, cfg: &Config, + config_dir: &Path, ) -> Result> { let prepared: Vec = checks .iter() - .filter_map(|&check| prepare(check, file_list, fix, project_root, checks, cfg)) + .filter_map(|&check| prepare(check, file_list, fix, project_root, checks, cfg, config_dir)) .collect(); if fix { @@ -111,6 +112,7 @@ fn prepare( project_root: &Path, active_checks: &[&Check], cfg: &Config, + config_dir: &Path, ) -> Option { let name = check.name.to_string(); match &check.kind { @@ -125,6 +127,7 @@ fn prepare( name, cfg: cfg.checks.lychee.clone(), file_list: file_list.clone(), + config_dir: config_dir.to_path_buf(), }), CheckKind::Special(SpecialKind::RenovateDeps) => { Some(PreparedCheck::RenovateDeps { name, cfg: cfg.checks.renovate_deps.clone() }) From 9540bc9ec01373413fae7211fb16d6c517788c9b Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 12:24:33 +0200 Subject: [PATCH 028/141] test: trigger hook Signed-off-by: Gregor Zeitlinger --- AGENTS.md | 4 +- FLINT-V2.md | 8 ++-- mise.toml | 20 ++------- src/files.rs | 7 +++- src/linters/lychee.rs | 50 +++++++++++++++++----- src/main.rs | 41 +++++++++++------- src/registry.rs | 87 +++++++++++++++++++++++++++++++-------- src/runner.rs | 96 ++++++++++++++++++++++++++----------------- tests/e2e.rs | 11 ++--- 9 files changed, 218 insertions(+), 106 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1567489..4bdbbdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This repository contains two generations of flint: ## Linting -**Always run `mise run fix` before committing changes.** +**Always run `mise run lint:fix` before committing changes.** This ensures all files pass CI linting (Biome formatting, shellcheck, etc.). Review the auto-fixed files before committing — auto-fixes may produce unexpected results. @@ -26,7 +26,7 @@ workflow — both are optional. To install the Git hook: ```bash # Auto-fix and verify (recommended dev workflow) -mise run fix +mise run lint:fix # Verify only (same command used in CI) mise run lint diff --git a/FLINT-V2.md b/FLINT-V2.md index c400c22..34e51b7 100644 --- a/FLINT-V2.md +++ b/FLINT-V2.md @@ -171,11 +171,11 @@ flint = "0.x.y" description = "Run all lints" run = "flint" -[tasks."native-lint"] -description = "Run fast lints (skip slow checks)" -run = "flint --fast" +[tasks."lint:pre-commit"] +description = "Fast auto-fix lint pass (skips slow checks) — intended for pre-commit/pre-push hooks" +run = "flint --auto --fast" -[tasks.fix] +[tasks."lint:fix"] description = "Auto-fix lint issues" run = "flint --fix" ``` diff --git a/mise.toml b/mise.toml index a90a17e..b059e7e 100644 --- a/mise.toml +++ b/mise.toml @@ -14,6 +14,7 @@ editorconfig-checker = "v3.6.1" "npm:@biomejs/biome" = "2.3.14" "pipx:ruff" = "0.15.0" "pipx:codespell" = "2.4.1" +rust = "1.94.1" [tasks."setup:update-super-linter-versions"] description = "Generate super-linter version mapping from the super-linter repo" @@ -23,31 +24,18 @@ file = "tasks/setup/update-super-linter-versions.sh" description = "Run all lints" run = "cargo build -q && ./target/debug/flint" -[tasks.fix] +[tasks."lint:fix"] description = "Auto-fix lint issues" run = "cargo build -q && ./target/debug/flint --fix" -[tasks.native-lint] -description = "Run lints natively (fast, no renovate)" +[tasks."lint:pre-commit"] +description = "Fast auto-fix lint pass (skips slow checks like renovate) — intended for pre-commit/pre-push hooks" run = "cargo build -q && ./target/debug/flint --auto --fast" -# Rust tasks [tasks.build] description = "Build the project" run = "cargo build" -[tasks."lint:rust"] -description = "Lint Rust code (clippy)" -run = "cargo clippy -q -- -D warnings" - -[tasks.fmt] -description = "Format Rust code" -run = "cargo fmt" - -[tasks."check-fmt"] -description = "Check Rust formatting" -run = "cargo fmt -- --check" - [tasks.test] description = "Run tests" run = "cargo test -q" diff --git a/src/files.rs b/src/files.rs index 344b576..53d42c0 100644 --- a/src/files.rs +++ b/src/files.rs @@ -38,7 +38,10 @@ pub fn changed( } fn compile_exclude_re(cfg: &Config) -> Option { - cfg.settings.exclude.as_deref().and_then(|pat| regex::Regex::new(pat).ok()) + cfg.settings + .exclude + .as_deref() + .and_then(|pat| regex::Regex::new(pat).ok()) } fn resolve_merge_base( @@ -129,7 +132,7 @@ fn filter_names( ) -> Vec { names .into_iter() - .filter(|name| exclude_re.map_or(true, |re| !re.is_match(name))) + .filter(|name| exclude_re.is_none_or(|re| !re.is_match(name))) .map(|name| project_root.join(name)) .collect() } diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 63f308c..ffbb180 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -13,7 +13,10 @@ pub async fn run( ) -> (bool, Vec, Vec) { let lychee_cfg_raw = cfg.config.as_deref().unwrap_or("lychee.toml"); let lychee_cfg = if Path::new(lychee_cfg_raw).is_relative() { - config_dir.join(lychee_cfg_raw).to_string_lossy().into_owned() + config_dir + .join(lychee_cfg_raw) + .to_string_lossy() + .into_owned() } else { lychee_cfg_raw.to_string() }; @@ -33,7 +36,10 @@ pub async fn run( } // Check if lychee config is in the changed file list - let config_changed = file_list.files.iter().any(|f| f.as_path() == Path::new(&lychee_cfg)); + let config_changed = file_list + .files + .iter() + .any(|f| f.as_path() == Path::new(&lychee_cfg)); if config_changed { let mut stderr = b"Config changes detected, falling back to full check.\n".to_vec(); @@ -197,17 +203,41 @@ async fn build_branch_remap_args(project_root: &Path) -> Vec { if head_repo == repo { let pwd = project_root.to_string_lossy(); - push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1")); - push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ file://{pwd}/$1")); - push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*)$ file://{pwd}/$1")); - push_remap(&mut args, format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1")); - push_remap(&mut args, format!("^{base_url}/tree/{base_ref}/(.*)$ file://{pwd}/$1")); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ file://{pwd}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*)$ file://{pwd}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/tree/{base_ref}/(.*)$ file://{pwd}/$1"), + ); } else { let raw_head = format!("https://raw.githubusercontent.com/{head_repo}/{head_ref}"); let head_url = format!("https://github.com/{head_repo}"); - push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ {raw_head}/$1")); - push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ {raw_head}/$1")); - push_remap(&mut args, format!("^{base_url}/blob/{base_ref}/(.*)$ {raw_head}/$1")); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ {raw_head}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ {raw_head}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*)$ {raw_head}/$1"), + ); push_remap( &mut args, format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ {head_url}/tree/{head_ref}/$1"), diff --git a/src/main.rs b/src/main.rs index 556bae8..51ae89b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,12 @@ -mod linters; mod config; mod files; +mod linters; mod registry; mod runner; use anyhow::Result; use clap::{Parser, Subcommand}; +use runner::CheckResult; use std::collections::HashMap; #[derive(Parser, Debug)] @@ -132,15 +133,15 @@ async fn main() -> Result<()> { ) .await?; - let (fixable_names, reviewable): (Vec<&str>, Vec<&str>) = check_results - .iter() - .filter(|(_, ok)| !ok) - .map(|(name, _)| name.as_str()) - .partition(|name| is_fixable(name, &active)); + let (fixable, reviewable): (Vec, Vec) = check_results + .into_iter() + .filter(|r| !r.ok) + .partition(|r| is_fixable(&r.name, &active)); let mut fixed = vec![]; let mut fix_failed = vec![]; - if !fixable_names.is_empty() { + if !fixable.is_empty() { + let fixable_names: Vec<&str> = fixable.iter().map(|r| r.name.as_str()).collect(); let to_fix: Vec<®istry::Check> = active .iter() .filter(|c| fixable_names.contains(&c.name)) @@ -157,18 +158,30 @@ async fn main() -> Result<()> { &config_dir, ) .await?; - for (name, ok) in fix_results { - if ok { - fixed.push(name); + for r in fix_results { + if r.ok { + fixed.push(r.name); } else { - fix_failed.push(name); + fix_failed.push(r.name); } } } + // Emit linter output for checks that need manual review so the caller + // has the failure details without a second flint invocation. + for r in &reviewable { + eprintln!("[{}]", r.name); + if !r.stdout.is_empty() { + print!("{}", String::from_utf8_lossy(&r.stdout)); + } + if !r.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&r.stderr)); + } + } + let remaining: Vec<&str> = reviewable .iter() - .copied() + .map(|r| r.name.as_str()) .chain(fix_failed.iter().map(String::as_str)) .collect(); @@ -205,8 +218,8 @@ async fn main() -> Result<()> { let failed: Vec<&str> = results .iter() - .filter(|(_, ok)| !ok) - .map(|(name, _)| name.as_str()) + .filter(|r| !r.ok) + .map(|r| r.name.as_str()) .collect(); if !failed.is_empty() { diff --git a/src/registry.rs b/src/registry.rs index 3b13742..5af5f2e 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -64,17 +64,29 @@ impl Check { // --- Constructors --- /// Check invoked once per matched file (`{FILE}`). `name` is also used as `bin_name`. - pub fn file(name: &'static str, check_cmd: &'static str, patterns: &'static [&'static str]) -> Self { + pub fn file( + name: &'static str, + check_cmd: &'static str, + patterns: &'static [&'static str], + ) -> Self { Self::template(name, patterns, check_cmd, Scope::File) } /// Check invoked once with all matched files (`{FILES}`). `name` is also used as `bin_name`. - pub fn files(name: &'static str, check_cmd: &'static str, patterns: &'static [&'static str]) -> Self { + pub fn files( + name: &'static str, + check_cmd: &'static str, + patterns: &'static [&'static str], + ) -> Self { Self::template(name, patterns, check_cmd, Scope::Files) } /// Check invoked once per project (no file args). `name` is also used as `bin_name`. - pub fn project(name: &'static str, check_cmd: &'static str, patterns: &'static [&'static str]) -> Self { + pub fn project( + name: &'static str, + check_cmd: &'static str, + patterns: &'static [&'static str], + ) -> Self { Self::template(name, patterns, check_cmd, Scope::Project) } @@ -132,7 +144,10 @@ impl Check { /// Add a fix command (auto-fix mode). pub fn fix(mut self, fix_cmd: &'static str) -> Self { - if let CheckKind::Template { fix_cmd: ref mut f, .. } = self.kind { + if let CheckKind::Template { + fix_cmd: ref mut f, .. + } = self.kind + { *f = fix_cmd; } self @@ -160,22 +175,62 @@ impl Check { pub fn builtin() -> Vec { vec![ - Check::file("shellcheck", "shellcheck {FILE}", &["*.sh", "*.bash", "*.bats"]), + Check::file( + "shellcheck", + "shellcheck {FILE}", + &["*.sh", "*.bash", "*.bats"], + ), Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]).fix("shfmt -w {FILE}"), - Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]).fix("markdownlint --fix {FILE}"), - Check::files("prettier", "prettier --check {FILES}", &["*.md", "*.yml", "*.yaml"]).fix("prettier --write {FILES}"), - Check::file("actionlint", "actionlint {FILE}", &[".github/workflows/*.yml", ".github/workflows/*.yaml"]), - Check::file("hadolint", "hadolint {FILE}", &["Dockerfile", "Dockerfile.*", "*.dockerfile"]), - Check::files("codespell", "codespell {FILES}", &["*"]).fix("codespell --write-changes {FILES}"), + Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) + .fix("markdownlint --fix {FILE}"), + Check::files( + "prettier", + "prettier --check {FILES}", + &["*.md", "*.yml", "*.yaml"], + ) + .fix("prettier --write {FILES}"), + Check::file( + "actionlint", + "actionlint {FILE}", + &[".github/workflows/*.yml", ".github/workflows/*.yaml"], + ), + Check::file( + "hadolint", + "hadolint {FILE}", + &["Dockerfile", "Dockerfile.*", "*.dockerfile"], + ), + Check::files("codespell", "codespell {FILES}", &["*"]) + .fix("codespell --write-changes {FILES}"), // Defer to formatters that enforce line length — those are the ones // that conflict with ec's max_line_length editorconfig check. - Check::files("ec", "ec {FILES}", &["*"]) - .excludes(&["cargo-fmt", "ruff-format", "biome-format", "prettier"]), - Check::project("golangci-lint", "golangci-lint run --new-from-rev={MERGE_BASE}", &["*.go"]), + Check::files("ec", "ec {FILES}", &["*"]).excludes(&[ + "cargo-fmt", + "ruff-format", + "biome-format", + "prettier", + ]), + Check::project( + "golangci-lint", + "golangci-lint run --new-from-rev={MERGE_BASE}", + &["*.go"], + ), Check::file("ruff", "ruff check {FILE}", &["*.py"]).fix("ruff check --fix {FILE}"), - Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]).bin("ruff").fix("ruff format {FILE}"), - Check::file("biome", "biome check {FILE}", &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"]).fix("biome check --fix {FILE}"), - Check::file("biome-format", "biome format {FILE}", &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"]).bin("biome").fix("biome format --write {FILE}"), + Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]) + .bin("ruff") + .fix("ruff format {FILE}"), + Check::file( + "biome", + "biome check {FILE}", + &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], + ) + .fix("biome check --fix {FILE}"), + Check::file( + "biome-format", + "biome format {FILE}", + &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], + ) + .bin("biome") + .fix("biome format --write {FILE}"), Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") .mise_tool("rust"), diff --git a/src/runner.rs b/src/runner.rs index 20d63b9..98f8fd2 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -9,12 +9,30 @@ use crate::files::FileList; use crate::linters::{lychee, renovate_deps}; use crate::registry::{Check, CheckKind, Scope, SpecialKind}; +pub struct CheckResult { + pub name: String, + pub ok: bool, + pub stdout: Vec, + pub stderr: Vec, +} + /// A check with all inputs pre-resolved, ready to execute without borrowing /// the registry or config. Built by `prepare()` before the fix/check split. enum PreparedCheck { - Invocations { name: String, argv_list: Vec> }, - Links { name: String, cfg: LycheeConfig, file_list: FileList, config_dir: PathBuf }, - RenovateDeps { name: String, cfg: RenovateDepsConfig }, + Invocations { + name: String, + argv_list: Vec>, + }, + Links { + name: String, + cfg: LycheeConfig, + file_list: FileList, + config_dir: PathBuf, + }, + RenovateDeps { + name: String, + cfg: RenovateDepsConfig, + }, } impl PreparedCheck { @@ -26,20 +44,26 @@ impl PreparedCheck { } } - async fn execute(self, fix: bool, project_root: &Path) -> (String, bool, Vec, Vec) { + async fn execute(self, fix: bool, project_root: &Path) -> CheckResult { let name = self.name().to_string(); let (ok, stdout, stderr) = match self { Self::Invocations { argv_list, .. } => { run_invocations(&name, &argv_list, project_root).await } - Self::Links { cfg, file_list, config_dir, .. } => { - lychee::run(&cfg, &file_list, project_root, &config_dir).await - } - Self::RenovateDeps { cfg, .. } => { - renovate_deps::run(&cfg, fix, project_root).await - } + Self::Links { + cfg, + file_list, + config_dir, + .. + } => lychee::run(&cfg, &file_list, project_root, &config_dir).await, + Self::RenovateDeps { cfg, .. } => renovate_deps::run(&cfg, fix, project_root).await, }; - (name, ok, stdout, stderr) + CheckResult { + name, + ok, + stdout, + stderr, + } } } @@ -52,7 +76,7 @@ pub async fn run( project_root: &Path, cfg: &Config, config_dir: &Path, -) -> Result> { +) -> Result> { let prepared: Vec = checks .iter() .filter_map(|&check| prepare(check, file_list, fix, project_root, checks, cfg, config_dir)) @@ -61,26 +85,26 @@ pub async fn run( if fix { let mut results = vec![]; for task in prepared { - let name = task.name().to_string(); - let (_, ok, stdout, stderr) = task.execute(fix, project_root).await; - if !short && (verbose || !ok) { - eprintln!("[{name}]"); - flush_output(&stdout, &stderr); + let r = task.execute(fix, project_root).await; + if !short && (verbose || !r.ok) { + eprintln!("[{}]", r.name); + flush_output(&r.stdout, &r.stderr); } - results.push((name, ok)); + results.push(r); } return Ok(results); } - let mut set: JoinSet<(String, bool, Vec, Vec)> = JoinSet::new(); + let mut set: JoinSet = JoinSet::new(); for task in prepared { let root = project_root.to_path_buf(); set.spawn(async move { - let (name, ok, stdout, stderr) = task.execute(false, &root).await; + let r = task.execute(false, &root).await; if verbose { - flush_output(&stdout, &stderr); + eprintln!("[{}]", r.name); + flush_output(&r.stdout, &r.stderr); } - (name, ok, stdout, stderr) + r }); } @@ -91,18 +115,15 @@ pub async fn run( } if !verbose && !short { - for (name, ok, stdout, stderr) in &collected { - if !ok { - eprintln!("[{name}]"); - flush_output(stdout, stderr); + for r in &collected { + if !r.ok { + eprintln!("[{}]", r.name); + flush_output(&r.stdout, &r.stderr); } } } - Ok(collected - .into_iter() - .map(|(name, ok, _, _)| (name, ok)) - .collect()) + Ok(collected) } fn prepare( @@ -129,9 +150,10 @@ fn prepare( file_list: file_list.clone(), config_dir: config_dir.to_path_buf(), }), - CheckKind::Special(SpecialKind::RenovateDeps) => { - Some(PreparedCheck::RenovateDeps { name, cfg: cfg.checks.renovate_deps.clone() }) - } + CheckKind::Special(SpecialKind::RenovateDeps) => Some(PreparedCheck::RenovateDeps { + name, + cfg: cfg.checks.renovate_deps.clone(), + }), } } @@ -168,10 +190,10 @@ fn build_invocations( match scope { Scope::Project => { // If patterns are set, only run when relevant files are present. - if !check.patterns.is_empty() { - if match_files(&file_list.files, check.patterns, &excludes, project_root).is_empty() { - return vec![]; - } + if !check.patterns.is_empty() + && match_files(&file_list.files, check.patterns, &excludes, project_root).is_empty() + { + return vec![]; } let cmd = substitute_merge_base(cmd_template, file_list.merge_base.as_deref()); vec![shell_words(cmd)] diff --git a/tests/e2e.rs b/tests/e2e.rs index 7f1d0e3..dd3a983 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -36,11 +36,7 @@ fn write_mise_toml(repo: &TempDir, tools: &[&str]) { .iter() .map(|t| format!("{t} = \"latest\"\n")) .collect(); - std::fs::write( - repo.path().join("mise.toml"), - format!("[tools]\n{entries}"), - ) - .unwrap(); + std::fs::write(repo.path().join("mise.toml"), format!("[tools]\n{entries}")).unwrap(); } // Helper to stage a file so it appears in `git ls-files` (used by --full). @@ -178,6 +174,11 @@ fn auto_reports_unfixable_as_review() { !out.status.success(), "flint --auto should exit 1 for unfixable checks" ); + // Linter output must appear inline so the caller doesn't need a second invocation. + assert!( + stderr.contains("[shellcheck]"), + "expected inline [shellcheck] output, got:\n{stderr}" + ); assert!( stderr.contains("review: shellcheck"), "expected 'review: shellcheck' in summary, got:\n{stderr}" From 9fd286d26259c04abe2aa30b88e2eab5e949479c Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 10:29:07 +0000 Subject: [PATCH 029/141] refactor: introduce RunOptions struct and CheckResult to avoid fragile positional args Signed-off-by: Gregor Zeitlinger --- src/main.rs | 26 ++++++++++++-------- src/runner.rs | 15 +++++++++--- tests/e2e.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 51ae89b..9ba8ed4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ mod runner; use anyhow::Result; use clap::{Parser, Subcommand}; -use runner::CheckResult; +use runner::{CheckResult, RunOptions}; use std::collections::HashMap; #[derive(Parser, Debug)] @@ -124,9 +124,11 @@ async fn main() -> Result<()> { let check_results = runner::run( &active, &file_list, - false, - false, - true, // suppress per-check output + RunOptions { + fix: false, + verbose: false, + short: true, + }, &project_root, &cfg, &config_dir, @@ -150,9 +152,11 @@ async fn main() -> Result<()> { let fix_results = runner::run( &to_fix, &file_list, - true, - false, - true, // suppress per-check output + RunOptions { + fix: true, + verbose: false, + short: true, + }, &project_root, &cfg, &config_dir, @@ -207,9 +211,11 @@ async fn main() -> Result<()> { let results = runner::run( &active, &file_list, - cli.fix, - cli.verbose, - cli.short, + RunOptions { + fix: cli.fix, + verbose: cli.verbose, + short: cli.short, + }, &project_root, &cfg, &config_dir, diff --git a/src/runner.rs b/src/runner.rs index 98f8fd2..298f2bb 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -9,6 +9,12 @@ use crate::files::FileList; use crate::linters::{lychee, renovate_deps}; use crate::registry::{Check, CheckKind, Scope, SpecialKind}; +pub struct RunOptions { + pub fix: bool, + pub verbose: bool, + pub short: bool, +} + pub struct CheckResult { pub name: String, pub ok: bool, @@ -70,13 +76,16 @@ impl PreparedCheck { pub async fn run( checks: &[&Check], file_list: &FileList, - fix: bool, - verbose: bool, - short: bool, + opts: RunOptions, project_root: &Path, cfg: &Config, config_dir: &Path, ) -> Result> { + let RunOptions { + fix, + verbose, + short, + } = opts; let prepared: Vec = checks .iter() .filter_map(|&check| prepare(check, file_list, fix, project_root, checks, cfg, config_dir)) diff --git a/tests/e2e.rs b/tests/e2e.rs index dd3a983..20fc4fe 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -185,6 +185,74 @@ fn auto_reports_unfixable_as_review() { ); } +#[test] +fn auto_inline_output_has_header_per_linter() { + let repo = git_repo(); + write_mise_toml(&repo, &["shellcheck", "actionlint"]); + + // SC2086: unquoted variable — shellcheck violation with no auto-fix. + stage( + &repo.path().join("bad.sh"), + "#!/bin/bash\necho $1\n", + repo.path(), + ); + + // Undefined expression context — actionlint violation with no auto-fix. + let workflows = repo.path().join(".github/workflows"); + std::fs::create_dir_all(&workflows).unwrap(); + stage( + &workflows.join("ci.yml"), + "on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ foo.bar }}\n", + repo.path(), + ); + + let out = flint( + &["--full", "--auto", "shellcheck", "actionlint"], + repo.path(), + ); + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + + println!("=== stdout ===\n{stdout}"); + eprintln!("=== stderr ===\n{stderr}"); + + assert!(!out.status.success(), "flint --auto should exit 1"); + + // Each failing linter must emit its own header so output is attributable. + assert!( + stderr.contains("[shellcheck]"), + "expected [shellcheck] header in:\n{stderr}" + ); + assert!( + stderr.contains("[actionlint]"), + "expected [actionlint] header in:\n{stderr}" + ); + + // Headers must appear before the summary line. + let summary_pos = stderr.find("flint:").expect("expected summary line"); + let shellcheck_pos = stderr.find("[shellcheck]").unwrap(); + let actionlint_pos = stderr.find("[actionlint]").unwrap(); + assert!( + shellcheck_pos < summary_pos, + "[shellcheck] header must precede summary" + ); + assert!( + actionlint_pos < summary_pos, + "[actionlint] header must precede summary" + ); + + // Both must appear in the review summary. + assert!(stderr.contains("review:"), "expected review: in summary"); + assert!( + stderr.contains("shellcheck"), + "expected shellcheck in summary" + ); + assert!( + stderr.contains("actionlint"), + "expected actionlint in summary" + ); +} + #[test] fn shellcheck_clean_script_passes() { let repo = git_repo(); From 6f412ed40a6f6ec4e34d2f7c64eb6e9be08b6f39 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 10:46:41 +0000 Subject: [PATCH 030/141] feat: route all linter output to stderr, declarative e2e fixtures, task renames - All tool stdout redirected to stderr so headers and diagnostics appear on the same stream for AI callers and humans alike - Sort check results by name for deterministic output order - Replace ad-hoc e2e tests with declarative fixture cases under tests/cases/*/: files/ + test.toml (args, exit, golden stderr/stdout) UPDATE_SNAPSHOTS=1 regenerates golden output in-place - Rename mise tasks: fix -> lint:fix, native-lint -> lint:pre-commit; drop lint:rust/fmt/check-fmt (covered by cargo-clippy/cargo-fmt via rust tool) - Pin rust = 1.94.1 in mise.toml to activate cargo-clippy and cargo-fmt Signed-off-by: Gregor Zeitlinger --- src/main.rs | 4 +- src/runner.rs | 6 +- .../cases/auto-fix-cargo-fmt/files/Cargo.toml | 4 + .../cases/auto-fix-cargo-fmt/files/mise.toml | 2 + .../cases/auto-fix-cargo-fmt/files/src/lib.rs | 1 + tests/cases/auto-fix-cargo-fmt/test.toml | 6 + .../files/.github/workflows/ci.yml | 6 + .../auto-review-two-linters/files/bad.sh | 2 + .../auto-review-two-linters/files/mise.toml | 3 + tests/cases/auto-review-two-linters/test.toml | 22 ++ .../cases/auto-review-unfixable/files/bad.sh | 2 + .../auto-review-unfixable/files/mise.toml | 2 + tests/cases/auto-review-unfixable/test.toml | 17 + .../cases/cargo-fmt-failure/files/Cargo.toml | 4 + tests/cases/cargo-fmt-failure/files/mise.toml | 2 + .../cases/cargo-fmt-failure/files/src/lib.rs | 1 + tests/cases/cargo-fmt-failure/test.toml | 16 + tests/cases/shellcheck-clean/files/good.sh | 2 + tests/cases/shellcheck-clean/files/mise.toml | 2 + tests/cases/shellcheck-clean/test.toml | 2 + tests/cases/shellcheck-failure/files/bad.sh | 2 + .../cases/shellcheck-failure/files/mise.toml | 2 + tests/cases/shellcheck-failure/test.toml | 19 + tests/e2e.rs | 333 ++++++------------ 24 files changed, 236 insertions(+), 226 deletions(-) create mode 100644 tests/cases/auto-fix-cargo-fmt/files/Cargo.toml create mode 100644 tests/cases/auto-fix-cargo-fmt/files/mise.toml create mode 100644 tests/cases/auto-fix-cargo-fmt/files/src/lib.rs create mode 100644 tests/cases/auto-fix-cargo-fmt/test.toml create mode 100644 tests/cases/auto-review-two-linters/files/.github/workflows/ci.yml create mode 100644 tests/cases/auto-review-two-linters/files/bad.sh create mode 100644 tests/cases/auto-review-two-linters/files/mise.toml create mode 100644 tests/cases/auto-review-two-linters/test.toml create mode 100644 tests/cases/auto-review-unfixable/files/bad.sh create mode 100644 tests/cases/auto-review-unfixable/files/mise.toml create mode 100644 tests/cases/auto-review-unfixable/test.toml create mode 100644 tests/cases/cargo-fmt-failure/files/Cargo.toml create mode 100644 tests/cases/cargo-fmt-failure/files/mise.toml create mode 100644 tests/cases/cargo-fmt-failure/files/src/lib.rs create mode 100644 tests/cases/cargo-fmt-failure/test.toml create mode 100644 tests/cases/shellcheck-clean/files/good.sh create mode 100644 tests/cases/shellcheck-clean/files/mise.toml create mode 100644 tests/cases/shellcheck-clean/test.toml create mode 100644 tests/cases/shellcheck-failure/files/bad.sh create mode 100644 tests/cases/shellcheck-failure/files/mise.toml create mode 100644 tests/cases/shellcheck-failure/test.toml diff --git a/src/main.rs b/src/main.rs index 9ba8ed4..d8fde29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,7 +176,7 @@ async fn main() -> Result<()> { for r in &reviewable { eprintln!("[{}]", r.name); if !r.stdout.is_empty() { - print!("{}", String::from_utf8_lossy(&r.stdout)); + eprint!("{}", String::from_utf8_lossy(&r.stdout)); } if !r.stderr.is_empty() { eprint!("{}", String::from_utf8_lossy(&r.stderr)); @@ -253,7 +253,7 @@ async fn main() -> Result<()> { ); if !cli.fix { eprintln!( - "šŸ’” Try `mise run fix` to auto-fix lint issues, then re-run `mise run lint` to verify." + "šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify." ); } } diff --git a/src/runner.rs b/src/runner.rs index 298f2bb..5f782ed 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -118,10 +118,12 @@ pub async fn run( } // Collect all results before printing to avoid interleaved output. + // Sort by name for deterministic output order. let mut collected = vec![]; while let Some(res) = set.join_next().await { collected.push(res?); } + collected.sort_by(|a, b| a.name.cmp(&b.name)); if !verbose && !short { for r in &collected { @@ -276,8 +278,10 @@ async fn run_invocations( } fn flush_output(stdout: &[u8], stderr: &[u8]) { + // All tool output goes to stderr so headers and diagnostics stay on the + // same stream — callers (humans and AI alike) see a coherent sequence. if !stdout.is_empty() { - print!("{}", String::from_utf8_lossy(stdout)); + eprint!("{}", String::from_utf8_lossy(stdout)); } if !stderr.is_empty() { eprint!("{}", String::from_utf8_lossy(stderr)); diff --git a/tests/cases/auto-fix-cargo-fmt/files/Cargo.toml b/tests/cases/auto-fix-cargo-fmt/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/auto-fix-cargo-fmt/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/auto-fix-cargo-fmt/files/mise.toml b/tests/cases/auto-fix-cargo-fmt/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/auto-fix-cargo-fmt/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/auto-fix-cargo-fmt/files/src/lib.rs b/tests/cases/auto-fix-cargo-fmt/files/src/lib.rs new file mode 100644 index 0000000..1379407 --- /dev/null +++ b/tests/cases/auto-fix-cargo-fmt/files/src/lib.rs @@ -0,0 +1 @@ +pub struct Foo { pub a: u32, pub b: u32 } diff --git a/tests/cases/auto-fix-cargo-fmt/test.toml b/tests/cases/auto-fix-cargo-fmt/test.toml new file mode 100644 index 0000000..f757f83 --- /dev/null +++ b/tests/cases/auto-fix-cargo-fmt/test.toml @@ -0,0 +1,6 @@ +args = "--full --auto cargo-fmt" +exit = 1 + +expected_stderr = """ +flint: fixed: cargo-fmt — commit before pushing +""" \ No newline at end of file diff --git a/tests/cases/auto-review-two-linters/files/.github/workflows/ci.yml b/tests/cases/auto-review-two-linters/files/.github/workflows/ci.yml new file mode 100644 index 0000000..36bed8f --- /dev/null +++ b/tests/cases/auto-review-two-linters/files/.github/workflows/ci.yml @@ -0,0 +1,6 @@ +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo ${{ foo.bar }} diff --git a/tests/cases/auto-review-two-linters/files/bad.sh b/tests/cases/auto-review-two-linters/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/auto-review-two-linters/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/auto-review-two-linters/files/mise.toml b/tests/cases/auto-review-two-linters/files/mise.toml new file mode 100644 index 0000000..a27736f --- /dev/null +++ b/tests/cases/auto-review-two-linters/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +shellcheck = "latest" +actionlint = "latest" diff --git a/tests/cases/auto-review-two-linters/test.toml b/tests/cases/auto-review-two-linters/test.toml new file mode 100644 index 0000000..5a28b47 --- /dev/null +++ b/tests/cases/auto-review-two-linters/test.toml @@ -0,0 +1,22 @@ +args = "--full --auto shellcheck actionlint" +exit = 1 + +expected_stderr = """ +[actionlint] +.github/workflows/ci.yml:6:23: undefined variable "foo". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression] + | +6 | - run: echo ${{ foo.bar }} + | ^~~~~~~ +[shellcheck] + +In /bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. + +Did you mean: +echo "$1" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... +flint: review: actionlint, shellcheck +""" \ No newline at end of file diff --git a/tests/cases/auto-review-unfixable/files/bad.sh b/tests/cases/auto-review-unfixable/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/auto-review-unfixable/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/auto-review-unfixable/files/mise.toml b/tests/cases/auto-review-unfixable/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/auto-review-unfixable/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/auto-review-unfixable/test.toml b/tests/cases/auto-review-unfixable/test.toml new file mode 100644 index 0000000..f797f78 --- /dev/null +++ b/tests/cases/auto-review-unfixable/test.toml @@ -0,0 +1,17 @@ +args = "--full --auto shellcheck" +exit = 1 + +expected_stderr = """ +[shellcheck] + +In /bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. + +Did you mean: +echo "$1" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... +flint: review: shellcheck +""" \ No newline at end of file diff --git a/tests/cases/cargo-fmt-failure/files/Cargo.toml b/tests/cases/cargo-fmt-failure/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-fmt-failure/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-fmt-failure/files/mise.toml b/tests/cases/cargo-fmt-failure/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-fmt-failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-fmt-failure/files/src/lib.rs b/tests/cases/cargo-fmt-failure/files/src/lib.rs new file mode 100644 index 0000000..1379407 --- /dev/null +++ b/tests/cases/cargo-fmt-failure/files/src/lib.rs @@ -0,0 +1 @@ +pub struct Foo { pub a: u32, pub b: u32 } diff --git a/tests/cases/cargo-fmt-failure/test.toml b/tests/cases/cargo-fmt-failure/test.toml new file mode 100644 index 0000000..1a0e874 --- /dev/null +++ b/tests/cases/cargo-fmt-failure/test.toml @@ -0,0 +1,16 @@ +args = "--full cargo-fmt" +exit = 1 + +expected_stderr = """ +[cargo-fmt] +Diff in /src/lib.rs:1: +-pub struct Foo { pub a: u32, pub b: u32 } ++pub struct Foo { ++ pub a: u32, ++ pub b: u32, ++} + + +flint: 1 check failed (cargo-fmt) +šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. +""" \ No newline at end of file diff --git a/tests/cases/shellcheck-clean/files/good.sh b/tests/cases/shellcheck-clean/files/good.sh new file mode 100644 index 0000000..ccc95ec --- /dev/null +++ b/tests/cases/shellcheck-clean/files/good.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "$1" diff --git a/tests/cases/shellcheck-clean/files/mise.toml b/tests/cases/shellcheck-clean/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/shellcheck-clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/shellcheck-clean/test.toml b/tests/cases/shellcheck-clean/test.toml new file mode 100644 index 0000000..0411110 --- /dev/null +++ b/tests/cases/shellcheck-clean/test.toml @@ -0,0 +1,2 @@ +args = "--full shellcheck" +exit = 0 diff --git a/tests/cases/shellcheck-failure/files/bad.sh b/tests/cases/shellcheck-failure/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/shellcheck-failure/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/shellcheck-failure/files/mise.toml b/tests/cases/shellcheck-failure/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/shellcheck-failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/shellcheck-failure/test.toml b/tests/cases/shellcheck-failure/test.toml new file mode 100644 index 0000000..39b54e8 --- /dev/null +++ b/tests/cases/shellcheck-failure/test.toml @@ -0,0 +1,19 @@ +args = "--full shellcheck" +exit = 1 + +expected_stderr = """ +[shellcheck] + +In /bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. + +Did you mean: +echo "$1" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... + +flint: 1 check failed (shellcheck) +šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. +""" \ No newline at end of file diff --git a/tests/e2e.rs b/tests/e2e.rs index 20fc4fe..90d64be 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -29,248 +29,135 @@ fn git_repo() -> TempDir { dir } -/// Writes a minimal mise.toml declaring the given tools so flint's availability -/// check passes. Version strings are arbitrary — these checks have no version_range. -fn write_mise_toml(repo: &TempDir, tools: &[&str]) { - let entries: String = tools - .iter() - .map(|t| format!("{t} = \"latest\"\n")) +/// Runs all fixture cases under tests/cases/. +/// Each case is a directory containing: +/// files/ — files to copy into the repo and stage +/// test.toml — args, expected exit code, and golden output +/// +/// test.toml format: +/// args = "--full --auto shellcheck" +/// exit = 1 # optional, default 0 +/// expected_stderr = """...""" # optional, default "" +/// expected_stdout = """...""" # optional, default "" +/// +/// Set UPDATE_SNAPSHOTS=1 to regenerate golden output in test.toml. +#[test] +fn cases() { + let cases_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); + let update = std::env::var("UPDATE_SNAPSHOTS").is_ok(); + + let mut entries: Vec<_> = std::fs::read_dir(&cases_dir) + .expect("tests/cases/ not found") + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) .collect(); - std::fs::write(repo.path().join("mise.toml"), format!("[tools]\n{entries}")).unwrap(); -} + entries.sort_by_key(|e| e.file_name()); -// Helper to stage a file so it appears in `git ls-files` (used by --full). -fn stage(path: &Path, content: &str, repo: &Path) { - std::fs::write(path, content).unwrap(); - Command::new("git") - .args(["add", path.to_str().unwrap()]) - .current_dir(repo) - .output() - .expect("git add failed"); + for entry in entries { + let case = entry.path(); + let name = case.file_name().unwrap().to_string_lossy().into_owned(); + run_case(&case, &name, update); + } } -#[test] -fn shellcheck_failure_shows_check_name_header() { - let repo = git_repo(); - write_mise_toml(&repo, &["shellcheck"]); - - // SC2086: unquoted variable — reliable shellcheck violation. - stage( - &repo.path().join("bad.sh"), - "#!/bin/bash\necho $1\n", - repo.path(), - ); - - let out = flint(&["--full", "shellcheck"], repo.path()); - let stderr = String::from_utf8_lossy(&out.stderr); - let stdout = String::from_utf8_lossy(&out.stdout); - - println!("=== stdout ===\n{stdout}"); - eprintln!("=== stderr ===\n{stderr}"); +fn run_case(case: &Path, name: &str, update: bool) { + let toml_path = case.join("test.toml"); + let raw = std::fs::read_to_string(&toml_path) + .unwrap_or_else(|_| panic!("{name}: missing test.toml")); + let cfg: toml::Value = toml::from_str(&raw) + .unwrap_or_else(|e| panic!("{name}: invalid test.toml: {e}")); - assert!(!out.status.success(), "flint should fail"); - assert!( - stderr.contains("[shellcheck]"), - "expected [shellcheck] header, got:\n{stderr}" - ); -} + let args_str = cfg["args"].as_str().unwrap_or_else(|| panic!("{name}: missing args")); + let args: Vec<&str> = args_str.split_whitespace().collect(); + let expected_exit = cfg.get("exit").and_then(|v| v.as_integer()).unwrap_or(0) as i32; -#[test] -fn cargo_fmt_diff_shows_check_name_header() { let repo = git_repo(); - write_mise_toml(&repo, &["rust"]); - - // Minimal Cargo project with a badly formatted Rust file. - std::fs::write( - repo.path().join("Cargo.toml"), - "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", - ) - .unwrap(); - let src = repo.path().join("src"); - std::fs::create_dir_all(&src).unwrap(); - // Poorly formatted: fields on one line, which rustfmt will expand. - stage( - &src.join("lib.rs"), - "pub struct Foo { pub a: u32, pub b: u32 }\n", - repo.path(), - ); - let out = flint(&["--full", "cargo-fmt"], repo.path()); - let stderr = String::from_utf8_lossy(&out.stderr); - let stdout = String::from_utf8_lossy(&out.stdout); + let files_dir = case.join("files"); + copy_dir_into(&files_dir, repo.path()); + Command::new("git") + .args(["add", "-A"]) + .current_dir(repo.path()) + .output() + .expect("git add failed"); - println!("=== stdout ===\n{stdout}"); - eprintln!("=== stderr ===\n{stderr}"); + let out = flint(&args, repo.path()); - assert!(!out.status.success(), "flint should fail"); - assert!( - stderr.contains("[cargo-fmt]"), - "expected [cargo-fmt] header, got:\n{stderr}" + let repo_str = repo.path().to_string_lossy(); + let stderr = strip_ansi( + &String::from_utf8_lossy(&out.stderr).replace(repo_str.as_ref(), ""), ); -} - -#[test] -fn auto_fixes_and_reports_summary() { - let repo = git_repo(); - write_mise_toml(&repo, &["rust"]); - - // Poorly formatted Rust — cargo-fmt is fixable. - std::fs::write( - repo.path().join("Cargo.toml"), - "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", - ) - .unwrap(); - let src = repo.path().join("src"); - std::fs::create_dir_all(&src).unwrap(); - stage( - &src.join("lib.rs"), - "pub struct Foo { pub a: u32, pub b: u32 }\n", - repo.path(), + let stdout = strip_ansi( + &String::from_utf8_lossy(&out.stdout).replace(repo_str.as_ref(), ""), ); - let out = flint(&["--full", "--auto", "cargo-fmt"], repo.path()); - let stderr = String::from_utf8_lossy(&out.stderr); - let stdout = String::from_utf8_lossy(&out.stdout); - - println!("=== stdout ===\n{stdout}"); - eprintln!("=== stderr ===\n{stderr}"); + if update { + write_test_toml(&toml_path, args_str, expected_exit, &stderr, &stdout); + println!("{name}: snapshots updated"); + return; + } - // --auto fixes cargo-fmt but exits 1 — fixed files must be committed before pushing. - assert!( - !out.status.success(), - "flint --auto should exit 1 when fixes were applied" - ); - assert!( - stderr.contains("fixed: cargo-fmt"), - "expected 'fixed: cargo-fmt' in summary, got:\n{stderr}" - ); - assert!( - stderr.contains("commit before pushing"), - "expected 'commit before pushing' hint, got:\n{stderr}" + let exp_stderr = cfg + .get("expected_stderr") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let exp_stdout = cfg + .get("expected_stdout") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + assert_eq!(stderr, exp_stderr, "{name}: stderr mismatch"); + assert_eq!(stdout, exp_stdout, "{name}: stdout mismatch"); + assert_eq!( + out.status.code(), + Some(expected_exit), + "{name}: exit code mismatch" ); } -#[test] -fn auto_reports_unfixable_as_review() { - let repo = git_repo(); - write_mise_toml(&repo, &["shellcheck"]); - - // SC2086: unquoted variable — shellcheck violation with no auto-fix. - stage( - &repo.path().join("bad.sh"), - "#!/bin/bash\necho $1\n", - repo.path(), - ); - - let out = flint(&["--full", "--auto", "shellcheck"], repo.path()); - let stderr = String::from_utf8_lossy(&out.stderr); - let stdout = String::from_utf8_lossy(&out.stdout); - - println!("=== stdout ===\n{stdout}"); - eprintln!("=== stderr ===\n{stderr}"); - - // --auto should exit 1 for non-fixable failures and surface them under review:. - assert!( - !out.status.success(), - "flint --auto should exit 1 for unfixable checks" - ); - // Linter output must appear inline so the caller doesn't need a second invocation. - assert!( - stderr.contains("[shellcheck]"), - "expected inline [shellcheck] output, got:\n{stderr}" - ); - assert!( - stderr.contains("review: shellcheck"), - "expected 'review: shellcheck' in summary, got:\n{stderr}" - ); +/// Rewrites test.toml preserving args/exit and updating the expected fields. +fn write_test_toml(path: &Path, args: &str, exit: i32, stderr: &str, stdout: &str) { + let mut out = format!("args = \"{}\"\n", args.replace('"', "\\\"")); + out += &format!("exit = {exit}\n"); + if !stderr.is_empty() { + out += &format!("\nexpected_stderr = \"\"\"\n{stderr}\"\"\""); + } + if !stdout.is_empty() { + out += &format!("\nexpected_stdout = \"\"\"\n{stdout}\"\"\""); + } + std::fs::write(path, out).unwrap(); } -#[test] -fn auto_inline_output_has_header_per_linter() { - let repo = git_repo(); - write_mise_toml(&repo, &["shellcheck", "actionlint"]); - - // SC2086: unquoted variable — shellcheck violation with no auto-fix. - stage( - &repo.path().join("bad.sh"), - "#!/bin/bash\necho $1\n", - repo.path(), - ); - - // Undefined expression context — actionlint violation with no auto-fix. - let workflows = repo.path().join(".github/workflows"); - std::fs::create_dir_all(&workflows).unwrap(); - stage( - &workflows.join("ci.yml"), - "on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ foo.bar }}\n", - repo.path(), - ); - - let out = flint( - &["--full", "--auto", "shellcheck", "actionlint"], - repo.path(), - ); - let stderr = String::from_utf8_lossy(&out.stderr); - let stdout = String::from_utf8_lossy(&out.stdout); - - println!("=== stdout ===\n{stdout}"); - eprintln!("=== stderr ===\n{stderr}"); - - assert!(!out.status.success(), "flint --auto should exit 1"); - - // Each failing linter must emit its own header so output is attributable. - assert!( - stderr.contains("[shellcheck]"), - "expected [shellcheck] header in:\n{stderr}" - ); - assert!( - stderr.contains("[actionlint]"), - "expected [actionlint] header in:\n{stderr}" - ); - - // Headers must appear before the summary line. - let summary_pos = stderr.find("flint:").expect("expected summary line"); - let shellcheck_pos = stderr.find("[shellcheck]").unwrap(); - let actionlint_pos = stderr.find("[actionlint]").unwrap(); - assert!( - shellcheck_pos < summary_pos, - "[shellcheck] header must precede summary" - ); - assert!( - actionlint_pos < summary_pos, - "[actionlint] header must precede summary" - ); - - // Both must appear in the review summary. - assert!(stderr.contains("review:"), "expected review: in summary"); - assert!( - stderr.contains("shellcheck"), - "expected shellcheck in summary" - ); - assert!( - stderr.contains("actionlint"), - "expected actionlint in summary" - ); +/// Strips ANSI escape sequences (e.g. colour codes from cargo fmt diffs). +/// TOML strings cannot contain raw control characters, so these must be removed. +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' && chars.peek() == Some(&'[') { + chars.next(); // consume '[' + while let Some(&next) = chars.peek() { + chars.next(); + if next.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out } -#[test] -fn shellcheck_clean_script_passes() { - let repo = git_repo(); - write_mise_toml(&repo, &["shellcheck"]); - - // A well-formed shell script — no violations. - stage( - &repo.path().join("good.sh"), - "#!/bin/bash\necho \"$1\"\n", - repo.path(), - ); - - let out = flint(&["--full", "shellcheck"], repo.path()); - let stderr = String::from_utf8_lossy(&out.stderr); - let stdout = String::from_utf8_lossy(&out.stdout); - - println!("=== stdout ===\n{stdout}"); - eprintln!("=== stderr ===\n{stderr}"); - - assert!(out.status.success(), "flint should pass, got:\n{stderr}"); +fn copy_dir_into(src: &Path, dst: &Path) { + for entry in std::fs::read_dir(src).expect("files/ dir not found") { + let entry = entry.unwrap(); + let target = dst.join(entry.file_name()); + if entry.path().is_dir() { + std::fs::create_dir_all(&target).unwrap(); + copy_dir_into(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), &target).unwrap(); + } + } } From afe80c5b49e580721d53a0632febe9d3fe374d70 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 11:00:51 +0000 Subject: [PATCH 031/141] feat: add exclude_paths setting to filter files by path prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `exclude_paths` to `[settings]` as a list of path prefixes (simple string starts_with match, no regex). Files whose relative path matches any prefix are excluded before any linter runs. Also moves flint.toml from the repo root to .github/config/ — matching the FLINT_CONFIG_DIR=".github/config" set in mise.toml [env], which meant the root config was silently ignored by all mise-run invocations. Fixes the e2e test harness to unset FLINT_CONFIG_DIR so test subprocesses always load config from the temp repo root rather than inheriting the parent env. Signed-off-by: Gregor Zeitlinger --- flint.toml => .github/config/flint.toml | 1 + src/config.rs | 2 ++ src/files.rs | 20 ++++++++++----- .../cases/exclude-paths/files/excluded/bad.sh | 2 ++ tests/cases/exclude-paths/files/flint.toml | 2 ++ tests/cases/exclude-paths/files/mise.toml | 2 ++ tests/cases/exclude-paths/test.toml | 2 ++ tests/e2e.rs | 25 ++++++++++--------- 8 files changed, 38 insertions(+), 18 deletions(-) rename flint.toml => .github/config/flint.toml (87%) create mode 100644 tests/cases/exclude-paths/files/excluded/bad.sh create mode 100644 tests/cases/exclude-paths/files/flint.toml create mode 100644 tests/cases/exclude-paths/files/mise.toml create mode 100644 tests/cases/exclude-paths/test.toml diff --git a/flint.toml b/.github/config/flint.toml similarity index 87% rename from flint.toml rename to .github/config/flint.toml index 9e8e911..2e60889 100644 --- a/flint.toml +++ b/.github/config/flint.toml @@ -1,6 +1,7 @@ [settings] base_branch = "main" exclude = "CHANGELOG\\.md|\\.github/renovate-tracked-deps\\.json" +exclude_paths = ["tests/cases/"] [checks.lychee] check_all_local = true diff --git a/src/config.rs b/src/config.rs index 432dcb7..d978726 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ pub struct Config { pub struct Settings { pub base_branch: String, pub exclude: Option, + pub exclude_paths: Vec, } impl Default for Settings { @@ -21,6 +22,7 @@ impl Default for Settings { Self { base_branch: "main".to_string(), exclude: None, + exclude_paths: vec![], } } } diff --git a/src/files.rs b/src/files.rs index 53d42c0..06845f1 100644 --- a/src/files.rs +++ b/src/files.rs @@ -19,19 +19,20 @@ pub fn changed( to_ref: Option<&str>, ) -> Result { let exclude_re = compile_exclude_re(cfg); + let exclude_paths = &cfg.settings.exclude_paths; if full { - return all_files(project_root, exclude_re.as_ref()); + return all_files(project_root, exclude_re.as_ref(), exclude_paths); } let merge_base = resolve_merge_base(project_root, cfg, from_ref)?; let files = if let Some(ref base) = merge_base { let to = to_ref.unwrap_or("HEAD"); - collect_changed_files(project_root, exclude_re.as_ref(), base, to)? + collect_changed_files(project_root, exclude_re.as_ref(), exclude_paths, base, to)? } else { // No merge base (shallow clone etc.) — fall back to all files. - return all_files(project_root, exclude_re.as_ref()); + return all_files(project_root, exclude_re.as_ref(), exclude_paths); }; Ok(FileList { files, merge_base }) @@ -71,6 +72,7 @@ fn resolve_merge_base( fn collect_changed_files( project_root: &Path, exclude_re: Option<®ex::Regex>, + exclude_paths: &[String], base: &str, to: &str, ) -> Result> { @@ -90,10 +92,14 @@ fn collect_changed_files( names.insert(line); } - Ok(filter_names(project_root, exclude_re, names)) + Ok(filter_names(project_root, exclude_re, exclude_paths, names)) } -fn all_files(project_root: &Path, exclude_re: Option<®ex::Regex>) -> Result { +fn all_files( + project_root: &Path, + exclude_re: Option<®ex::Regex>, + exclude_paths: &[String], +) -> Result { let out = Command::new("git") .args(["ls-files"]) .current_dir(project_root) @@ -106,7 +112,7 @@ fn all_files(project_root: &Path, exclude_re: Option<®ex::Regex>) -> Result Result, + exclude_paths: &[String], names: std::collections::BTreeSet, ) -> Vec { names .into_iter() .filter(|name| exclude_re.is_none_or(|re| !re.is_match(name))) + .filter(|name| !exclude_paths.iter().any(|p| name.starts_with(p.as_str()))) .map(|name| project_root.join(name)) .collect() } diff --git a/tests/cases/exclude-paths/files/excluded/bad.sh b/tests/cases/exclude-paths/files/excluded/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/exclude-paths/files/excluded/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/exclude-paths/files/flint.toml b/tests/cases/exclude-paths/files/flint.toml new file mode 100644 index 0000000..970075d --- /dev/null +++ b/tests/cases/exclude-paths/files/flint.toml @@ -0,0 +1,2 @@ +[settings] +exclude_paths = ["excluded/"] diff --git a/tests/cases/exclude-paths/files/mise.toml b/tests/cases/exclude-paths/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/exclude-paths/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/exclude-paths/test.toml b/tests/cases/exclude-paths/test.toml new file mode 100644 index 0000000..0411110 --- /dev/null +++ b/tests/cases/exclude-paths/test.toml @@ -0,0 +1,2 @@ +args = "--full shellcheck" +exit = 0 diff --git a/tests/e2e.rs b/tests/e2e.rs index 90d64be..c4f1872 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -7,6 +7,7 @@ fn flint(args: &[&str], cwd: &Path) -> Output { Command::new(env!("CARGO_BIN_EXE_flint")) .args(args) .env("MISE_PROJECT_ROOT", cwd) + .env_remove("FLINT_CONFIG_DIR") .current_dir(cwd) .output() .expect("failed to spawn flint") @@ -62,12 +63,14 @@ fn cases() { fn run_case(case: &Path, name: &str, update: bool) { let toml_path = case.join("test.toml"); - let raw = std::fs::read_to_string(&toml_path) - .unwrap_or_else(|_| panic!("{name}: missing test.toml")); - let cfg: toml::Value = toml::from_str(&raw) - .unwrap_or_else(|e| panic!("{name}: invalid test.toml: {e}")); - - let args_str = cfg["args"].as_str().unwrap_or_else(|| panic!("{name}: missing args")); + let raw = + std::fs::read_to_string(&toml_path).unwrap_or_else(|_| panic!("{name}: missing test.toml")); + let cfg: toml::Value = + toml::from_str(&raw).unwrap_or_else(|e| panic!("{name}: invalid test.toml: {e}")); + + let args_str = cfg["args"] + .as_str() + .unwrap_or_else(|| panic!("{name}: missing args")); let args: Vec<&str> = args_str.split_whitespace().collect(); let expected_exit = cfg.get("exit").and_then(|v| v.as_integer()).unwrap_or(0) as i32; @@ -84,12 +87,10 @@ fn run_case(case: &Path, name: &str, update: bool) { let out = flint(&args, repo.path()); let repo_str = repo.path().to_string_lossy(); - let stderr = strip_ansi( - &String::from_utf8_lossy(&out.stderr).replace(repo_str.as_ref(), ""), - ); - let stdout = strip_ansi( - &String::from_utf8_lossy(&out.stdout).replace(repo_str.as_ref(), ""), - ); + let stderr = + strip_ansi(&String::from_utf8_lossy(&out.stderr).replace(repo_str.as_ref(), "")); + let stdout = + strip_ansi(&String::from_utf8_lossy(&out.stdout).replace(repo_str.as_ref(), "")); if update { write_test_toml(&toml_path, args_str, expected_exit, &stderr, &stdout); From 69611a44ef92f1c1a905ea5adcd46d57bc1e69d7 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 11:12:09 +0000 Subject: [PATCH 032/141] feat: env var overrides for any flint.toml setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses figment to layer FLINT_* env vars over flint.toml. Mapping is derived from the registry — no manual maintenance needed: FLINT_BASE_BRANCH, FLINT_EXCLUDE → settings.* FLINT_LYCHEE_*, FLINT_RUFF_*, … → checks..* Any new check added to the registry automatically gets env var support. Adds flint_with_env helper and [env] table support in e2e fixture test.toml. Signed-off-by: Gregor Zeitlinger --- Cargo.lock | 140 +++++++++++++++++--- Cargo.toml | 3 +- src/config.rs | 66 +++++++-- tests/cases/env-var-exclude/files/bad.sh | 2 + tests/cases/env-var-exclude/files/mise.toml | 2 + tests/cases/env-var-exclude/test.toml | 2 + tests/e2e.rs | 32 ++++- 7 files changed, 210 insertions(+), 37 deletions(-) create mode 100644 tests/cases/env-var-exclude/files/bad.sh create mode 100644 tests/cases/env-var-exclude/files/mise.toml create mode 100644 tests/cases/env-var-exclude/test.toml diff --git a/Cargo.lock b/Cargo.lock index 1c36966..69e8086 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,12 +67,27 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.1" @@ -153,12 +168,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + [[package]] name = "flint" version = "0.1.0" dependencies = [ "anyhow", "clap", + "figment", "regex", "semver", "serde", @@ -225,6 +255,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -322,6 +358,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -347,6 +406,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + [[package]] name = "quote" version = "1.0.45" @@ -470,11 +542,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ - "serde_core", + "serde", ] [[package]] @@ -563,42 +635,53 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ - "indexmap", - "serde_core", + "serde", "serde_spanned", "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", + "toml_edit", ] [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ - "serde_core", + "serde", ] [[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" +name = "toml_edit" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", "winnow", ] [[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] [[package]] name = "unicode-ident" @@ -618,6 +701,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -693,9 +782,12 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -785,6 +877,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 0b82d5b..b4c8562 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,9 @@ repository = "https://github.com/grafana/flint" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive", "env"] } +figment = { version = "0.10", features = ["toml", "env"] } serde = { version = "1", features = ["derive"] } -toml = "1.0" +toml = "0.8" tokio = { version = "1", features = ["full"] } semver = "1" regex = "1" diff --git a/src/config.rs b/src/config.rs index d978726..b7db44c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,14 +1,29 @@ use anyhow::Result; +use figment::{ + Figment, + providers::{Env, Format, Toml}, +}; use serde::Deserialize; use std::path::Path; -#[derive(Debug, Default, Deserialize, Clone)] +use crate::registry; + +#[derive(Debug, Deserialize, Clone)] #[serde(default)] pub struct Config { pub settings: Settings, pub checks: ChecksConfig, } +impl Default for Config { + fn default() -> Self { + Self { + settings: Settings::default(), + checks: ChecksConfig::default(), + } + } +} + #[derive(Debug, Deserialize, Clone)] #[serde(default)] pub struct Settings { @@ -31,7 +46,9 @@ impl Default for Settings { #[serde(default)] pub struct ChecksConfig { pub lychee: LycheeConfig, - #[serde(rename = "renovate-deps")] + // The alias allows the underscore form used in env var keys alongside the + // hyphenated form used in flint.toml. + #[serde(rename = "renovate-deps", alias = "renovate_deps")] pub renovate_deps: RenovateDepsConfig, } @@ -45,15 +62,48 @@ pub struct LycheeConfig { #[derive(Debug, Default, Deserialize, Clone)] #[serde(default)] pub struct RenovateDepsConfig { + // Env var: FLINT_RENOVATE_DEPS_EXCLUDE_MANAGERS (JSON array, e.g. '["npm"]') pub exclude_managers: Vec, } +/// Builds env-var prefix → figment key-path mappings for every check in the registry. +/// e.g. "lychee" → ("lychee_", "checks.lychee.") +/// "renovate-deps" → ("renovate_deps_", "checks.renovate_deps.") +/// "ruff-format" → ("ruff_format_", "checks.ruff_format.") +/// Sorted longest-prefix-first so "ruff_format_" is matched before "ruff_". +fn check_env_sections() -> Vec<(String, String)> { + let mut sections: Vec<(String, String)> = registry::builtin() + .into_iter() + .map(|c| { + let n = c.name.replace('-', "_"); + (format!("{n}_"), format!("checks.{n}.")) + }) + .collect(); + // Dedup by prefix (multiple checks can share a name after normalisation is unlikely, + // but be safe) then sort longest-first to avoid short prefixes shadowing longer ones. + sections.sort_by(|a, b| b.0.len().cmp(&a.0.len())); + sections.dedup_by(|a, b| a.0 == b.0); + sections +} + pub fn load(config_dir: &Path) -> Result { - let path = config_dir.join("flint.toml"); - if !path.exists() { - return Ok(Config::default()); - } - let text = std::fs::read_to_string(&path)?; - let cfg: Config = toml::from_str(&text)?; + let sections = check_env_sections(); + let cfg: Config = Figment::new() + .merge(Toml::file(config_dir.join("flint.toml"))) + // Flat FLINT_ env vars, no double-underscore separators: + // FLINT_BASE_BRANCH, FLINT_EXCLUDE → settings.* + // FLINT_LYCHEE_CONFIG, FLINT_LYCHEE_* → checks.lychee.* + // FLINT_RENOVATE_DEPS_EXCLUDE_MANAGERS → checks.renovate_deps.* + // New Special checks added to the registry get env support automatically. + .merge(Env::prefixed("FLINT_").map(move |k| { + let k = k.as_str(); + for (prefix, namespace) in §ions { + if let Some(rest) = k.strip_prefix(prefix.as_str()) { + return format!("{namespace}{rest}").into(); + } + } + format!("settings.{k}").into() + })) + .extract()?; Ok(cfg) } diff --git a/tests/cases/env-var-exclude/files/bad.sh b/tests/cases/env-var-exclude/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/env-var-exclude/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/env-var-exclude/files/mise.toml b/tests/cases/env-var-exclude/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/env-var-exclude/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/env-var-exclude/test.toml b/tests/cases/env-var-exclude/test.toml new file mode 100644 index 0000000..0411110 --- /dev/null +++ b/tests/cases/env-var-exclude/test.toml @@ -0,0 +1,2 @@ +args = "--full shellcheck" +exit = 0 diff --git a/tests/e2e.rs b/tests/e2e.rs index c4f1872..c4ebbfa 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -4,13 +4,20 @@ use tempfile::TempDir; /// Runs the flint binary in the given directory with the given args. fn flint(args: &[&str], cwd: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_flint")) - .args(args) + flint_with_env(args, cwd, &[]) +} + +/// Runs the flint binary with additional environment variables. +fn flint_with_env(args: &[&str], cwd: &Path, env: &[(&str, &str)]) -> Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_flint")); + cmd.args(args) .env("MISE_PROJECT_ROOT", cwd) .env_remove("FLINT_CONFIG_DIR") - .current_dir(cwd) - .output() - .expect("failed to spawn flint") + .current_dir(cwd); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("failed to spawn flint") } /// Creates a temp directory initialised as a git repo. @@ -84,7 +91,19 @@ fn run_case(case: &Path, name: &str, update: bool) { .output() .expect("git add failed"); - let out = flint(&args, repo.path()); + let env_vars: Vec<(String, String)> = cfg + .get("env") + .and_then(|v| v.as_table()) + .map(|t| { + t.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + let env_refs: Vec<(&str, &str)> = + env_vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + + let out = flint_with_env(&args, repo.path(), &env_refs); let repo_str = repo.path().to_string_lossy(); let stderr = @@ -106,7 +125,6 @@ fn run_case(case: &Path, name: &str, update: bool) { .get("expected_stdout") .and_then(|v| v.as_str()) .unwrap_or(""); - assert_eq!(stderr, exp_stderr, "{name}: stderr mismatch"); assert_eq!(stdout, exp_stdout, "{name}: stdout mismatch"); assert_eq!( From 8ddc4bf66ab0a6b93bbaba23cd5c747969e31f5f Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 12:01:18 +0000 Subject: [PATCH 033/141] feat: inject linter config files from FLINT_CONFIG_DIR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When FLINT_CONFIG_DIR is set, each linter that supports an explicit config flag now has it injected automatically if the corresponding file exists in that directory. Absent files are silently skipped. Supported: shellcheck (--rcfile), markdownlint (--config), prettier (--config), actionlint (-config-file), hadolint (--config), codespell (--config), ec (-config), golangci-lint (--config), ruff/ruff-format (--config). Biome's --config-path takes a directory rather than a file and requires a separate injection variant — documented as a known gap. Update FLINT-V2.md and AGENTS-V2.md to document the mechanism, the per-linter config filenames, and guidance for future additions. Signed-off-by: Gregor Zeitlinger --- AGENTS-V2.md | 31 +++++++++++++++++++++ FLINT-V2.md | 62 ++++++++++++++++++++++++++++------------- src/config.rs | 10 +------ src/registry.rs | 51 ++++++++++++++++++++++++---------- src/runner.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++----- tests/e2e.rs | 6 ++-- 6 files changed, 181 insertions(+), 53 deletions(-) diff --git a/AGENTS-V2.md b/AGENTS-V2.md index 730fb8c..626de1c 100644 --- a/AGENTS-V2.md +++ b/AGENTS-V2.md @@ -79,6 +79,37 @@ Available builder modifiers: | `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | | `.excludes(names)` | Skip files already owned by these active checks | | `.slow()` | Mark as slow — skipped by `--fast` | +| `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | + +#### Config file injection (`.linter_config`) + +Use `.linter_config(filename, flag)` when the tool supports an explicit config +file path via a CLI flag. At runtime, if `FLINT_CONFIG_DIR/` exists, +flint injects `flag ` right after the binary name in the command. +If the file is absent the flag is silently omitted — native config discovery +remains in effect. + +```rust +// Example: markdownlint accepts --config +Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) + .fix("markdownlint --fix {FILE}") + .linter_config(".markdownlint.json", "--config"), +// → markdownlint --config /repo/.github/config/.markdownlint.json +``` + +**When NOT to use it:** +- The tool has no explicit `--config`/`--rcfile`/equivalent flag (e.g. `shfmt`) +- The flag accepts a **directory** rather than a file (e.g. biome's + `--config-path `) — a different injection shape is needed. For biome, + check for `biome.json` existence but pass `config_dir` itself as the arg: + `biome --config-path check `. This requires a variant of + `.linter_config` that injects the directory rather than the full file path + (not yet implemented) +- The tool is project-scoped and its config must live at the project root to + function (e.g. `cargo-fmt` reads `rustfmt.toml` via Cargo, not a direct flag) + +Look up the tool's `--help` or man page for the config flag name and expected +argument type before adding `.linter_config`. For checks that need custom logic (not a simple command template), add a module under `src/linters/` and use diff --git a/FLINT-V2.md b/FLINT-V2.md index 34e51b7..fee5f0d 100644 --- a/FLINT-V2.md +++ b/FLINT-V2.md @@ -146,7 +146,7 @@ renovate-deps renovate installed slow ## Config (`flint.toml`) -Optional. Place in the repo root. All settings have defaults. +Optional. Place in the repo root (or in `FLINT_CONFIG_DIR` — see below). All settings have defaults. ```toml [settings] @@ -161,6 +161,25 @@ check_all_local = true # second pass: local links in all exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers ``` +### `FLINT_CONFIG_DIR` + +Set this env var to consolidate config files in one directory (e.g. `.github/config`): + +```toml +# mise.toml +[env] +FLINT_CONFIG_DIR = ".github/config" +``` + +When set, `flint.toml` is loaded from that directory, and each linter that supports +an explicit config flag will have it injected automatically when the corresponding +file exists there (see the "Config file" column in the table below). Files that are +absent are silently skipped — existing project-root configs remain in effect. + +**Note:** `ec`'s config file (`.editorconfig-checker.json`) controls ec's own settings, +not `.editorconfig` itself — editorconfig discovery always walks up from the file +being linted and cannot be redirected via a flag. + ## mise.toml wiring ```toml @@ -186,25 +205,28 @@ Checks auto-enable when their binary is found in PATH. Install tools via `mise.t -| Name | Binary | Patterns | Fix | Scope | -| --------------- | --------------- | -------------------------------------------------- | --- | ------- | -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | -| `markdownlint` | `markdownlint` | `*.md` | yes | file | -| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | files | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | -| `codespell` | `codespell` | `*` | yes | files | -| `ec` | `ec` | `*` | no | files | -| `golangci-lint` | `golangci-lint` | `*.go` | no | project | -| `ruff` | `ruff` | `*.py` | yes | file | -| `ruff-format` | `ruff` | `*.py` | yes | file | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | -| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | -| `links` | `lychee` | (all files) | no | special | -| `renovate-deps` | `renovate` | (all files) | yes | special | +| Name | Binary | Patterns | Fix | Scope | Config file | +| --------------- | --------------- | -------------------------------------------------- | --- | ------- | ------------------------------ | +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | `.shellcheckrc` | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | — | +| `markdownlint` | `markdownlint` | `*.md` | yes | file | `.markdownlint.json` | +| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | files | `.prettierrc` | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | `actionlint.yml` | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | `.hadolint.yaml` | +| `codespell` | `codespell` | `*` | yes | files | `.codespellrc` | +| `ec` | `ec` | `*` | no | files | `.editorconfig-checker.json` | +| `golangci-lint` | `golangci-lint` | `*.go` | no | project | `.golangci.yml` | +| `ruff` | `ruff` | `*.py` | yes | file | `ruff.toml` | +| `ruff-format` | `ruff` | `*.py` | yes | file | `ruff.toml` | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | — | +| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | — | +| `links` | `lychee` | (all files) | no | special | via `[checks.links]` in flint.toml | +| `renovate-deps` | `renovate` | (all files) | yes | special | — | + +¹ Not yet implemented. Biome's flag (`--config-path`) takes a directory, not a +file path — requires a directory-injection variant of the config mechanism. diff --git a/src/config.rs b/src/config.rs index b7db44c..2bddee3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,20 +10,12 @@ use crate::registry; #[derive(Debug, Deserialize, Clone)] #[serde(default)] +#[derive(Default)] pub struct Config { pub settings: Settings, pub checks: ChecksConfig, } -impl Default for Config { - fn default() -> Self { - Self { - settings: Settings::default(), - checks: ChecksConfig::default(), - } - } -} - #[derive(Debug, Deserialize, Clone)] #[serde(default)] pub struct Settings { diff --git a/src/registry.rs b/src/registry.rs index 5af5f2e..d1877a1 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -49,6 +49,9 @@ pub struct Check { pub excludes_if_active: &'static [&'static str], /// Slow checks are skipped when `--fast` is passed. pub slow: bool, + /// When set, look for `(filename, flag)` in config_dir: if the file exists, inject + /// `flag ` into the command right after the binary name. + pub linter_config: Option<(&'static str, &'static str)>, pub kind: CheckKind, } @@ -104,6 +107,7 @@ impl Check { patterns, excludes_if_active: &[], slow: false, + linter_config: None, kind: CheckKind::Template { check_cmd, fix_cmd: "", @@ -122,6 +126,7 @@ impl Check { patterns: &[], excludes_if_active: &[], slow: false, + linter_config: None, kind: CheckKind::Special(kind), } } @@ -171,6 +176,14 @@ impl Check { self.slow = true; self } + + /// Inject a config file from config_dir into the linter command. + /// If `config_dir/file` exists at runtime, `flag ` is inserted + /// right after the binary name. Has no effect when the file is absent. + pub fn linter_config(mut self, file: &'static str, flag: &'static str) -> Self { + self.linter_config = Some((file, flag)); + self + } } pub fn builtin() -> Vec { @@ -179,45 +192,53 @@ pub fn builtin() -> Vec { "shellcheck", "shellcheck {FILE}", &["*.sh", "*.bash", "*.bats"], - ), + ) + .linter_config(".shellcheckrc", "--rcfile"), Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]).fix("shfmt -w {FILE}"), Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) - .fix("markdownlint --fix {FILE}"), + .fix("markdownlint --fix {FILE}") + .linter_config(".markdownlint.json", "--config"), Check::files( "prettier", "prettier --check {FILES}", &["*.md", "*.yml", "*.yaml"], ) - .fix("prettier --write {FILES}"), + .fix("prettier --write {FILES}") + .linter_config(".prettierrc", "--config"), Check::file( "actionlint", "actionlint {FILE}", &[".github/workflows/*.yml", ".github/workflows/*.yaml"], - ), + ) + .linter_config("actionlint.yml", "-config-file"), Check::file( "hadolint", "hadolint {FILE}", &["Dockerfile", "Dockerfile.*", "*.dockerfile"], - ), + ) + .linter_config(".hadolint.yaml", "--config"), Check::files("codespell", "codespell {FILES}", &["*"]) - .fix("codespell --write-changes {FILES}"), + .fix("codespell --write-changes {FILES}") + .linter_config(".codespellrc", "--config"), // Defer to formatters that enforce line length — those are the ones // that conflict with ec's max_line_length editorconfig check. - Check::files("ec", "ec {FILES}", &["*"]).excludes(&[ - "cargo-fmt", - "ruff-format", - "biome-format", - "prettier", - ]), + // Note: ec's -config flag controls ec's own JSON config, not .editorconfig itself. + Check::files("ec", "ec {FILES}", &["*"]) + .excludes(&["cargo-fmt", "ruff-format", "biome-format", "prettier"]) + .linter_config(".editorconfig-checker.json", "-config"), Check::project( "golangci-lint", "golangci-lint run --new-from-rev={MERGE_BASE}", &["*.go"], - ), - Check::file("ruff", "ruff check {FILE}", &["*.py"]).fix("ruff check --fix {FILE}"), + ) + .linter_config(".golangci.yml", "--config"), + Check::file("ruff", "ruff check {FILE}", &["*.py"]) + .fix("ruff check --fix {FILE}") + .linter_config("ruff.toml", "--config"), Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]) .bin("ruff") - .fix("ruff format {FILE}"), + .fix("ruff format {FILE}") + .linter_config("ruff.toml", "--config"), Check::file( "biome", "biome check {FILE}", diff --git a/src/runner.rs b/src/runner.rs index 5f782ed..5ddcc82 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -149,7 +149,14 @@ fn prepare( let name = check.name.to_string(); match &check.kind { CheckKind::Template { .. } => { - let argv_list = build_invocations(check, file_list, fix, project_root, active_checks); + let argv_list = build_invocations( + check, + file_list, + fix, + project_root, + active_checks, + config_dir, + ); if argv_list.is_empty() { return None; } @@ -175,6 +182,7 @@ fn build_invocations( fix: bool, project_root: &Path, active_checks: &[&Check], + config_dir: &Path, ) -> Vec> { let CheckKind::Template { check_cmd, @@ -198,6 +206,8 @@ fn build_invocations( .flat_map(|c| c.patterns.iter().copied()) .collect(); + let config_args = resolve_linter_config(check, config_dir); + match scope { Scope::Project => { // If patterns are set, only run when relevant files are present. @@ -207,7 +217,7 @@ fn build_invocations( return vec![]; } let cmd = substitute_merge_base(cmd_template, file_list.merge_base.as_deref()); - vec![shell_words(cmd)] + vec![inject_config(shell_words(cmd), &config_args)] } Scope::File => { @@ -216,7 +226,7 @@ fn build_invocations( .iter() .map(|f| { let cmd = cmd_template.replace("{FILE}", "e_path(f)); - shell_words(cmd) + inject_config(shell_words(cmd), &config_args) }) .collect() } @@ -232,11 +242,36 @@ fn build_invocations( .collect::>() .join(" "); let cmd = cmd_template.replace("{FILES}", &files_arg); - vec![shell_words(cmd)] + vec![inject_config(shell_words(cmd), &config_args)] } } } +/// Returns `[flag, abs-path]` if `check.linter_config` is set and the file exists +/// in `config_dir`, otherwise an empty slice. +fn resolve_linter_config(check: &Check, config_dir: &Path) -> Vec { + let Some((file, flag)) = check.linter_config else { + return vec![]; + }; + let path = config_dir.join(file); + if !path.exists() { + return vec![]; + } + vec![flag.to_string(), path.to_string_lossy().into_owned()] +} + +/// Inserts `config_args` at position 1 (right after the binary name) in `argv`. +fn inject_config(mut argv: Vec, config_args: &[String]) -> Vec { + if config_args.is_empty() || argv.is_empty() { + return argv; + } + // Insert after argv[0] (the binary name). + let tail = argv.split_off(1); + argv.extend_from_slice(config_args); + argv.extend(tail); + argv +} + /// Runs all invocations for one check, returning (ok, stdout, stderr). /// Never prints — callers decide when and whether to flush output. async fn run_invocations( @@ -402,6 +437,7 @@ mod tests { patterns, excludes_if_active: &[], slow: false, + linter_config: None, kind: CheckKind::Template { check_cmd: "run-it", fix_cmd: "", @@ -424,14 +460,31 @@ mod tests { fn project_scope_skips_when_no_matching_files() { let check = project_check(&["*.rs"]); let fl = file_list(&["foo.py", "bar.md"]); - assert!(build_invocations(&check, &fl, false, Path::new("/repo"), &[]).is_empty()); + assert!( + build_invocations( + &check, + &fl, + false, + Path::new("/repo"), + &[], + Path::new("/repo") + ) + .is_empty() + ); } #[test] fn project_scope_runs_when_matching_files_present() { let check = project_check(&["*.rs"]); let fl = file_list(&["src/main.rs", "foo.py"]); - let inv = build_invocations(&check, &fl, false, Path::new("/repo"), &[]); + let inv = build_invocations( + &check, + &fl, + false, + Path::new("/repo"), + &[], + Path::new("/repo"), + ); assert_eq!(inv, vec![vec!["run-it".to_string()]]); } @@ -439,7 +492,14 @@ mod tests { fn project_scope_empty_patterns_always_runs() { let check = project_check(&[]); let fl = file_list(&["foo.py"]); - let inv = build_invocations(&check, &fl, false, Path::new("/repo"), &[]); + let inv = build_invocations( + &check, + &fl, + false, + Path::new("/repo"), + &[], + Path::new("/repo"), + ); assert_eq!(inv, vec![vec!["run-it".to_string()]]); } } diff --git a/tests/e2e.rs b/tests/e2e.rs index c4ebbfa..da467f4 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -100,8 +100,10 @@ fn run_case(case: &Path, name: &str, update: bool) { .collect() }) .unwrap_or_default(); - let env_refs: Vec<(&str, &str)> = - env_vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let env_refs: Vec<(&str, &str)> = env_vars + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); let out = flint_with_env(&args, repo.path(), &env_refs); From 29415f0c74b3f45f15bd54eba29ab400d74233b0 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 12:05:13 +0000 Subject: [PATCH 034/141] test: add coverage for linter config injection Unit tests for inject_config (insert at position 1, noop cases) and resolve_linter_config (absent file, present file, no linter_config). E2e fixture shellcheck-config-dir: bad.sh that normally fails SC2086 passes when FLINT_CONFIG_DIR points to a config/ dir containing .shellcheckrc that disables SC2086. Signed-off-by: Gregor Zeitlinger --- src/runner.rs | 53 +++++++++++++++++++ .../cases/shellcheck-config-dir/files/bad.sh | 2 + .../files/config/.shellcheckrc | 1 + .../shellcheck-config-dir/files/mise.toml | 2 + tests/cases/shellcheck-config-dir/test.toml | 5 ++ 5 files changed, 63 insertions(+) create mode 100644 tests/cases/shellcheck-config-dir/files/bad.sh create mode 100644 tests/cases/shellcheck-config-dir/files/config/.shellcheckrc create mode 100644 tests/cases/shellcheck-config-dir/files/mise.toml create mode 100644 tests/cases/shellcheck-config-dir/test.toml diff --git a/src/runner.rs b/src/runner.rs index 5ddcc82..8dd1f1b 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -428,6 +428,59 @@ mod tests { use crate::registry::{Check, CheckKind, Scope}; use std::path::PathBuf; + #[test] + fn inject_config_inserts_after_binary() { + let argv = vec!["shellcheck".to_string(), "file.sh".to_string()]; + let config = vec!["--rcfile".to_string(), "/cfg/.shellcheckrc".to_string()]; + assert_eq!( + inject_config(argv, &config), + vec!["shellcheck", "--rcfile", "/cfg/.shellcheckrc", "file.sh"], + ); + } + + #[test] + fn inject_config_noop_when_no_config_args() { + let argv = vec!["shellcheck".to_string(), "file.sh".to_string()]; + assert_eq!(inject_config(argv.clone(), &[]), argv,); + } + + #[test] + fn inject_config_noop_when_argv_empty() { + assert_eq!( + inject_config(vec![], &["--rcfile".to_string()]), + vec![] as Vec + ); + } + + #[test] + fn resolve_linter_config_absent_file_returns_empty() { + let check = Check::file("shellcheck", "shellcheck {FILE}", &["*.sh"]) + .linter_config(".shellcheckrc", "--rcfile"); + let dir = tempfile::tempdir().unwrap(); + assert!(resolve_linter_config(&check, dir.path()).is_empty()); + } + + #[test] + fn resolve_linter_config_present_file_returns_flag_and_path() { + let check = Check::file("shellcheck", "shellcheck {FILE}", &["*.sh"]) + .linter_config(".shellcheckrc", "--rcfile"); + let dir = tempfile::tempdir().unwrap(); + let cfg_path = dir.path().join(".shellcheckrc"); + std::fs::write(&cfg_path, "").unwrap(); + let result = resolve_linter_config(&check, dir.path()); + assert_eq!( + result, + vec!["--rcfile", cfg_path.to_string_lossy().as_ref()] + ); + } + + #[test] + fn resolve_linter_config_none_returns_empty() { + let check = Check::file("shellcheck", "shellcheck {FILE}", &["*.sh"]); + let dir = tempfile::tempdir().unwrap(); + assert!(resolve_linter_config(&check, dir.path()).is_empty()); + } + fn project_check(patterns: &'static [&'static str]) -> Check { Check { name: "test", diff --git a/tests/cases/shellcheck-config-dir/files/bad.sh b/tests/cases/shellcheck-config-dir/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/shellcheck-config-dir/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/shellcheck-config-dir/files/config/.shellcheckrc b/tests/cases/shellcheck-config-dir/files/config/.shellcheckrc new file mode 100644 index 0000000..5de1df3 --- /dev/null +++ b/tests/cases/shellcheck-config-dir/files/config/.shellcheckrc @@ -0,0 +1 @@ +disable=SC2086 diff --git a/tests/cases/shellcheck-config-dir/files/mise.toml b/tests/cases/shellcheck-config-dir/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/shellcheck-config-dir/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/shellcheck-config-dir/test.toml b/tests/cases/shellcheck-config-dir/test.toml new file mode 100644 index 0000000..eb7d7bf --- /dev/null +++ b/tests/cases/shellcheck-config-dir/test.toml @@ -0,0 +1,5 @@ +args = "--full shellcheck" +exit = 0 + +[env] +FLINT_CONFIG_DIR = "config" From 50dfbaf81dcad4bdc511f4451c9a15e1888f5cc9 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 12:14:21 +0000 Subject: [PATCH 035/141] feat: add linters, license-header check, formatter-aware ec exclusion New linters: gofmt, google-java-format, ktlint, dotnet-format, markdownlint-cli2. All formatter-marked via .formatter(). New special check: license-header. Config-activated (no mise tool required) via .activate_unconditionally(). Checks that configured text appears in the first N lines of matching files. Refactor ec exclusion: replace hardcoded excludes_if_active list with .is_formatter() / .defer_to_formatters() flags so newly added formatters are automatically excluded from ec without touching its registry entry. Also: quote_path switched to double-quotes, shell_words extended to handle double-quoted strings and \" escapes. Fix env-var-exclude test to actually set FLINT_EXCLUDE via env rather than relying on absent config. Signed-off-by: Gregor Zeitlinger --- src/config.rs | 24 +++++++ src/linters/license_header.rs | 79 +++++++++++++++++++++ src/linters/mod.rs | 1 + src/registry.rs | 98 +++++++++++++++++++++++---- src/runner.rs | 56 ++++++++++++--- tests/cases/env-var-exclude/test.toml | 3 + 6 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 src/linters/license_header.rs diff --git a/src/config.rs b/src/config.rs index 2bddee3..211deb2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -42,6 +42,8 @@ pub struct ChecksConfig { // hyphenated form used in flint.toml. #[serde(rename = "renovate-deps", alias = "renovate_deps")] pub renovate_deps: RenovateDepsConfig, + #[serde(rename = "license-header", alias = "license_header")] + pub license_header: LicenseHeaderConfig, } #[derive(Debug, Default, Deserialize, Clone)] @@ -58,6 +60,28 @@ pub struct RenovateDepsConfig { pub exclude_managers: Vec, } +#[derive(Debug, Deserialize, Clone)] +#[serde(default)] +pub struct LicenseHeaderConfig { + /// The text that must appear within the first `lines_to_check` lines of each file. + /// When empty (default), the check is disabled. + pub text: String, + /// Glob patterns for files to check (e.g. `["*.java", "*.kt"]`). + pub patterns: Vec, + /// How many lines from the top of each file to search. Default: 5. + pub lines_to_check: usize, +} + +impl Default for LicenseHeaderConfig { + fn default() -> Self { + Self { + text: String::new(), + patterns: vec![], + lines_to_check: 5, + } + } +} + /// Builds env-var prefix → figment key-path mappings for every check in the registry. /// e.g. "lychee" → ("lychee_", "checks.lychee.") /// "renovate-deps" → ("renovate_deps_", "checks.renovate_deps.") diff --git a/src/linters/license_header.rs b/src/linters/license_header.rs new file mode 100644 index 0000000..347e622 --- /dev/null +++ b/src/linters/license_header.rs @@ -0,0 +1,79 @@ +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use crate::config::LicenseHeaderConfig; + +/// Checks that each file matching `cfg.patterns` contains `cfg.text` within +/// the first `cfg.lines_to_check` lines. Returns early (ok=true) when not configured. +pub async fn run( + cfg: &LicenseHeaderConfig, + project_root: &Path, + files: &[PathBuf], +) -> (bool, Vec, Vec) { + if cfg.text.is_empty() { + return (true, vec![], vec![]); + } + + let mut all_ok = true; + let mut stderr = Vec::new(); + + for file in files { + let rel = file.strip_prefix(project_root).unwrap_or(file); + let rel_str = rel.to_string_lossy(); + let file_name = file + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + + if !cfg + .patterns + .iter() + .any(|pat| glob_match(pat, &file_name) || glob_match(pat, &rel_str)) + { + continue; + } + + match check_file(file, &cfg.text, cfg.lines_to_check) { + Ok(true) => {} + Ok(false) => { + all_ok = false; + stderr.extend_from_slice(format!("{rel_str}: missing license header\n").as_bytes()); + } + Err(e) => { + all_ok = false; + stderr.extend_from_slice(format!("{rel_str}: failed to read: {e}\n").as_bytes()); + } + } + } + + (all_ok, vec![], stderr) +} + +/// Returns `true` if `text` appears anywhere within the first `lines_to_check` lines of `path`. +fn check_file(path: &Path, text: &str, lines_to_check: usize) -> std::io::Result { + let f = std::fs::File::open(path)?; + let reader = BufReader::new(f); + for line in reader.lines().take(lines_to_check) { + if line?.contains(text) { + return Ok(true); + } + } + Ok(false) +} + +fn glob_match(pattern: &str, name: &str) -> bool { + let parts: Vec<&str> = pattern.splitn(2, '*').collect(); + match parts.as_slice() { + [only] => name == *only || name.ends_with(&format!("/{only}")), + [prefix, suffix] => { + let anchor_start = prefix.is_empty() || name.starts_with(prefix) || { + name.contains('/') && { + let after_slash = name.rfind('/').map(|i| &name[i + 1..]).unwrap_or(name); + prefix.is_empty() || after_slash.starts_with(prefix) + } + }; + anchor_start && name.ends_with(suffix) + } + _ => false, + } +} diff --git a/src/linters/mod.rs b/src/linters/mod.rs index 325b766..b902252 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -1,2 +1,3 @@ +pub mod license_header; pub mod lychee; pub mod renovate_deps; diff --git a/src/registry.rs b/src/registry.rs index d1877a1..8ba02d5 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -16,6 +16,7 @@ pub enum Scope { pub enum SpecialKind { Links, RenovateDeps, + LicenseHeader, } #[derive(Debug, Clone)] @@ -52,6 +53,12 @@ pub struct Check { /// When set, look for `(filename, flag)` in config_dir: if the file exists, inject /// `flag ` into the command right after the binary name. pub linter_config: Option<(&'static str, &'static str)>, + /// This check is a formatter — it owns certain file types for formatting purposes. + pub is_formatter: bool, + /// Skip files owned by active formatters (used by ec to avoid double-checking). + pub defers_to_formatters: bool, + /// Always considered active regardless of mise.toml (used for config-activated checks). + pub activate_unconditionally: bool, pub kind: CheckKind, } @@ -61,6 +68,7 @@ impl Check { CheckKind::Template { fix_cmd, .. } => !fix_cmd.is_empty(), CheckKind::Special(SpecialKind::Links) => false, CheckKind::Special(SpecialKind::RenovateDeps) => true, + CheckKind::Special(SpecialKind::LicenseHeader) => false, } } @@ -108,6 +116,9 @@ impl Check { excludes_if_active: &[], slow: false, linter_config: None, + is_formatter: false, + defers_to_formatters: false, + activate_unconditionally: false, kind: CheckKind::Template { check_cmd, fix_cmd: "", @@ -127,6 +138,9 @@ impl Check { excludes_if_active: &[], slow: false, linter_config: None, + is_formatter: false, + defers_to_formatters: false, + activate_unconditionally: false, kind: CheckKind::Special(kind), } } @@ -165,18 +179,30 @@ impl Check { self } - /// Skip files already owned by the named checks (avoids double-checking). - pub fn excludes(mut self, names: &'static [&'static str]) -> Self { - self.excludes_if_active = names; - self - } - /// Mark as slow — skipped when `--fast` is passed. pub fn slow(mut self) -> Self { self.slow = true; self } + /// Mark as a formatter — files it owns are excluded from ec when both are active. + pub fn formatter(mut self) -> Self { + self.is_formatter = true; + self + } + + /// Skip files owned by active formatters (for ec — avoids double-checking). + pub fn defer_to_formatters(mut self) -> Self { + self.defers_to_formatters = true; + self + } + + /// Always considered active regardless of mise.toml (for config-activated checks). + pub fn activate_unconditionally(mut self) -> Self { + self.activate_unconditionally = true; + self + } + /// Inject a config file from config_dir into the linter command. /// If `config_dir/file` exists at runtime, `flag ` is inserted /// right after the binary name. Has no effect when the file is absent. @@ -194,17 +220,23 @@ pub fn builtin() -> Vec { &["*.sh", "*.bash", "*.bats"], ) .linter_config(".shellcheckrc", "--rcfile"), - Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]).fix("shfmt -w {FILE}"), + Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) + .fix("shfmt -w {FILE}") + .formatter(), Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) .fix("markdownlint --fix {FILE}") .linter_config(".markdownlint.json", "--config"), + Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) + .fix("markdownlint-cli2 --fix {FILE}") + .linter_config(".markdownlint.json", "--config"), Check::files( "prettier", "prettier --check {FILES}", &["*.md", "*.yml", "*.yaml"], ) .fix("prettier --write {FILES}") - .linter_config(".prettierrc", "--config"), + .linter_config(".prettierrc", "--config") + .formatter(), Check::file( "actionlint", "actionlint {FILE}", @@ -224,7 +256,7 @@ pub fn builtin() -> Vec { // that conflict with ec's max_line_length editorconfig check. // Note: ec's -config flag controls ec's own JSON config, not .editorconfig itself. Check::files("ec", "ec {FILES}", &["*"]) - .excludes(&["cargo-fmt", "ruff-format", "biome-format", "prettier"]) + .defer_to_formatters() .linter_config(".editorconfig-checker.json", "-config"), Check::project( "golangci-lint", @@ -238,7 +270,8 @@ pub fn builtin() -> Vec { Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]) .bin("ruff") .fix("ruff format {FILE}") - .linter_config("ruff.toml", "--config"), + .linter_config("ruff.toml", "--config") + .formatter(), Check::file( "biome", "biome check {FILE}", @@ -251,15 +284,53 @@ pub fn builtin() -> Vec { &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], ) .bin("biome") - .fix("biome format --write {FILE}"), + .fix("biome format --write {FILE}") + .formatter(), Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") .mise_tool("rust"), Check::project("cargo-fmt", "cargo fmt -- --check", &["*.rs"]) .fix("cargo fmt") - .mise_tool("rust"), + .mise_tool("rust") + .formatter(), + Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) + .fix("gofmt -w {FILE}") + .mise_tool("go") + .formatter(), + Check::files( + "google-java-format", + "google-java-format --dry-run --set-exit-if-changed {FILES}", + &["*.java"], + ) + .fix("google-java-format -i {FILES}") + .mise_tool("ubi:google/google-java-format") + .formatter(), + Check::files("ktlint", "ktlint {FILES}", &["*.kt", "*.kts"]) + .fix("ktlint --format {FILES}") + .mise_tool("ubi:pinterest/ktlint") + .bin(if cfg!(windows) { + "ktlint.bat" + } else { + "ktlint" + }) + .formatter(), + Check::project( + "dotnet-format", + "dotnet format --verify-no-changes", + &["*.cs"], + ) + .fix("dotnet format") + .mise_tool("dotnet") + .slow() + .formatter(), Check::special("lychee", "lychee", SpecialKind::Links), Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps).slow(), + Check::special( + "license-header", + "license-header", + SpecialKind::LicenseHeader, + ) + .activate_unconditionally(), ] } @@ -296,6 +367,9 @@ pub fn read_mise_tools(project_root: &Path) -> HashMap { /// Returns true if the check's tool is declared in mise.toml and its version /// satisfies the check's version_range (if any). pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool { + if check.activate_unconditionally { + return true; + } let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); let Some(declared) = mise_tools.get(lookup_key) else { return false; diff --git a/src/runner.rs b/src/runner.rs index 8dd1f1b..20fbc59 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -4,9 +4,9 @@ use std::process::Stdio; use tokio::process::Command; use tokio::task::JoinSet; -use crate::config::{Config, LycheeConfig, RenovateDepsConfig}; +use crate::config::{Config, LicenseHeaderConfig, LycheeConfig, RenovateDepsConfig}; use crate::files::FileList; -use crate::linters::{lychee, renovate_deps}; +use crate::linters::{license_header, lychee, renovate_deps}; use crate::registry::{Check, CheckKind, Scope, SpecialKind}; pub struct RunOptions { @@ -39,6 +39,11 @@ enum PreparedCheck { name: String, cfg: RenovateDepsConfig, }, + LicenseHeader { + name: String, + cfg: LicenseHeaderConfig, + files: Vec, + }, } impl PreparedCheck { @@ -46,7 +51,8 @@ impl PreparedCheck { match self { Self::Invocations { name, .. } | Self::Links { name, .. } - | Self::RenovateDeps { name, .. } => name, + | Self::RenovateDeps { name, .. } + | Self::LicenseHeader { name, .. } => name, } } @@ -63,6 +69,9 @@ impl PreparedCheck { .. } => lychee::run(&cfg, &file_list, project_root, &config_dir).await, Self::RenovateDeps { cfg, .. } => renovate_deps::run(&cfg, fix, project_root).await, + Self::LicenseHeader { cfg, files, .. } => { + license_header::run(&cfg, project_root, &files).await + } }; CheckResult { name, @@ -172,6 +181,11 @@ fn prepare( name, cfg: cfg.checks.renovate_deps.clone(), }), + CheckKind::Special(SpecialKind::LicenseHeader) => Some(PreparedCheck::LicenseHeader { + name, + cfg: cfg.checks.license_header.clone(), + files: file_list.files.clone(), + }), } } @@ -200,12 +214,19 @@ fn build_invocations( }; // Collect patterns from checks that are active and listed in excludes_if_active. - let excludes: Vec<&str> = active_checks + let mut excludes: Vec<&str> = active_checks .iter() .filter(|c| check.excludes_if_active.contains(&c.name)) .flat_map(|c| c.patterns.iter().copied()) .collect(); + // When this check defers to formatters, also exclude files owned by active formatters. + if check.defers_to_formatters { + for active in active_checks.iter().filter(|c| c.is_formatter) { + excludes.extend(active.patterns.iter().copied()); + } + } + let config_args = resolve_linter_config(check, config_dir); match scope { @@ -388,25 +409,41 @@ fn substitute_merge_base(cmd: &str, merge_base: Option<&str>) -> String { fn quote_path(p: &Path) -> String { let s = p.to_string_lossy(); - format!("'{}'", s.replace('\'', "'\\''")) + format!("\"{}\"", s.replace('"', "\\\"")) } fn shell_words(cmd: String) -> Vec { - // Minimal word-splitting that respects single-quoted strings. + // Minimal word-splitting that respects single- and double-quoted strings. let mut words = vec![]; let mut current = String::new(); let mut in_single = false; + let mut in_double = false; let chars: Vec = cmd.chars().collect(); let mut i = 0; while i < chars.len() { match chars[i] { - '\'' if !in_single => { + '\'' if !in_single && !in_double => { in_single = true; } '\'' if in_single => { in_single = false; } - ' ' | '\t' if !in_single => { + '"' if !in_single && !in_double => { + in_double = true; + } + '"' if in_double => { + in_double = false; + } + '\\' if in_double => { + // Only handle \" inside double quotes; pass other backslashes through. + if i + 1 < chars.len() && chars[i + 1] == '"' { + current.push('"'); + i += 2; + continue; + } + current.push('\\'); + } + ' ' | '\t' if !in_single && !in_double => { if !current.is_empty() { words.push(std::mem::take(&mut current)); } @@ -491,6 +528,9 @@ mod tests { excludes_if_active: &[], slow: false, linter_config: None, + is_formatter: false, + defers_to_formatters: false, + activate_unconditionally: false, kind: CheckKind::Template { check_cmd: "run-it", fix_cmd: "", diff --git a/tests/cases/env-var-exclude/test.toml b/tests/cases/env-var-exclude/test.toml index 0411110..db77a76 100644 --- a/tests/cases/env-var-exclude/test.toml +++ b/tests/cases/env-var-exclude/test.toml @@ -1,2 +1,5 @@ args = "--full shellcheck" exit = 0 + +[env] +FLINT_EXCLUDE = "bad.sh" From b54be4c02a6c94cd81b0ca1745a68f2392699ec9 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 13:42:48 +0000 Subject: [PATCH 036/141] docs: restructure README for v2, remove AUTOFIX env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md now documents the v2 Rust binary (Getting Started + Reference) - README-V1.md preserves the full v1 bash task script docs - FLINT-V2.md removed — content merged into README.md - AGENTS-V2.md updated to reference README.md - Remove AUTOFIX env var: v1 holdover, no longer needed — use flint --fix Signed-off-by: Gregor Zeitlinger --- AGENTS-V2.md | 2 +- FLINT-V2.md | 297 -------- README-V1.md | 542 ++++++++++++++ README.md | 700 +++++++----------- src/main.rs | 2 +- .../auto-fix-and-review/files/Cargo.toml | 4 + tests/cases/auto-fix-and-review/files/bad.sh | 2 + .../cases/auto-fix-and-review/files/mise.toml | 3 + .../auto-fix-and-review/files/src/lib.rs | 1 + tests/cases/auto-fix-and-review/test.toml | 17 + tests/cases/env-var-exclude/test.toml | 2 +- 11 files changed, 829 insertions(+), 743 deletions(-) delete mode 100644 FLINT-V2.md create mode 100644 README-V1.md create mode 100644 tests/cases/auto-fix-and-review/files/Cargo.toml create mode 100644 tests/cases/auto-fix-and-review/files/bad.sh create mode 100644 tests/cases/auto-fix-and-review/files/mise.toml create mode 100644 tests/cases/auto-fix-and-review/files/src/lib.rs create mode 100644 tests/cases/auto-fix-and-review/test.toml diff --git a/AGENTS-V2.md b/AGENTS-V2.md index 626de1c..7840265 100644 --- a/AGENTS-V2.md +++ b/AGENTS-V2.md @@ -10,7 +10,7 @@ tools from the consuming repo's `mise.toml`, runs them against changed files in parallel, and produces identical output locally and in CI. -See [FLINT-V2.md](FLINT-V2.md) for usage documentation. +See [README.md](README.md) for usage documentation. ## Architecture diff --git a/FLINT-V2.md b/FLINT-V2.md deleted file mode 100644 index fee5f0d..0000000 --- a/FLINT-V2.md +++ /dev/null @@ -1,297 +0,0 @@ -# flint v2 - -A single Rust binary that replaces the bash task scripts. -Discovers linting tools from PATH, runs them against changed files in parallel, -and produces identical output locally and in CI. - -> **Status**: in development on the `feat/flint-v2` branch. -> The bash task scripts (v1) remain the stable option until v2 is released. - -## Why - -The bash task scripts (v1) have two problems: - -**Local ≠ CI**: `--native` runs a subset of linters; CI runs full super-linter -in Docker. Different tools, different behavior. Passing locally does not mean -passing in CI. - -**Bash has limits**: the registry pattern was already at the edge of what bash -does cleanly. Adding built-in checks (links, renovate) would make it worse. - -### Why not pre-commit? - -pre-commit adds a parallel tool management system on top of mise. Consuming repos -already declare their tools in `mise.toml` — pre-commit would require maintaining -a second inventory of the same tools in `.pre-commit-config.yaml`, with its own -versioning and install lifecycle. That's friction without benefit for repos that -are already mise-first. - -### Why not MegaLinter / super-linter? - -Container-based linters (super-linter, MegaLinter) ship their own tool versions, -independent of what the repo pins in `mise.toml`. This breaks the "declare once, -use everywhere" promise of mise. Container startup also adds latency to every run. - -## Principles - -1. **mise-based** — `flint` distributed via mise. Tools managed by the consuming - repo's `mise.toml`. No separate tool installation step. - -2. **Fast** — native execution only (no Docker). Linters run in parallel. - Designed to be the default `mise run lint`, not a slow fallback. - Slow checks (e.g. `renovate-deps`) can be skipped with `--fast`. - -3. **Local same as CI** — one binary, one config, identical behavior. - No "native mode subset" distinction. If it passes locally, it passes in CI. - -4. **AI-friendly** — `--short` suppresses per-check output and emits a single - structured summary line (`flint --fix prettier | review: shellcheck`) for - token-efficient AI consumption. Fixable checks are expressed as the exact - command to run — no reasoning step required. Also runnable containerised — - no host tool dependencies required. - -5. **Opt-in via tool install** — checks auto-enable when their binary is in PATH. - Installing a tool in `mise.toml` is the opt-in. `flint.toml` adds detail - (config paths, exclusions) but is not required to activate anything. - -6. **Changed files by default** — git-aware diff detection. `--from-ref`/`--to-ref` - for CI. `--full` to check everything. Falls back to all files when no merge - base is found. - -7. **Autofix where possible** — `--fix` flag (or `AUTOFIX=true`). Fix mode runs - serially to avoid concurrent writes to the same file. Pass specific linter - names to limit which fixers run (`flint --fix prettier shfmt`). - -## Installation - -Add `flint` to your repo's `mise.toml` (once published): - -```toml -[tools] -flint = "0.x.y" -``` - -Until the first release, build from source: - -```bash -git clone https://github.com/grafana/flint -cd flint -cargo build --release -# Binary at target/release/flint -``` - -## Usage - -```text -flint [OPTIONS] [LINTERS...] -flint list -``` - -**Options:** - -| Flag | Description | -| ---------------- | -------------------------------------------------- | -| `--fix` | Auto-fix issues instead of checking | -| `--auto` | Fix what's fixable, report what still needs review | -| `--full` | Lint all files instead of only changed files | -| `--fast` | Skip slow checks (e.g. `renovate-deps`) | -| `--short` | Compact summary output, no per-check noise | -| `--verbose` | Show all linter output, not just failures | -| `--from-ref REF` | Diff base (default: merge base with base branch) | -| `--to-ref REF` | Diff head (default: HEAD) | - -Env var equivalents: `AUTOFIX=true` for `--fix`, `FLINT_SHORT=true` for `--short`. - -### Intended use by context - -| Context | Command | Why | -| ---------------------------- | ------------------------- | ----------------------------------------------------------------- | -| Interactive development | `flint` or `flint --fast` | Full output so you can read the details | -| Human wanting a summary | `flint --short` | Compact output, no per-check noise | -| Pre-push hook (CC / agentic) | `flint --auto --fast` | Fixes what it can silently, surfaces only what needs human review | -| CI | `flint` | Full output for humans reading CI logs | - -**`--short` output** — failed checks partitioned by fixability, fixable ones -expressed as the exact command to run: - -```text -flint: 2 checks failed — flint --fix prettier cargo-fmt | review: shellcheck -``` - -**`--auto` output** — fixes what's fixable, reports the outcome. Exits 1 if -anything was fixed (so the caller commits the fixes before pushing) or if -anything still needs review. Exits 0 only if everything was already clean: - -```text -flint: fixed: prettier cargo-fmt — commit before pushing | review: shellcheck -``` - -Pass one or more linter names to run only those: - -```bash -flint shellcheck shfmt # run only shellcheck and shfmt -flint --fix prettier # fix only prettier -``` - -`flint list` shows every check with its status: - -```text -NAME BINARY STATUS SPEED PATTERNS -------------------------------------------------------------------- -shellcheck shellcheck installed fast *.sh *.bash *.bats -cargo-fmt cargo-fmt missing fast *.rs -renovate-deps renovate installed slow -... -``` - -## Config (`flint.toml`) - -Optional. Place in the repo root (or in `FLINT_CONFIG_DIR` — see below). All settings have defaults. - -```toml -[settings] -base_branch = "main" # branch to diff against -exclude = "CHANGELOG\\.md|vendor/.*" # regex — exclude matching files - -[checks.links] -config = ".github/config/lychee.toml" # lychee config path -check_all_local = true # second pass: local links in all files - -[checks.renovate-deps] -exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers -``` - -### `FLINT_CONFIG_DIR` - -Set this env var to consolidate config files in one directory (e.g. `.github/config`): - -```toml -# mise.toml -[env] -FLINT_CONFIG_DIR = ".github/config" -``` - -When set, `flint.toml` is loaded from that directory, and each linter that supports -an explicit config flag will have it injected automatically when the corresponding -file exists there (see the "Config file" column in the table below). Files that are -absent are silently skipped — existing project-root configs remain in effect. - -**Note:** `ec`'s config file (`.editorconfig-checker.json`) controls ec's own settings, -not `.editorconfig` itself — editorconfig discovery always walks up from the file -being linted and cannot be redirected via a flag. - -## mise.toml wiring - -```toml -[tools] -flint = "0.x.y" - -[tasks.lint] -description = "Run all lints" -run = "flint" - -[tasks."lint:pre-commit"] -description = "Fast auto-fix lint pass (skips slow checks) — intended for pre-commit/pre-push hooks" -run = "flint --auto --fast" - -[tasks."lint:fix"] -description = "Auto-fix lint issues" -run = "flint --fix" -``` - -## Built-in linter registry - -Checks auto-enable when their binary is found in PATH. Install tools via `mise.toml`. - - - -| Name | Binary | Patterns | Fix | Scope | Config file | -| --------------- | --------------- | -------------------------------------------------- | --- | ------- | ------------------------------ | -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | `.shellcheckrc` | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | — | -| `markdownlint` | `markdownlint` | `*.md` | yes | file | `.markdownlint.json` | -| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | files | `.prettierrc` | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | `actionlint.yml` | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | `.hadolint.yaml` | -| `codespell` | `codespell` | `*` | yes | files | `.codespellrc` | -| `ec` | `ec` | `*` | no | files | `.editorconfig-checker.json` | -| `golangci-lint` | `golangci-lint` | `*.go` | no | project | `.golangci.yml` | -| `ruff` | `ruff` | `*.py` | yes | file | `ruff.toml` | -| `ruff-format` | `ruff` | `*.py` | yes | file | `ruff.toml` | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | — | -| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | — | -| `links` | `lychee` | (all files) | no | special | via `[checks.links]` in flint.toml | -| `renovate-deps` | `renovate` | (all files) | yes | special | — | - -¹ Not yet implemented. Biome's flag (`--config-path`) takes a directory, not a -file path — requires a directory-injection variant of the config mechanism. - - - -**Scopes:** - -- `file` — invoked once per matched file -- `files` — invoked once with all matched files as args -- `project` — invoked once with no file args; for checks with patterns set - (e.g. `cargo-clippy`), skipped entirely if no matching files changed - -**Slow checks** (`renovate-deps`) are skipped by `--fast`. Use `--fast` for -local/pre-push feedback and the full set in CI. - -**`ec` deference**: `ec` (editorconfig-checker) runs on all files, but -automatically skips file types owned by an active line-length-enforcing -formatter. When `cargo-fmt`, `ruff-format`, `biome-format`, or `prettier` -are active, their file types are excluded from `ec` — those formatters -already enforce line length and would conflict with `ec`'s -`max_line_length` editorconfig check. If none of those formatters are -installed, `ec` checks those files itself. - -## Special checks - -### links - -Orchestrates [lychee](https://lychee.cli.rs/) for link checking. Requires -`lychee` in PATH (install via `mise.toml`). - -Default behavior: checks all links in changed files. When `check_all_local = true` -in `flint.toml`, adds a second pass over local links in all files — useful when -broken internal links from unchanged files also matter. - -Configure via `flint.toml`: - -```toml -[checks.links] -config = ".github/config/lychee.toml" -check_all_local = true -``` - -### renovate-deps - -Verifies `.github/renovate-tracked-deps.json` is up to date by running Renovate -locally and comparing its output against the committed snapshot. Same purpose as -the v1 `lint:renovate-deps` task. Requires `renovate` in PATH (install via `mise.toml`). - -Tagged `slow = true` — skipped by `--fast`. With `--fix`, automatically regenerates -and commits the snapshot. - -Configure via `flint.toml`: - -```toml -[checks.renovate-deps] -exclude_managers = ["github-actions", "github-runners"] -``` - -## CI example - -```yaml -- name: Install tools - run: mise install - -- name: Lint - run: mise run lint # or: flint --from-ref origin/main --to-ref HEAD -``` - -`--from-ref`/`--to-ref` is optional in CI — flint detects the merge base -automatically when running in a PR context. diff --git a/README-V1.md b/README-V1.md new file mode 100644 index 0000000..79b2423 --- /dev/null +++ b/README-V1.md @@ -0,0 +1,542 @@ +# flint v1 (legacy) + +> **This is the legacy v1 documentation** (bash task scripts consumed as +> mise HTTP remote tasks). The current version is [flint v2](README.md) — +> a single Rust binary. + +A toolbox of reusable [mise](https://mise.jdx.dev/) lint task scripts. +Pick the ones you need — each task is independent and can be adopted +on its own. + +**Available tasks:** + +| Task | Tool | +| -------------------- | ------------------------------------------------------------- | +| `lint:super-linter` | [Super-Linter](https://github.com/super-linter/super-linter) | +| `lint:links` | [lychee](https://lychee.cli.rs/) | +| `lint:renovate-deps` | [Renovate](https://docs.renovatebot.com/) dependency tracking | + +## How it works + +Flint relies on two tools that each play a distinct role: + +### mise — the task runner + +[mise](https://mise.jdx.dev/) is a polyglot dev tool manager and task +runner. In the context of flint, mise serves two purposes: + +1. **Installing tools.** mise's `[tools]` section pins exact versions + of the linters each task needs (e.g., `lychee`, `node`, + `"npm:renovate"`). Running `mise install` gives every developer and + CI runner the same versions, so local runs are consistent with CI. + +2. **Running tasks.** mise downloads task scripts from this repository + via HTTP, wires them into your project as local commands + (`mise run lint`, `mise run fix`), and passes flags and environment + variables through to each script. You don't need to clone flint — + mise fetches the scripts directly from GitHub URLs pinned in your + `mise.toml`. + +### Renovate — the dependency updater + +[Renovate](https://docs.renovatebot.com/) is an automated dependency update bot. +Extending the flint [Renovate preset](#automatic-version-updates-with-renovate) +(`default.json`) is essential for any repository that uses flint — without it, +SHA-pinned flint URLs and `_VERSION` variables in `mise.toml` would never get +updated. The preset ships custom managers that detect these patterns and open +PRs to bump both flint itself and the tools it runs +(e.g., Super-Linter, lychee). + +Optionally, the [`lint:renovate-deps`](#lintrenovate-deps) task adds a second +layer: it runs Renovate locally to detect which dependencies Renovate is +tracking, compares this against a committed snapshot, and fails if they +diverge — catching cases where a dependency silently falls off Renovate's +radar. + +## Usage + +āš ļø **Important**: Always pin to a specific version, never use `main`. +The main branch may contain breaking changes. +See [CHANGELOG.md](CHANGELOG.md) for version history. + +Add whichever tasks you need as HTTP remote tasks in your `mise.toml`, +pinned to the commit SHA of a release tag with a version comment: + + + +```toml +# Pick the tasks you need from flint (https://github.com/grafana/flint) +[tasks."lint:super-linter"] +description = "Run Super-Linter on the repository" +file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/super-linter.sh" # v0.9.1 +[tasks."lint:links"] +description = "Check for broken links in changed files + all local links" +file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/links.sh" # v0.9.1 +[tasks."lint:renovate-deps"] +description = "Verify renovate-tracked-deps.json is up to date" +file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/renovate-deps.py" # v0.9.1 +``` + + + +The SHA pin ensures the URL is immutable (tag-based URLs can change +if a tag is force-pushed), and the `# v0.3.0` comment tells Renovate +which version is currently pinned. + +Then wire up top-level `lint` and `fix` tasks that reference whichever tasks +you adopted (add any project-specific subtasks to the `depends` list): + +```toml +[tasks."lint:fast"] +description = "Run fast lints (no Renovate)" +depends = ["lint:super-linter", "lint:links"] + +[tasks.lint] +description = "Run all lints" +depends = ["lint:fast", "lint:renovate-deps"] + +[tasks.fix] +description = "Auto-fix lint issues and regenerate tracked deps" +run = "AUTOFIX=true mise run lint" + +[tasks.native-lint] +description = "Run lints natively (no container)" +run = "NATIVE=true mise run lint:fast" +``` + +Finally, extend the flint [Renovate preset](#automatic-version-updates-with-renovate) +in your `renovate.json5` to keep flint and its tools up to date: + +```json5 +{ + extends: ["github>grafana/flint"], +} +``` + +Without this, SHA-pinned flint URLs and tool versions (e.g., +`SUPER_LINTER_VERSION`) in `mise.toml` will never receive automated +updates. + +## Example + +See [grafana/docker-otel-lgtm][example-repo] for a real-world example +of a repository using flint. Its [CONTRIBUTING.md][example-contributing] +describes the developer workflow, and its [mise.toml][example-mise] +shows how the tasks are wired up. + +[example-repo]: https://github.com/grafana/docker-otel-lgtm +[example-contributing]: https://github.com/grafana/docker-otel-lgtm/blob/main/CONTRIBUTING.md +[example-mise]: https://github.com/grafana/docker-otel-lgtm/blob/main/mise.toml + +## Tasks + +### `lint:super-linter` + +Runs [Super-Linter](https://github.com/super-linter/super-linter) +via Docker or Podman. Auto-detects the container runtime (prefers +Podman, falls back to Docker) and handles SELinux bind-mount flags +on Fedora. + +**mise** fetches this script from the SHA-pinned URL in `mise.toml` +and runs it as `mise run lint:super-linter`. The +`SUPER_LINTER_VERSION` environment variable (set in `mise.toml`) +controls which Super-Linter image is pulled. **Renovate**, via the +flint preset, opens PRs to bump both the flint script URL and the +`SUPER_LINTER_VERSION` value when new versions are available. + +**Slim vs full image:** Super-Linter publishes a slim image +(`slim-v8.4.0`) that is ~2 GB smaller than the full image. The slim +image excludes Rust, .NET/C#, PowerShell, and ARM template linters. +Flint defaults to the slim image. To use the full image instead, set +`SUPER_LINTER_VERSION` to the non-prefixed tag (e.g. +`v8.4.0@sha256:...`) and update the Renovate `depName` comment +accordingly (drop the `versioning` override so Renovate uses standard +Docker versioning). + +**Flags:** + +| Flag | Description | +| ----------- | ------------------------------------------------------------ | +| `--autofix` | Enable autofix mode (enables `FIX_*` vars from the env file) | +| `--native` | Run linters natively instead of via container | +| `--full` | Lint all files instead of only changed files | + +`--autofix` and `--native` can also be set via the `AUTOFIX=true` +and `NATIVE=true` environment variables respectively. This is how +the `fix` and `native-lint` meta-tasks propagate them through the +`depends` chain. + +When autofix is not enabled, all `FIX_*` lines are filtered out of +the env file before running Super-Linter. + +**Native mode:** + +The `--native` flag runs a **subset** of linters directly on +the host for fast local feedback. It is not a full replacement +for the Super-Linter container — CI should always use the +container for comprehensive checks. + +Native mode reads the same `super-linter.env` file and follows +Super-Linter's default logic for determining which linters are +enabled: if any `VALIDATE_*=true` is set, only those linters run; +otherwise all linters run unless explicitly `VALIDATE_*=false`. +`FILTER_REGEX_EXCLUDE` is respected. `FIX_*` variables are honored +when `--autofix` is also set. + +Supported native linters (subset of super-linter): + +- `actionlint` +- `biome` +- `codespell` +- `editorconfig-checker` +- `golangci-lint` +- `hadolint` +- `markdownlint` +- `prettier` +- `ruff` +- `shellcheck` +- `shfmt` + +Tools must be installed separately (e.g., via +`mise run setup:native-lint-tools`). Missing tools and unsupported +`VALIDATE_*` flags are skipped with a warning. Linter configs must +be at standard project-root locations (not `.github/linters/`). + +**Environment variables:** + + + +| Variable | Default | Required | Description | +| ----------------------- | --------------------------------- | -------- | --------------------------------------------------------------------------------------------- | +| `SUPER_LINTER_VERSION` | — | yes | Super-Linter image tag (e.g. `slim-v8.4.0@sha256:...` for slim, `v8.4.0@sha256:...` for full) | +| `SUPER_LINTER_ENV_FILE` | `.github/config/super-linter.env` | no | Path to the Super-Linter env file | + + + +### `lint:links` + +Checks links with [lychee](https://lychee.cli.rs/). By default, it +runs two checks: **all links (local + remote) in modified files** and +**local file links in all files**. This keeps CI fast while catching +both broken remote links in changed content and broken internal links +across the whole repository. + +**mise** fetches this script and runs it as `mise run lint:links`. +Lychee is installed via mise's `[tools]` section — add +`lychee = ""` to your `mise.toml`. **Renovate**, via the +flint preset, opens PRs to bump the flint script URL when a new +version is available. + +**Flags:** + + + +| Flag | Description | +| ---------------------- | ------------------------------------------------------------------------------------ | +| `--full` | Check all links (local + remote) in all files (single run) | +| `--base ` | Base branch to compare against (default: `origin/$GITHUB_BASE_REF` or `origin/main`) | +| `--head ` | Head commit to compare against (default: `$GITHUB_HEAD_SHA` or `HEAD`) | +| `--lychee-args ` | Extra arguments to pass to lychee | +| `...` | Files to check (default: `.`; only used with `--full`) | + + + +When running in default mode, if a config change is detected +(matching `LYCHEE_CONFIG_CHANGE_PATTERN` or lychee-related changes +in `mise.toml`), the script falls back to `--full` behavior. +Changes to `mise.toml` are content-aware: only lychee-related +lines (e.g. version or task config) trigger a full check, not +unrelated tool version bumps. + +**GitHub URL remaps:** + +When running on a PR branch, the script automatically remaps GitHub +`/blob//` and `/tree//` URLs so that links +to the base branch resolve against the PR branch instead. This +ensures that links like `/blob/main/README.md` don't break when +the file was added or moved in the PR. + +For `/blob/` URLs, four ordered remap rules are applied +(lychee uses first-match-wins): + +1. **Line-number anchors** (`#L123`, `#L10-L20`): GitHub renders + these with JavaScript, so lychee can never verify the fragment. + The anchor is stripped and the file is checked on the PR branch. +2. **[Scroll to Text Fragment][stf] anchors** (`#:~:text=...`): + Browser-only feature, not present in static HTML. The anchor + is stripped and the file is checked on the PR branch. +3. **Other fragment URLs** (`#section`): Remapped to + `raw.githubusercontent.com` where lychee can verify the fragment + in the raw file content (workaround for + [lychee#1729](https://github.com/lycheeverse/lychee/issues/1729)). +4. **Non-fragment URLs**: Remapped from the base branch to the PR + branch (the original behavior). + +For `/tree/` URLs, rules 1 and 4 apply (no raw remap needed). + +**Global GitHub URL handling:** + +In addition to the PR-specific remaps above, the script handles +two patterns that affect ALL GitHub URLs (any repository): + +- **Line-number anchors** (`#L123`, `#L10-L20`): Stripped from + any GitHub `/blob/` URL. The file is still checked, but the + JS-rendered line-number fragment is skipped. This means + consuming repos don't need to exclude these in their + `lychee.toml`. +- **Scroll to Text Fragment anchors** (`#:~:text=...`): Stripped + from any GitHub `/blob/` URL. These are a browser-only feature + not present in static HTML. +- **Issue comment anchors** (`#issuecomment-*`): The fragment + is stripped so the issue/PR page is still checked, but the + JS-rendered comment anchor is skipped. + +Set `LYCHEE_SKIP_GITHUB_REMAPS=true` to disable all GitHub-specific +remaps as an escape hatch if they cause unexpected behavior. + +**Lychee config cleanup:** + +When adopting `lint:links`, you can remove the following entries +from your `lychee.toml` because flint handles them at runtime +via `--remap` arguments: + +- **GitHub blob/fragment remap for + [lychee#1729](https://github.com/lycheeverse/lychee/issues/1729)** + — flint remaps fragment URLs to `raw.githubusercontent.com` + for the current PR's head branch, and strips line-number + and Scroll to Text Fragment anchors globally. +- **`#issuecomment-*` excludes** — flint strips the fragment + via remap so the issue/PR page is still checked. +- **`#L\d+` / `#L\d+-L\d+` line-number excludes** — flint strips + the fragment via remap so the file is still checked. +- **`#:~:text=...` [Scroll to Text Fragment][stf] excludes** — + flint strips the fragment via remap so the file is still + checked. + +Note: flint uses `--remap` (not `--exclude`) for these because +lychee's CLI `--exclude` flags override config file excludes +rather than merging with them. + +**Environment variables:** + + + +| Variable | Default | Description | +| ------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `LYCHEE_CONFIG` | `.github/config/lychee.toml` | Path to the lychee config file | +| `LYCHEE_CONFIG_CHANGE_PATTERN` | `^(\.github/config/lychee\.toml\|\.mise/tasks/lint/.*)$` | Files whose change triggers a full link check (`mise.toml` checked separately) | +| `LYCHEE_SKIP_GITHUB_REMAPS` | unset | Set to `true` to disable all GitHub URL remaps | + + + +**Examples:** + +```bash +mise run lint:links # All links in modified + local links in all files (default) +mise run lint:links --full # All links in all files +``` + +### `lint:renovate-deps` + +Verifies `.github/renovate-tracked-deps.json` is up to date by +running Renovate locally and parsing its debug logs. + +**mise** fetches this script and runs it as `mise run lint:renovate-deps`. +The Renovate CLI is installed via mise's `[tools]` section — add +`node = ""` and `"npm:renovate" = ""` to your +`mise.toml`. **Renovate** plays a dual role here: the flint preset +keeps the script URL up to date, while the script itself runs Renovate +locally in `--platform=local` mode to discover which dependencies +Renovate is tracking and compares them against a committed snapshot. + +**Flags:** + +| Flag | Description | +| ----------- | ------------------------------------------------------ | +| `--autofix` | Automatically regenerate and update the committed file | + +**Environment variables:** + + + +| Variable | Default | Description | +| ------------------------------- | ------- | ----------------------------------------------------------------------------------- | +| `RENOVATE_TRACKED_DEPS_EXCLUDE` | unset | Comma-separated Renovate managers to exclude (e.g. `github-actions,github-runners`) | + + + +#### Why this exists + +Renovate silently stops tracking a dependency when it can no longer +parse the version reference (typo in a comment annotation, +unsupported syntax, moved file, etc.). When that happens, the +dependency freezes in place with no PR and no dashboard entry — it +simply disappears from Renovate's radar. + +The Dependency Dashboard catches _known_ dependencies that are +pending or in error, but it cannot show you a dependency that +Renovate no longer sees at all. This linter closes that gap by +keeping a committed snapshot of every dependency Renovate tracks +and failing CI when the two diverge. + +#### How the linter works + +The `lint:renovate-deps` task runs Renovate locally in +`--platform=local` mode, parses its debug log for the +`packageFiles with updates` message, and generates a dependency +list (grouped by file and manager). It then diffs this against the +committed `.github/renovate-tracked-deps.json`: + +- If they match → linter passes +- If they differ → linter fails with a unified diff showing which + dependencies were added or removed +- With `--autofix` flag (or `AUTOFIX=true` env var) → automatically + regenerates and updates the committed file + +#### Typical workflow + +- **A dependency disappears** (e.g., someone removes a + `# renovate:` comment or changes a file that Renovate was + matching) → CI fails, showing the removed dependency in the diff. + The author can then decide whether the removal was intentional or + accidental. + +- **A new dependency is added** → CI fails because the committed + snapshot is stale. Run `mise run fix` (or + `AUTOFIX=true mise run lint:renovate-deps`) to regenerate and + update the file, then commit. + +- **Routine regeneration** → After any change to `renovate.json5`, + Dockerfiles, `go.mod`, `package.json`, or other files Renovate + scans, the linter will detect the change and require + regeneration. + +## How AUTOFIX and NATIVE Work + +`lint:super-linter` accepts `--autofix` and `--native` flags. +Both can also be set as environment variables (`AUTOFIX=true`, +`NATIVE=true`), which is how the `fix` and `native-lint` +meta-tasks propagate them — mise's `depends` cannot forward CLI +flags, but env vars flow through naturally. Tasks that don't +recognize these variables simply ignore them. + +**Check mode** (default): + +```bash +mise run lint # Check all linters, fail on issues +mise run lint:super-linter # Check code style, fail on issues +mise run lint:renovate-deps # Verify tracked deps, fail if out of date +``` + +**Fix mode:** + +```bash +mise run fix # Auto-fix all fixable issues +# Or run individual linters: +mise run lint:super-linter --autofix # Apply code fixes +mise run lint:renovate-deps --autofix # Regenerate tracked deps +``` + +Linters that don't support autofix (like lychee link checker) +silently ignore the `AUTOFIX` environment variable. + +**Native mode:** + +```bash +mise run native-lint # Fast lints, natively (no container) +# Or run directly: +NATIVE=true mise run lint:fast # Same effect +mise run lint:super-linter --native # Single task with CLI flag +``` + +Native mode is useful in environments where Docker/Podman is +unavailable (e.g., inside containers, CI hooks). `native-lint` +targets `lint:fast` (super-linter + links), skipping +`lint:renovate-deps` which requires the Renovate CLI. Tasks +that don't use a container (like `lint:links`) ignore the +`NATIVE` variable. + +## Pre-commit hook + +Flint provides a `pre-commit` task that runs native linters on +every commit — fast feedback without the container overhead. To +set it up: + +```bash +mise run setup:pre-commit-hook +``` + +This generates a `.git/hooks/pre-commit` that runs +`mise run pre-commit`, which uses native mode for fast checks +without requiring a container. + +**For consuming repos**, add these tasks to your `mise.toml`: + +```toml +[tasks.pre-commit] +description = "Pre-commit hook: native lint" +depends = ["setup:native-lint-tools"] +run = "NATIVE=true mise run lint:fast" + +[tasks."setup:pre-commit-hook"] +description = "Install git pre-commit hook" +run = "mise generate git-pre-commit --write --task=pre-commit" +``` + +Then run `mise run setup:pre-commit-hook` once per clone. + +## Automatic version updates with Renovate + +Flint provides a [Renovate shareable preset](https://docs.renovatebot.com/config-presets/) +with custom managers that automatically update: + +- **SHA-pinned flint versions** in `mise.toml` + (`raw.githubusercontent.com` URLs with commit SHA and version + comment) +- **`_VERSION` variables** in `mise.toml` (e.g., `SUPER_LINTER_VERSION`) + +Add this to your `renovate.json5`: + +```json5 +{ + extends: ["github>grafana/flint"], +} +``` + +## Per-repo configuration + +Each task expects certain config files that your repository must +provide. You only need the files for the tasks you adopt: + +- **`lint:super-linter`** — Super-Linter env file + (`.github/config/super-linter.env`) to select which validators + to enable and which `FIX_*` vars to set, plus any linter config + files (`.golangci.yaml`, `.markdownlint.yaml`, `.yaml-lint.yml`, + `.editorconfig`, etc.) +- **`lint:links`** — Lychee config + (`.github/config/lychee.toml`) for exclusions, timeouts, + remappings +- **`lint:renovate-deps`** — Renovate config + (`.github/renovate.json5`) and committed snapshot + (`.github/renovate-tracked-deps.json`) +- **Renovate preset** — Add `"github>grafana/flint"` to your + `renovate.json5` `extends` array to enable automatic updates of + flint URLs and tool versions + +## Versioning + +This project uses [Semantic Versioning](https://semver.org/). +Breaking changes will be documented in [CHANGELOG.md](CHANGELOG.md) +and will result in a major version bump. + +**Always pin to a specific commit SHA** in your `mise.toml` file +URLs with a version comment (e.g., `# v0.6.0`). Never reference +`main` directly as it may contain unreleased breaking changes. To +find the commit SHA for a release tag, run +`git rev-parse v0.6.0`. + +## Releasing + +See [RELEASING.md](RELEASING.md). + +[stf]: https://developer.mozilla.org/en-US/docs/Web/URI/Fragment/Text_fragments diff --git a/README.md b/README.md index fe7b8a2..3f2626c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ flint logo

-

flint

+

flint — fast lint

Lint @@ -13,527 +13,349 @@ -A toolbox of reusable [mise](https://mise.jdx.dev/) lint task scripts. -Pick the ones you need — each task is independent and can be adopted -on its own. +mise-native linter runner. Parallel, cross-platform, AI-friendly, local == CI. +See [Why / Principles](#why) for background. -> **v2 in development**: a single Rust binary is replacing these bash -> scripts. See [FLINT-V2.md](FLINT-V2.md) for details. +> **Legacy v1** (bash task scripts): see [README-V1.md](README-V1.md). -**Available tasks:** +--- -| Task | Tool | -| -------------------- | ------------------------------------------------------------- | -| `lint:super-linter` | [Super-Linter](https://github.com/super-linter/super-linter) | -| `lint:links` | [lychee](https://lychee.cli.rs/) | -| `lint:renovate-deps` | [Renovate](https://docs.renovatebot.com/) dependency tracking | +## Getting Started -## How it works +### Installation -Flint relies on two tools that each play a distinct role: +Add `flint` to your repo's `mise.toml` (once published): -### mise — the task runner - -[mise](https://mise.jdx.dev/) is a polyglot dev tool manager and task -runner. In the context of flint, mise serves two purposes: - -1. **Installing tools.** mise's `[tools]` section pins exact versions - of the linters each task needs (e.g., `lychee`, `node`, - `"npm:renovate"`). Running `mise install` gives every developer and - CI runner the same versions, so local runs are consistent with CI. - -2. **Running tasks.** mise downloads task scripts from this repository - via HTTP, wires them into your project as local commands - (`mise run lint`, `mise run fix`), and passes flags and environment - variables through to each script. You don't need to clone flint — - mise fetches the scripts directly from GitHub URLs pinned in your - `mise.toml`. - -### Renovate — the dependency updater - -[Renovate](https://docs.renovatebot.com/) is an automated dependency update bot. -Extending the flint [Renovate preset](#automatic-version-updates-with-renovate) -(`default.json`) is essential for any repository that uses flint — without it, -SHA-pinned flint URLs and `_VERSION` variables in `mise.toml` would never get -updated. The preset ships custom managers that detect these patterns and open -PRs to bump both flint itself and the tools it runs -(e.g., Super-Linter, lychee). +```toml +[tools] +flint = "0.x.y" +``` -Optionally, the [`lint:renovate-deps`](#lintrenovate-deps) task adds a second -layer: it runs Renovate locally to detect which dependencies Renovate is -tracking, compares this against a committed snapshot, and fails if they -diverge — catching cases where a dependency silently falls off Renovate's -radar. +Until the first release, build from source: -## Usage +```bash +git clone https://github.com/grafana/flint +cd flint +cargo build --release +# Binary at target/release/flint +``` -āš ļø **Important**: Always pin to a specific version, never use `main`. -The main branch may contain breaking changes. -See [CHANGELOG.md](CHANGELOG.md) for version history. +### mise.toml setup -Add whichever tasks you need as HTTP remote tasks in your `mise.toml`, -pinned to the commit SHA of a release tag with a version comment: +Flint reads your `[tools]` section to discover which linters to run — declaring +a tool is the opt-in. No separate configuration needed to activate a check: if +`shellcheck` is in `[tools]`, flint runs shellcheck; if it isn't, that check is +skipped. `mise install` puts all declared tools on PATH; flint picks up whatever +is there. - +Add the linting tools your project needs alongside the `flint` binary itself: ```toml -# Pick the tasks you need from flint (https://github.com/grafana/flint) -[tasks."lint:super-linter"] -description = "Run Super-Linter on the repository" -file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/super-linter.sh" # v0.9.1 -[tasks."lint:links"] -description = "Check for broken links in changed files + all local links" -file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/links.sh" # v0.9.1 -[tasks."lint:renovate-deps"] -description = "Verify renovate-tracked-deps.json is up to date" -file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/renovate-deps.py" # v0.9.1 +[tools] +flint = "0.x.y" + +# Add whichever linters apply to your repo: +shellcheck = "v0.11.0" +shfmt = "v3.12.0" +actionlint = "1.7.10" +"npm:markdownlint-cli" = "0.47.0" +"npm:prettier" = "3.5.0" +rust = "1.87.0" # activates cargo-fmt + cargo-clippy +go = "1.24.0" # activates gofmt +lychee = "0.18.0" # activates links check +"npm:renovate" = "39.0.0" # activates renovate-deps check (slow) ``` - - -The SHA pin ensures the URL is immutable (tag-based URLs can change -if a tag is force-pushed), and the `# v0.3.0` comment tells Renovate -which version is currently pinned. - -Then wire up top-level `lint` and `fix` tasks that reference whichever tasks -you adopted (add any project-specific subtasks to the `depends` list): +Then wire up lint tasks: ```toml -[tasks."lint:fast"] -description = "Run fast lints (no Renovate)" -depends = ["lint:super-linter", "lint:links"] - [tasks.lint] description = "Run all lints" -depends = ["lint:fast", "lint:renovate-deps"] +run = "flint" -[tasks.fix] -description = "Auto-fix lint issues and regenerate tracked deps" -run = "AUTOFIX=true mise run lint" +[tasks."lint:pre-commit"] +description = "Fast auto-fix lint pass — for pre-push hooks and agentic pipelines" +run = "flint --auto --fast" -[tasks.native-lint] -description = "Run lints natively (no container)" -run = "NATIVE=true mise run lint:fast" +[tasks."lint:fix"] +description = "Auto-fix lint issues" +run = "flint --fix" ``` -Finally, extend the flint [Renovate preset](#automatic-version-updates-with-renovate) -in your `renovate.json5` to keep flint and its tools up to date: +### CI setup + +```yaml +- name: Checkout code + uses: actions/checkout@... + with: + fetch-depth: 0 # needed for merge-base detection + +- name: Setup mise + uses: jdx/mise-action@... -```json5 -{ - extends: ["github>grafana/flint"], -} +- name: Lint + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: mise run lint ``` -Without this, SHA-pinned flint URLs and tool versions (e.g., -`SUPER_LINTER_VERSION`) in `mise.toml` will never receive automated -updates. - -## Example - -See [grafana/docker-otel-lgtm][example-repo] for a real-world example -of a repository using flint. Its [CONTRIBUTING.md][example-contributing] -describes the developer workflow, and its [mise.toml][example-mise] -shows how the tasks are wired up. - -[example-repo]: https://github.com/grafana/docker-otel-lgtm -[example-contributing]: https://github.com/grafana/docker-otel-lgtm/blob/main/CONTRIBUTING.md -[example-mise]: https://github.com/grafana/docker-otel-lgtm/blob/main/mise.toml - -## Tasks - -### `lint:super-linter` - -Runs [Super-Linter](https://github.com/super-linter/super-linter) -via Docker or Podman. Auto-detects the container runtime (prefers -Podman, falls back to Docker) and handles SELinux bind-mount flags -on Fedora. - -**mise** fetches this script from the SHA-pinned URL in `mise.toml` -and runs it as `mise run lint:super-linter`. The -`SUPER_LINTER_VERSION` environment variable (set in `mise.toml`) -controls which Super-Linter image is pulled. **Renovate**, via the -flint preset, opens PRs to bump both the flint script URL and the -`SUPER_LINTER_VERSION` value when new versions are available. - -**Slim vs full image:** Super-Linter publishes a slim image -(`slim-v8.4.0`) that is ~2 GB smaller than the full image. The slim -image excludes Rust, .NET/C#, PowerShell, and ARM template linters. -Flint defaults to the slim image. To use the full image instead, set -`SUPER_LINTER_VERSION` to the non-prefixed tag (e.g. -`v8.4.0@sha256:...`) and update the Renovate `depName` comment -accordingly (drop the `versioning` override so Renovate uses standard -Docker versioning). - -**Flags:** - -| Flag | Description | -| ----------- | ------------------------------------------------------------ | -| `--autofix` | Enable autofix mode (enables `FIX_*` vars from the env file) | -| `--native` | Run linters natively instead of via container | -| `--full` | Lint all files instead of only changed files | - -`--autofix` and `--native` can also be set via the `AUTOFIX=true` -and `NATIVE=true` environment variables respectively. This is how -the `fix` and `native-lint` meta-tasks propagate them through the -`depends` chain. - -When autofix is not enabled, all `FIX_*` lines are filtered out of -the env file before running Super-Linter. - -**Native mode:** - -The `--native` flag runs a **subset** of linters directly on -the host for fast local feedback. It is not a full replacement -for the Super-Linter container — CI should always use the -container for comprehensive checks. - -Native mode reads the same `super-linter.env` file and follows -Super-Linter's default logic for determining which linters are -enabled: if any `VALIDATE_*=true` is set, only those linters run; -otherwise all linters run unless explicitly `VALIDATE_*=false`. -`FILTER_REGEX_EXCLUDE` is respected. `FIX_*` variables are honored -when `--autofix` is also set. - -Supported native linters (subset of super-linter): - -- `actionlint` -- `biome` -- `codespell` -- `editorconfig-checker` -- `golangci-lint` -- `hadolint` -- `markdownlint` -- `prettier` -- `ruff` -- `shellcheck` -- `shfmt` - -Tools must be installed separately (e.g., via -`mise run setup:native-lint-tools`). Missing tools and unsupported -`VALIDATE_*` flags are skipped with a warning. Linter configs must -be at standard project-root locations (not `.github/linters/`). - -**Environment variables:** +`GITHUB_HEAD_SHA` tells flint which commit is the PR head when running in CI. +`fetch-depth: 0` is required for merge-base detection. - +--- -| Variable | Default | Required | Description | -| ----------------------- | --------------------------------- | -------- | --------------------------------------------------------------------------------------------- | -| `SUPER_LINTER_VERSION` | — | yes | Super-Linter image tag (e.g. `slim-v8.4.0@sha256:...` for slim, `v8.4.0@sha256:...` for full) | -| `SUPER_LINTER_ENV_FILE` | `.github/config/super-linter.env` | no | Path to the Super-Linter env file | +## Reference - +### CLI -### `lint:links` +```text +flint [OPTIONS] [LINTERS...] +flint list +``` -Checks links with [lychee](https://lychee.cli.rs/). By default, it -runs two checks: **all links (local + remote) in modified files** and -**local file links in all files**. This keeps CI fast while catching -both broken remote links in changed content and broken internal links -across the whole repository. +| Flag | Description | +| ---------------- | -------------------------------------------------- | +| `--fix` | Auto-fix issues instead of checking | +| `--auto` | Fix what's fixable, report what still needs review | +| `--full` | Lint all files instead of only changed files | +| `--fast` | Skip slow checks (e.g. `renovate-deps`) | +| `--short` | Compact summary output, no per-check noise | +| `--verbose` | Show all linter output, not just failures | +| `--from-ref REF` | Diff base (default: merge base with base branch) | +| `--to-ref REF` | Diff head (default: HEAD) | + +Env var equivalent: `FLINT_SHORT=true` for `--short`. + +#### Intended use by context + +| Context | Command | Why | +| ---------------------------- | ------------------------- | ----------------------------------------------------------------- | +| Interactive development | `flint` or `flint --fast` | Full output so you can read the details | +| Human wanting a summary | `flint --short` | Compact output, no per-check noise | +| Pre-push hook (CC / agentic) | `flint --auto --fast` | Fixes what it can silently, surfaces only what needs human review | +| CI | `flint` | Full output for humans reading CI logs | + +**`--short` output** — failed checks partitioned by fixability, fixable ones +expressed as the exact command to run: + +```text +flint: 2 checks failed — flint --fix prettier cargo-fmt | review: shellcheck +``` -**mise** fetches this script and runs it as `mise run lint:links`. -Lychee is installed via mise's `[tools]` section — add -`lychee = ""` to your `mise.toml`. **Renovate**, via the -flint preset, opens PRs to bump the flint script URL when a new -version is available. +**`--auto` output** — fixes what's fixable, then prints the full output of +any checks that still need review, followed by a summary line. Exits 1 if +anything was fixed (so the caller commits the fixes before pushing) or if +anything still needs review. Exits 0 only if everything was already clean: -**Flags:** +```text +[shellcheck] - +In bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. +... +flint: fixed: cargo-fmt — commit before pushing | review: shellcheck +``` -| Flag | Description | -| ---------------------- | ------------------------------------------------------------------------------------ | -| `--full` | Check all links (local + remote) in all files (single run) | -| `--base ` | Base branch to compare against (default: `origin/$GITHUB_BASE_REF` or `origin/main`) | -| `--head ` | Head commit to compare against (default: `$GITHUB_HEAD_SHA` or `HEAD`) | -| `--lychee-args ` | Extra arguments to pass to lychee | -| `...` | Files to check (default: `.`; only used with `--full`) | +Pass one or more linter names to run only those: - +```bash +flint shellcheck shfmt # run only shellcheck and shfmt +flint --fix prettier # fix only prettier +``` -When running in default mode, if a config change is detected -(matching `LYCHEE_CONFIG_CHANGE_PATTERN` or lychee-related changes -in `mise.toml`), the script falls back to `--full` behavior. -Changes to `mise.toml` are content-aware: only lychee-related -lines (e.g. version or task config) trigger a full check, not -unrelated tool version bumps. - -**GitHub URL remaps:** - -When running on a PR branch, the script automatically remaps GitHub -`/blob//` and `/tree//` URLs so that links -to the base branch resolve against the PR branch instead. This -ensures that links like `/blob/main/README.md` don't break when -the file was added or moved in the PR. - -For `/blob/` URLs, four ordered remap rules are applied -(lychee uses first-match-wins): - -1. **Line-number anchors** (`#L123`, `#L10-L20`): GitHub renders - these with JavaScript, so lychee can never verify the fragment. - The anchor is stripped and the file is checked on the PR branch. -2. **[Scroll to Text Fragment][stf] anchors** (`#:~:text=...`): - Browser-only feature, not present in static HTML. The anchor - is stripped and the file is checked on the PR branch. -3. **Other fragment URLs** (`#section`): Remapped to - `raw.githubusercontent.com` where lychee can verify the fragment - in the raw file content (workaround for - [lychee#1729](https://github.com/lycheeverse/lychee/issues/1729)). -4. **Non-fragment URLs**: Remapped from the base branch to the PR - branch (the original behavior). - -For `/tree/` URLs, rules 1 and 4 apply (no raw remap needed). - -**Global GitHub URL handling:** - -In addition to the PR-specific remaps above, the script handles -two patterns that affect ALL GitHub URLs (any repository): - -- **Line-number anchors** (`#L123`, `#L10-L20`): Stripped from - any GitHub `/blob/` URL. The file is still checked, but the - JS-rendered line-number fragment is skipped. This means - consuming repos don't need to exclude these in their - `lychee.toml`. -- **Scroll to Text Fragment anchors** (`#:~:text=...`): Stripped - from any GitHub `/blob/` URL. These are a browser-only feature - not present in static HTML. -- **Issue comment anchors** (`#issuecomment-*`): The fragment - is stripped so the issue/PR page is still checked, but the - JS-rendered comment anchor is skipped. - -Set `LYCHEE_SKIP_GITHUB_REMAPS=true` to disable all GitHub-specific -remaps as an escape hatch if they cause unexpected behavior. - -**Lychee config cleanup:** - -When adopting `lint:links`, you can remove the following entries -from your `lychee.toml` because flint handles them at runtime -via `--remap` arguments: - -- **GitHub blob/fragment remap for - [lychee#1729](https://github.com/lycheeverse/lychee/issues/1729)** - — flint remaps fragment URLs to `raw.githubusercontent.com` - for the current PR's head branch, and strips line-number - and Scroll to Text Fragment anchors globally. -- **`#issuecomment-*` excludes** — flint strips the fragment - via remap so the issue/PR page is still checked. -- **`#L\d+` / `#L\d+-L\d+` line-number excludes** — flint strips - the fragment via remap so the file is still checked. -- **`#:~:text=...` [Scroll to Text Fragment][stf] excludes** — - flint strips the fragment via remap so the file is still - checked. - -Note: flint uses `--remap` (not `--exclude`) for these because -lychee's CLI `--exclude` flags override config file excludes -rather than merging with them. - -**Environment variables:** +`flint list` shows every check with its status: - +```text +NAME BINARY STATUS SPEED PATTERNS +------------------------------------------------------------------- +shellcheck shellcheck installed fast *.sh *.bash *.bats +cargo-fmt cargo-fmt missing fast *.rs +renovate-deps renovate installed slow +... +``` -| Variable | Default | Description | -| ------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `LYCHEE_CONFIG` | `.github/config/lychee.toml` | Path to the lychee config file | -| `LYCHEE_CONFIG_CHANGE_PATTERN` | `^(\.github/config/lychee\.toml\|\.mise/tasks/lint/.*)$` | Files whose change triggers a full link check (`mise.toml` checked separately) | -| `LYCHEE_SKIP_GITHUB_REMAPS` | unset | Set to `true` to disable all GitHub URL remaps | +### Config (`flint.toml`) - +Optional. Place in the repo root (or in `FLINT_CONFIG_DIR` — see below). All settings have defaults. -**Examples:** +```toml +[settings] +base_branch = "main" # branch to diff against +exclude = "CHANGELOG\\.md|vendor/.*" # regex — exclude matching files -```bash -mise run lint:links # All links in modified + local links in all files (default) -mise run lint:links --full # All links in all files +[checks.links] +config = ".github/config/lychee.toml" # lychee config path +check_all_local = true # second pass: local links in all files + +[checks.renovate-deps] +exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers ``` -### `lint:renovate-deps` +### `FLINT_CONFIG_DIR` -Verifies `.github/renovate-tracked-deps.json` is up to date by -running Renovate locally and parsing its debug logs. +Set this env var to consolidate config files in one directory (e.g. `.github/config`): -**mise** fetches this script and runs it as `mise run lint:renovate-deps`. -The Renovate CLI is installed via mise's `[tools]` section — add -`node = ""` and `"npm:renovate" = ""` to your -`mise.toml`. **Renovate** plays a dual role here: the flint preset -keeps the script URL up to date, while the script itself runs Renovate -locally in `--platform=local` mode to discover which dependencies -Renovate is tracking and compares them against a committed snapshot. +```toml +# mise.toml +[env] +FLINT_CONFIG_DIR = ".github/config" +``` -**Flags:** +When set, `flint.toml` is loaded from that directory, and each linter that supports +an explicit config file path via a CLI flag will have it injected automatically when +the corresponding file exists there (see the "Config file" column in the table below). +Files that are absent are silently skipped — existing project-root configs remain in +effect. -| Flag | Description | -| ----------- | ------------------------------------------------------ | -| `--autofix` | Automatically regenerate and update the committed file | +**Note:** `ec`'s config file (`.editorconfig-checker.json`) controls ec's own settings, +not `.editorconfig` itself — editorconfig discovery always walks up from the file +being linted and cannot be redirected via a flag. -**Environment variables:** +### Built-in linter registry -| Variable | Default | Description | -| ------------------------------- | ------- | ----------------------------------------------------------------------------------- | -| `RENOVATE_TRACKED_DEPS_EXCLUDE` | unset | Comma-separated Renovate managers to exclude (e.g. `github-actions,github-runners`) | +| Name | Binary | Patterns | Fix | Scope | Config file | +| --------------- | --------------- | -------------------------------------------------- | --- | ------- | ------------------------------ | +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | `.shellcheckrc` | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | — | +| `markdownlint` | `markdownlint` | `*.md` | yes | file | `.markdownlint.json` | +| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | files | `.prettierrc` | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | `actionlint.yml` | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | `.hadolint.yaml` | +| `codespell` | `codespell` | `*` | yes | files | `.codespellrc` | +| `ec` | `ec` | `*` | no | files | `.editorconfig-checker.json` | +| `golangci-lint` | `golangci-lint` | `*.go` | no | project | `.golangci.yml` | +| `ruff` | `ruff` | `*.py` | yes | file | `ruff.toml` | +| `ruff-format` | `ruff` | `*.py` | yes | file | `ruff.toml` | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | — | +| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | — | +| `links` | `lychee` | (all files) | no | special | via `[checks.links]` in flint.toml | +| `renovate-deps` | `renovate` | (all files) | yes | special | — | + +¹ Not yet implemented. Biome's flag (`--config-path`) takes a directory, not a +file path — requires a directory-injection variant of the config mechanism. -#### Why this exists - -Renovate silently stops tracking a dependency when it can no longer -parse the version reference (typo in a comment annotation, -unsupported syntax, moved file, etc.). When that happens, the -dependency freezes in place with no PR and no dashboard entry — it -simply disappears from Renovate's radar. +**Scopes:** -The Dependency Dashboard catches _known_ dependencies that are -pending or in error, but it cannot show you a dependency that -Renovate no longer sees at all. This linter closes that gap by -keeping a committed snapshot of every dependency Renovate tracks -and failing CI when the two diverge. +- `file` — invoked once per matched file +- `files` — invoked once with all matched files as args +- `project` — invoked once with no file args; for checks with patterns set + (e.g. `cargo-clippy`), skipped entirely if no matching files changed -#### How the linter works +**Slow checks** (`renovate-deps`) are skipped by `--fast`. Use `--fast` for +local/pre-push feedback and the full set in CI. -The `lint:renovate-deps` task runs Renovate locally in -`--platform=local` mode, parses its debug log for the -`packageFiles with updates` message, and generates a dependency -list (grouped by file and manager). It then diffs this against the -committed `.github/renovate-tracked-deps.json`: +**`ec` deference**: `ec` (editorconfig-checker) runs on all files, but +automatically skips file types owned by an active line-length-enforcing +formatter. When `cargo-fmt`, `ruff-format`, `biome-format`, or `prettier` +are active, their file types are excluded from `ec` — those formatters +already enforce line length and would conflict with `ec`'s +`max_line_length` editorconfig check. If none of those formatters are +installed, `ec` checks those files itself. -- If they match → linter passes -- If they differ → linter fails with a unified diff showing which - dependencies were added or removed -- With `--autofix` flag (or `AUTOFIX=true` env var) → automatically - regenerates and updates the committed file +### Special checks -#### Typical workflow +#### links -- **A dependency disappears** (e.g., someone removes a - `# renovate:` comment or changes a file that Renovate was - matching) → CI fails, showing the removed dependency in the diff. - The author can then decide whether the removal was intentional or - accidental. +Orchestrates [lychee](https://lychee.cli.rs/) for link checking. Requires +`lychee` in `[tools]`. -- **A new dependency is added** → CI fails because the committed - snapshot is stale. Run `mise run fix` (or - `AUTOFIX=true mise run lint:renovate-deps`) to regenerate and - update the file, then commit. +Default behavior: checks all links in changed files. When `check_all_local = true` +in `flint.toml`, adds a second pass over local links in all files — useful when +broken internal links from unchanged files also matter. -- **Routine regeneration** → After any change to `renovate.json5`, - Dockerfiles, `go.mod`, `package.json`, or other files Renovate - scans, the linter will detect the change and require - regeneration. +Configure via `flint.toml`: -## How AUTOFIX and NATIVE Work +```toml +[checks.links] +config = ".github/config/lychee.toml" +check_all_local = true +``` -`lint:super-linter` accepts `--autofix` and `--native` flags. -Both can also be set as environment variables (`AUTOFIX=true`, -`NATIVE=true`), which is how the `fix` and `native-lint` -meta-tasks propagate them — mise's `depends` cannot forward CLI -flags, but env vars flow through naturally. Tasks that don't -recognize these variables simply ignore them. +#### renovate-deps -**Check mode** (default): +Verifies `.github/renovate-tracked-deps.json` is up to date by running Renovate +locally and comparing its output against the committed snapshot. Same purpose as +the v1 `lint:renovate-deps` task. Requires `renovate` in `[tools]`. -```bash -mise run lint # Check all linters, fail on issues -mise run lint:super-linter # Check code style, fail on issues -mise run lint:renovate-deps # Verify tracked deps, fail if out of date -``` +Tagged `slow = true` — skipped by `--fast`. With `--fix`, automatically regenerates +and commits the snapshot. -**Fix mode:** +Configure via `flint.toml`: -```bash -mise run fix # Auto-fix all fixable issues -# Or run individual linters: -mise run lint:super-linter --autofix # Apply code fixes -mise run lint:renovate-deps --autofix # Regenerate tracked deps +```toml +[checks.renovate-deps] +exclude_managers = ["github-actions", "github-runners"] ``` -Linters that don't support autofix (like lychee link checker) -silently ignore the `AUTOFIX` environment variable. +## Why -**Native mode:** +The bash task scripts (v1) have two problems: -```bash -mise run native-lint # Fast lints, natively (no container) -# Or run directly: -NATIVE=true mise run lint:fast # Same effect -mise run lint:super-linter --native # Single task with CLI flag -``` +**Local ≠ CI**: `--native` runs a subset of linters; CI runs full super-linter +in Docker. Different tools, different behavior. Passing locally does not mean +passing in CI. -Native mode is useful in environments where Docker/Podman is -unavailable (e.g., inside containers, CI hooks). `native-lint` -targets `lint:fast` (super-linter + links), skipping -`lint:renovate-deps` which requires the Renovate CLI. Tasks -that don't use a container (like `lint:links`) ignore the -`NATIVE` variable. +**Bash has limits**: the registry pattern was already at the edge of what bash +does cleanly. Adding built-in checks (links, renovate) would make it worse. -## Pre-commit hook +### Why not pre-commit? -Flint provides a `pre-commit` task that runs native linters on -every commit — fast feedback without the container overhead. To -set it up: +pre-commit adds a parallel tool management system on top of mise. Consuming repos +already declare their tools in `mise.toml` — pre-commit would require maintaining +a second inventory of the same tools in `.pre-commit-config.yaml`, with its own +versioning and install lifecycle. That's friction without benefit for repos that +are already mise-first. -```bash -mise run setup:pre-commit-hook -``` +### Why not MegaLinter / super-linter? -This generates a `.git/hooks/pre-commit` that runs -`mise run pre-commit`, which uses native mode for fast checks -without requiring a container. +Container-based linters (super-linter, MegaLinter) ship their own tool versions, +independent of what the repo pins in `mise.toml`. This breaks the "declare once, +use everywhere" promise of mise. Container startup also adds latency to every run. -**For consuming repos**, add these tasks to your `mise.toml`: +## Principles -```toml -[tasks.pre-commit] -description = "Pre-commit hook: native lint" -depends = ["setup:native-lint-tools"] -run = "NATIVE=true mise run lint:fast" - -[tasks."setup:pre-commit-hook"] -description = "Install git pre-commit hook" -run = "mise generate git-pre-commit --write --task=pre-commit" -``` +1. **mise-based** — `flint` distributed via mise. Tools managed by the consuming + repo's `mise.toml`. No separate tool installation step. -Then run `mise run setup:pre-commit-hook` once per clone. +2. **Fast** — native execution only (no Docker). Linters run in parallel. + Designed to be the default `mise run lint`, not a slow fallback. + Slow checks (e.g. `renovate-deps`) can be skipped with `--fast`. -## Automatic version updates with Renovate +3. **Cross-platform** — runs on Linux, macOS, and Windows. The built-in + registry accounts for platform differences (e.g. binary names, path quoting). -Flint provides a [Renovate shareable preset](https://docs.renovatebot.com/config-presets/) -with custom managers that automatically update: +4. **Local same as CI** — one binary, one config, identical behavior. + No "native mode subset" distinction. If it passes locally, it passes in CI. -- **SHA-pinned flint versions** in `mise.toml` - (`raw.githubusercontent.com` URLs with commit SHA and version - comment) -- **`_VERSION` variables** in `mise.toml` (e.g., `SUPER_LINTER_VERSION`) +5. **AI-friendly** — `--auto` fixes what's fixable silently, prints output + only for issues needing review, and exits with a structured summary: + ``` + [shellcheck] + ... + flint: fixed: cargo-fmt — commit before pushing | review: shellcheck + ``` + Only unfixable issues surface for review — no reasoning step required. + Also runnable containerised — no host tool dependencies required. -Add this to your `renovate.json5`: +6. **Opt-in via tool install** — checks auto-enable when their tool is declared + in `mise.toml`. `flint.toml` adds detail (config paths, exclusions) but is + not required to activate anything. -```json5 -{ - extends: ["github>grafana/flint"], -} -``` +7. **Changed files by default** — git-aware diff detection. `--from-ref`/`--to-ref` + for CI. `--full` to check everything. Falls back to all files when no merge + base is found. -## Per-repo configuration - -Each task expects certain config files that your repository must -provide. You only need the files for the tasks you adopt: - -- **`lint:super-linter`** — Super-Linter env file - (`.github/config/super-linter.env`) to select which validators - to enable and which `FIX_*` vars to set, plus any linter config - files (`.golangci.yaml`, `.markdownlint.yaml`, `.yaml-lint.yml`, - `.editorconfig`, etc.) -- **`lint:links`** — Lychee config - (`.github/config/lychee.toml`) for exclusions, timeouts, - remappings -- **`lint:renovate-deps`** — Renovate config - (`.github/renovate.json5`) and committed snapshot - (`.github/renovate-tracked-deps.json`) -- **Renovate preset** — Add `"github>grafana/flint"` to your - `renovate.json5` `extends` array to enable automatic updates of - flint URLs and tool versions +8. **Autofix where possible** — `--fix` flag. Fix mode runs serially to avoid + concurrent writes to the same file. Pass specific linter names to limit which + fixers run (`flint --fix prettier shfmt`). ## Versioning @@ -541,14 +363,6 @@ This project uses [Semantic Versioning](https://semver.org/). Breaking changes will be documented in [CHANGELOG.md](CHANGELOG.md) and will result in a major version bump. -**Always pin to a specific commit SHA** in your `mise.toml` file -URLs with a version comment (e.g., `# v0.6.0`). Never reference -`main` directly as it may contain unreleased breaking changes. To -find the commit SHA for a release tag, run -`git rev-parse v0.6.0`. - ## Releasing See [RELEASING.md](RELEASING.md). - -[stf]: https://developer.mozilla.org/en-US/docs/Web/URI/Fragment/Text_fragments diff --git a/src/main.rs b/src/main.rs index d8fde29..4261821 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ struct Cli { command: Option, /// Auto-fix issues instead of checking - #[arg(long, env = "AUTOFIX")] + #[arg(long)] fix: bool, /// Lint all files instead of only changed files diff --git a/tests/cases/auto-fix-and-review/files/Cargo.toml b/tests/cases/auto-fix-and-review/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/auto-fix-and-review/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/auto-fix-and-review/files/bad.sh b/tests/cases/auto-fix-and-review/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/auto-fix-and-review/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/auto-fix-and-review/files/mise.toml b/tests/cases/auto-fix-and-review/files/mise.toml new file mode 100644 index 0000000..8ecc60a --- /dev/null +++ b/tests/cases/auto-fix-and-review/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +rust = "latest" +shellcheck = "latest" diff --git a/tests/cases/auto-fix-and-review/files/src/lib.rs b/tests/cases/auto-fix-and-review/files/src/lib.rs new file mode 100644 index 0000000..1379407 --- /dev/null +++ b/tests/cases/auto-fix-and-review/files/src/lib.rs @@ -0,0 +1 @@ +pub struct Foo { pub a: u32, pub b: u32 } diff --git a/tests/cases/auto-fix-and-review/test.toml b/tests/cases/auto-fix-and-review/test.toml new file mode 100644 index 0000000..41fa078 --- /dev/null +++ b/tests/cases/auto-fix-and-review/test.toml @@ -0,0 +1,17 @@ +args = "--full --auto cargo-fmt shellcheck" +exit = 1 + +expected_stderr = """ +[shellcheck] + +In /bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. + +Did you mean: +echo "$1" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... +flint: fixed: cargo-fmt — commit before pushing | review: shellcheck +""" \ No newline at end of file diff --git a/tests/cases/env-var-exclude/test.toml b/tests/cases/env-var-exclude/test.toml index db77a76..b7dc52a 100644 --- a/tests/cases/env-var-exclude/test.toml +++ b/tests/cases/env-var-exclude/test.toml @@ -2,4 +2,4 @@ args = "--full shellcheck" exit = 0 [env] -FLINT_EXCLUDE = "bad.sh" +FLINT_EXCLUDE = "bad\\.sh" From 35b961d6ae3fedbf77058921119acfe19217669a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 13:48:54 +0000 Subject: [PATCH 037/141] feat: add env var overrides for all CLI flags Each flag now has a FLINT_ env var equivalent, consistent with FLINT_SHORT which already existed. Documented in README. Signed-off-by: Gregor Zeitlinger --- README.md | 3 ++- src/main.rs | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3f2626c..0c8bfc1 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,8 @@ flint list | `--from-ref REF` | Diff base (default: merge base with base branch) | | `--to-ref REF` | Diff head (default: HEAD) | -Env var equivalent: `FLINT_SHORT=true` for `--short`. +Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_FAST`, +`FLINT_VERBOSE`, `FLINT_SHORT`, `FLINT_AUTO`, `FLINT_FROM_REF`, `FLINT_TO_REF`. #### Intended use by context diff --git a/src/main.rs b/src/main.rs index 4261821..3a49f10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,19 +17,19 @@ struct Cli { command: Option, /// Auto-fix issues instead of checking - #[arg(long)] + #[arg(long, env = "FLINT_FIX")] fix: bool, /// Lint all files instead of only changed files - #[arg(long)] + #[arg(long, env = "FLINT_FULL")] full: bool, /// Skip slow checks - #[arg(long)] + #[arg(long, env = "FLINT_FAST")] fast: bool, /// Show all linter output, not just failures - #[arg(long)] + #[arg(long, env = "FLINT_VERBOSE")] verbose: bool, /// Compact summary output — no per-check noise (human) or read-only AI review @@ -39,15 +39,15 @@ struct Cli { /// Autonomous mode: fix what's fixable, report what still needs review. /// Exits 0 if everything passed or was fixed. Intended for pre-push hooks /// and agentic pipelines that have write access. - #[arg(long)] + #[arg(long, env = "FLINT_AUTO")] auto: bool, /// Compare changed files from this ref (default: merge base with base branch) - #[arg(long)] + #[arg(long, env = "FLINT_FROM_REF")] from_ref: Option, /// Compare changed files to this ref (default: HEAD) - #[arg(long)] + #[arg(long, env = "FLINT_TO_REF")] to_ref: Option, /// Linters to run (default: all discovered) From d0e71de2304c124b0e8add037d4b1df9eb3ee605 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 3 Apr 2026 13:52:34 +0000 Subject: [PATCH 038/141] feat: rewrite renovate-deps check in native Rust Replaces the Python subprocess approach with a pure Rust implementation that runs Renovate, parses its NDJSON log, extracts the dep snapshot, and diffs/writes .github/renovate-tracked-deps.json directly. Settings (exclude_managers, fix mode, project root) are passed as function arguments rather than env vars. Adds serde_json and similar deps. Signed-off-by: Gregor Zeitlinger --- Cargo.lock | 8 ++ Cargo.toml | 2 + src/linters/renovate_deps.rs | 253 +++++++++++++++++++++++++++++++---- 3 files changed, 235 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69e8086..8d984a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,8 @@ dependencies = [ "regex", "semver", "serde", + "serde_json", + "similar", "tempfile", "tokio", "toml", @@ -559,6 +561,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index b4c8562..c4009d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ toml = "0.8" tokio = { version = "1", features = ["full"] } semver = "1" regex = "1" +serde_json = "1" +similar = "2" [dev-dependencies] tempfile = "3" diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 5c62c69..ab2a3b4 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -1,56 +1,253 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::Path; use std::process::Stdio; use tokio::process::Command; use crate::config::RenovateDepsConfig; -const SCRIPT: &str = include_str!("../../tasks/lint/renovate-deps.py"); +const COMMITTED_PATH: &str = ".github/renovate-tracked-deps.json"; +const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-version"]; + +/// `{file_path: {manager: [dep_name, ...]}}` — all collections sorted. +type DepMap = BTreeMap>>; pub async fn run( cfg: &RenovateDepsConfig, fix: bool, project_root: &Path, ) -> (bool, Vec, Vec) { - let pid = std::process::id(); - let tmp_path = format!("/tmp/flint-renovate-deps-{pid}.py"); + let log_bytes = match run_renovate(project_root).await { + Ok(b) => b, + Err(e) => { + return ( + false, + vec![], + format!("flint: renovate-deps: {e}\n").into_bytes(), + ); + } + }; + + let generated = match extract_deps(&log_bytes, &cfg.exclude_managers) { + Ok(d) => d, + Err(e) => { + return ( + false, + vec![], + format!("flint: renovate-deps: {e}\n").into_bytes(), + ); + } + }; + + let committed_path = project_root.join(COMMITTED_PATH); - if let Err(e) = std::fs::write(&tmp_path, SCRIPT) { - let stderr = - format!("flint: renovate-deps: failed to write temp script: {e}\n").into_bytes(); - return (false, vec![], stderr); + if !committed_path.exists() { + if fix { + return match write_snapshot(&committed_path, &generated) { + Ok(()) => ( + true, + b"renovate-tracked-deps.json has been created.\n".to_vec(), + vec![], + ), + Err(e) => ( + false, + vec![], + format!("flint: renovate-deps: {e}\n").into_bytes(), + ), + }; + } + return ( + false, + vec![], + format!( + "ERROR: {COMMITTED_PATH} does not exist.\nRun `flint --fix renovate-deps` to create it.\n" + ) + .into_bytes(), + ); } - let mut cmd = Command::new("python3"); - cmd.arg(&tmp_path) - .current_dir(project_root) - .stdin(Stdio::null()) - .env("MISE_PROJECT_ROOT", project_root); + let committed: DepMap = match std::fs::read_to_string(&committed_path) + .map_err(anyhow::Error::from) + .and_then(|s| serde_json::from_str(&s).map_err(anyhow::Error::from)) + { + Ok(d) => d, + Err(e) => { + return ( + false, + vec![], + format!("flint: renovate-deps: failed to read committed snapshot: {e}\n") + .into_bytes(), + ); + } + }; + + if committed == generated { + return ( + true, + b"renovate-tracked-deps.json is up to date.\n".to_vec(), + vec![], + ); + } + + let diff = unified_diff(&committed, &generated); if fix { - cmd.env("AUTOFIX", "true"); + return match write_snapshot(&committed_path, &generated) { + Ok(()) => { + let mut stdout = diff.into_bytes(); + stdout.extend_from_slice(b"renovate-tracked-deps.json has been updated.\n"); + (true, stdout, vec![]) + } + Err(e) => ( + false, + vec![], + format!("flint: renovate-deps: {e}\n").into_bytes(), + ), + }; } - if !cfg.exclude_managers.is_empty() { - cmd.env( - "RENOVATE_TRACKED_DEPS_EXCLUDE", - cfg.exclude_managers.join(","), + ( + false, + diff.into_bytes(), + b"ERROR: renovate-tracked-deps.json is out of date.\nRun `flint --fix renovate-deps` to update.\n".to_vec(), + ) +} + +/// Runs `renovate --platform=local` and returns the combined stdout+stderr log bytes. +async fn run_renovate(project_root: &Path) -> anyhow::Result> { + let config_path = project_root.join(".github").join("renovate.json5"); + + // Forward env, setting Renovate-specific vars. + let mut env: Vec<(String, String)> = std::env::vars().collect(); + // Override logging to get parseable JSON output. + env.retain(|(k, _)| k != "LOG_LEVEL" && k != "LOG_FORMAT" && k != "RENOVATE_CONFIG_FILE"); + env.push(("LOG_LEVEL".into(), "debug".into())); + env.push(("LOG_FORMAT".into(), "json".into())); + env.push(( + "RENOVATE_CONFIG_FILE".into(), + config_path.to_string_lossy().into_owned(), + )); + // Renovate uses GITHUB_COM_TOKEN for github.com API calls; fall back to GITHUB_TOKEN. + let has_com_token = std::env::var("GITHUB_COM_TOKEN") + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !has_com_token + && let Ok(token) = std::env::var("GITHUB_TOKEN") + && !token.is_empty() + { + env.push(("GITHUB_COM_TOKEN".into(), token)); + } + + let out = Command::new("renovate") + .args(["--platform=local", "--require-config=ignored"]) + .current_dir(project_root) + .envs(env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + // Combine stdout+stderr: Renovate writes JSON log lines to stdout, but + // some startup messages may appear on stderr. + let mut combined = out.stdout; + combined.extend_from_slice(&out.stderr); + + if !out.status.success() { + let snippet = String::from_utf8_lossy(&combined); + anyhow::bail!( + "renovate exited with status {}: {}", + out.status.code().unwrap_or(-1), + snippet.lines().take(20).collect::>().join("\n") ); } - let result = cmd.output().await; + Ok(combined) +} + +/// Parses Renovate's NDJSON log and returns the dep map. +fn extract_deps(log_bytes: &[u8], exclude_managers: &[String]) -> anyhow::Result { + let log = std::str::from_utf8(log_bytes)?; - // Remove temp file regardless of outcome - let _ = std::fs::remove_file(&tmp_path); + let exclude: HashSet<&str> = exclude_managers.iter().map(String::as_str).collect(); - match result { - Ok(out) => { - let ok = out.status.success(); - (ok, out.stdout, out.stderr) + // Find the last "packageFiles with updates" log entry — Renovate emits it + // once per run with the full resolved config. + let mut config_obj: Option = None; + for line in log.lines() { + let Ok(entry) = serde_json::from_str::(line) else { + continue; + }; + if entry.get("msg").and_then(|v| v.as_str()) == Some("packageFiles with updates") { + config_obj = entry.get("config").cloned(); } - Err(e) => { - let stderr = - format!("flint: renovate-deps: failed to spawn python3: {e}\n").into_bytes(); - (false, vec![], stderr) + } + + let config = config_obj + .ok_or_else(|| anyhow::anyhow!("'packageFiles with updates' not found in Renovate log"))?; + + let mut deps_by_file: BTreeMap>> = BTreeMap::new(); + + if let Some(obj) = config.as_object() { + for (manager, manager_files) in obj { + if exclude.contains(manager.as_str()) { + continue; + } + let Some(files) = manager_files.as_array() else { + continue; + }; + for pkg_file in files { + let file_path = pkg_file + .get("packageFile") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let Some(deps) = pkg_file.get("deps").and_then(|v| v.as_array()) else { + continue; + }; + for dep in deps { + let skip_reason = dep.get("skipReason").and_then(|v| v.as_str()); + if SKIP_REASONS.contains(&skip_reason.unwrap_or("")) { + continue; + } + let Some(dep_name) = dep.get("depName").and_then(|v| v.as_str()) else { + continue; + }; + deps_by_file + .entry(file_path.clone()) + .or_default() + .entry(manager.clone()) + .or_default() + .insert(dep_name.to_string()); + } + } } } + + // BTreeMap + BTreeSet already sorted; convert sets to vecs. + Ok(deps_by_file + .into_iter() + .map(|(file, managers)| { + let managers = managers + .into_iter() + .map(|(m, deps)| (m, deps.into_iter().collect::>())) + .collect(); + (file, managers) + }) + .collect()) +} + +fn write_snapshot(path: &Path, deps: &DepMap) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(deps)?; + std::fs::write(path, json + "\n")?; + Ok(()) +} + +fn unified_diff(old: &DepMap, new: &DepMap) -> String { + let old_text = serde_json::to_string_pretty(old).unwrap_or_default() + "\n"; + let new_text = serde_json::to_string_pretty(new).unwrap_or_default() + "\n"; + + let diff = similar::TextDiff::from_lines(&old_text, &new_text); + diff.unified_diff() + .header(COMMITTED_PATH, "generated") + .to_string() } From 46272277ccc6b4bddad8b42a4ed41811dc2cc14f Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 09:22:34 +0000 Subject: [PATCH 039/141] refactor: extract constants and fix Windows path compatibility Replace hardcoded slash-separated path with Path::join calls, and extract all magic strings as named constants. Signed-off-by: Gregor Zeitlinger --- src/linters/renovate_deps.rs | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index ab2a3b4..ad0fc0a 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -5,7 +5,11 @@ use tokio::process::Command; use crate::config::RenovateDepsConfig; -const COMMITTED_PATH: &str = ".github/renovate-tracked-deps.json"; +const COMMITTED_DIR: &str = ".github"; +const COMMITTED_FILE: &str = "renovate-tracked-deps.json"; +const COMMITTED_DISPLAY: &str = ".github/renovate-tracked-deps.json"; +const RENOVATE_CONFIG_FILE: &str = "renovate.json5"; +const PACKAGE_FILES_MSG: &str = "packageFiles with updates"; const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-version"]; /// `{file_path: {manager: [dep_name, ...]}}` — all collections sorted. @@ -38,14 +42,14 @@ pub async fn run( } }; - let committed_path = project_root.join(COMMITTED_PATH); + let committed_path = project_root.join(COMMITTED_DIR).join(COMMITTED_FILE); if !committed_path.exists() { if fix { return match write_snapshot(&committed_path, &generated) { Ok(()) => ( true, - b"renovate-tracked-deps.json has been created.\n".to_vec(), + format!("{COMMITTED_FILE} has been created.\n").into_bytes(), vec![], ), Err(e) => ( @@ -59,7 +63,7 @@ pub async fn run( false, vec![], format!( - "ERROR: {COMMITTED_PATH} does not exist.\nRun `flint --fix renovate-deps` to create it.\n" + "ERROR: {COMMITTED_DISPLAY} does not exist.\nRun `flint --fix renovate-deps` to create it.\n" ) .into_bytes(), ); @@ -83,7 +87,7 @@ pub async fn run( if committed == generated { return ( true, - b"renovate-tracked-deps.json is up to date.\n".to_vec(), + format!("{COMMITTED_FILE} is up to date.\n").into_bytes(), vec![], ); } @@ -94,7 +98,8 @@ pub async fn run( return match write_snapshot(&committed_path, &generated) { Ok(()) => { let mut stdout = diff.into_bytes(); - stdout.extend_from_slice(b"renovate-tracked-deps.json has been updated.\n"); + stdout + .extend_from_slice(format!("{COMMITTED_FILE} has been updated.\n").as_bytes()); (true, stdout, vec![]) } Err(e) => ( @@ -108,13 +113,16 @@ pub async fn run( ( false, diff.into_bytes(), - b"ERROR: renovate-tracked-deps.json is out of date.\nRun `flint --fix renovate-deps` to update.\n".to_vec(), + format!( + "ERROR: {COMMITTED_FILE} is out of date.\nRun `flint --fix renovate-deps` to update.\n" + ) + .into_bytes(), ) } /// Runs `renovate --platform=local` and returns the combined stdout+stderr log bytes. async fn run_renovate(project_root: &Path) -> anyhow::Result> { - let config_path = project_root.join(".github").join("renovate.json5"); + let config_path = project_root.join(COMMITTED_DIR).join(RENOVATE_CONFIG_FILE); // Forward env, setting Renovate-specific vars. let mut env: Vec<(String, String)> = std::env::vars().collect(); @@ -177,13 +185,13 @@ fn extract_deps(log_bytes: &[u8], exclude_managers: &[String]) -> anyhow::Result let Ok(entry) = serde_json::from_str::(line) else { continue; }; - if entry.get("msg").and_then(|v| v.as_str()) == Some("packageFiles with updates") { + if entry.get("msg").and_then(|v| v.as_str()) == Some(PACKAGE_FILES_MSG) { config_obj = entry.get("config").cloned(); } } let config = config_obj - .ok_or_else(|| anyhow::anyhow!("'packageFiles with updates' not found in Renovate log"))?; + .ok_or_else(|| anyhow::anyhow!("'{PACKAGE_FILES_MSG}' not found in Renovate log"))?; let mut deps_by_file: BTreeMap>> = BTreeMap::new(); @@ -248,6 +256,6 @@ fn unified_diff(old: &DepMap, new: &DepMap) -> String { let diff = similar::TextDiff::from_lines(&old_text, &new_text); diff.unified_diff() - .header(COMMITTED_PATH, "generated") + .header(COMMITTED_DISPLAY, "generated") .to_string() } From 12ac0b6029941c4abf3395884eb157b2cfb3de2f Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 09:23:55 +0000 Subject: [PATCH 040/141] test: add unit tests for renovate-deps native implementation Covers extract_deps (basic extraction, sorting, skip reasons, excluded managers, missing message, non-JSON lines, last-entry-wins), write_snapshot (roundtrip, trailing newline), and unified_diff (content and header). Signed-off-by: Gregor Zeitlinger --- src/linters/renovate_deps.rs | 158 +++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index ad0fc0a..d210bc4 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -259,3 +259,161 @@ fn unified_diff(old: &DepMap, new: &DepMap) -> String { .header(COMMITTED_DISPLAY, "generated") .to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + fn log(config_json: &str) -> Vec { + format!(r#"{{"msg":"packageFiles with updates","config":{config_json}}}"#).into_bytes() + } + + fn dep_map(entries: &[(&str, &[(&str, &[&str])])]) -> DepMap { + entries + .iter() + .map(|(file, managers)| { + let m = managers + .iter() + .map(|(mgr, deps)| { + ( + mgr.to_string(), + deps.iter().map(|d| d.to_string()).collect(), + ) + }) + .collect(); + (file.to_string(), m) + }) + .collect() + } + + #[test] + fn extracts_deps_basic() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!( + result, + dep_map(&[("package.json", &[("npm", &["express", "lodash"])])]) + ); + } + + #[test] + fn deps_are_sorted() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"zebra"},{"depName":"alpha"},{"depName":"moose"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!( + result["package.json"]["npm"], + vec!["alpha", "moose", "zebra"] + ); + } + + #[test] + fn filters_skip_reasons() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"keep"},{"depName":"bad1","skipReason":"contains-variable"},{"depName":"bad2","skipReason":"invalid-value"},{"depName":"bad3","skipReason":"invalid-version"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!(result["package.json"]["npm"], vec!["keep"]); + } + + #[test] + fn other_skip_reasons_are_kept() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"pinned","skipReason":"pinned-major-version"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!(result["package.json"]["npm"], vec!["pinned"]); + } + + #[test] + fn excludes_managers() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"}]}],"cargo":[{"packageFile":"Cargo.toml","deps":[{"depName":"tokio"}]}]}"#, + ); + let result = extract_deps(&log, &["npm".to_string()]).unwrap(); + assert!(!result.contains_key("package.json")); + assert_eq!(result["Cargo.toml"]["cargo"], vec!["tokio"]); + } + + #[test] + fn skips_deps_without_dep_name() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"version":"1.0.0"},{"depName":"valid"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!(result["package.json"]["npm"], vec!["valid"]); + } + + #[test] + fn last_package_files_message_wins() { + let bytes = format!( + "{}\n{}\n", + r#"{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"a.json","deps":[{"depName":"old"}]}]}}"#, + r#"{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"b.json","deps":[{"depName":"new"}]}]}}"#, + ) + .into_bytes(); + let result = extract_deps(&bytes, &[]).unwrap(); + assert!(!result.contains_key("a.json"), "should use last entry"); + assert!(result.contains_key("b.json")); + } + + #[test] + fn non_json_lines_are_skipped() { + let bytes = + b"not json\n{\"msg\":\"packageFiles with updates\",\"config\":{\"npm\":[{\"packageFile\":\"p.json\",\"deps\":[{\"depName\":\"x\"}]}]}}\nmore garbage\n"; + let result = extract_deps(bytes, &[]).unwrap(); + assert!(result.contains_key("p.json")); + } + + #[test] + fn missing_message_returns_error() { + let bytes = b"{\"msg\":\"something else\"}\n"; + let err = extract_deps(bytes, &[]).unwrap_err(); + assert!(err.to_string().contains(PACKAGE_FILES_MSG)); + } + + #[test] + fn write_and_read_snapshot_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.json"); + let deps = dep_map(&[ + ("Cargo.toml", &[("cargo", &["serde", "tokio"])]), + ("package.json", &[("npm", &["express", "lodash"])]), + ]); + write_snapshot(&path, &deps).unwrap(); + let read_back: DepMap = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(deps, read_back); + } + + #[test] + fn write_snapshot_ends_with_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.json"); + write_snapshot(&path, &dep_map(&[])).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.ends_with('\n')); + } + + #[test] + fn unified_diff_contains_added_and_removed_lines() { + let old = dep_map(&[("a.json", &[("npm", &["old-dep"])])]); + let new = dep_map(&[("a.json", &[("npm", &["new-dep"])])]); + let diff = unified_diff(&old, &new); + assert!(diff.contains("-"), "should have removals"); + assert!(diff.contains("+"), "should have additions"); + assert!(diff.contains("old-dep")); + assert!(diff.contains("new-dep")); + } + + #[test] + fn unified_diff_header_uses_display_path() { + let old = dep_map(&[("a.json", &[("npm", &["x"])])]); + let new = dep_map(&[("a.json", &[("npm", &["y"])])]); + let diff = unified_diff(&old, &new); + assert!(diff.contains(COMMITTED_DISPLAY)); + } +} From 97aa613cb00ffc51efcdad65dda001104f2349a5 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 09:28:01 +0000 Subject: [PATCH 041/141] refactor: replace (bool, Vec, Vec) tuple with LinterOutput struct All linter run functions and run_invocations now return LinterOutput {ok, stdout, stderr} instead of an anonymous tuple. LinterOutput::ok() and LinterOutput::err() cover the common no-output cases. Signed-off-by: Gregor Zeitlinger --- src/linters/license_header.rs | 11 +++-- src/linters/lychee.rs | 60 ++++++++++++----------- src/linters/mod.rs | 25 ++++++++++ src/linters/renovate_deps.rs | 91 ++++++++++++----------------------- src/runner.rs | 24 ++++----- 5 files changed, 110 insertions(+), 101 deletions(-) diff --git a/src/linters/license_header.rs b/src/linters/license_header.rs index 347e622..5e5e128 100644 --- a/src/linters/license_header.rs +++ b/src/linters/license_header.rs @@ -2,6 +2,7 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use crate::config::LicenseHeaderConfig; +use crate::linters::LinterOutput; /// Checks that each file matching `cfg.patterns` contains `cfg.text` within /// the first `cfg.lines_to_check` lines. Returns early (ok=true) when not configured. @@ -9,9 +10,9 @@ pub async fn run( cfg: &LicenseHeaderConfig, project_root: &Path, files: &[PathBuf], -) -> (bool, Vec, Vec) { +) -> LinterOutput { if cfg.text.is_empty() { - return (true, vec![], vec![]); + return LinterOutput::ok(); } let mut all_ok = true; @@ -46,7 +47,11 @@ pub async fn run( } } - (all_ok, vec![], stderr) + LinterOutput { + ok: all_ok, + stdout: vec![], + stderr, + } } /// Returns `true` if `text` appears anywhere within the first `lines_to_check` lines of `path`. diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index ffbb180..8a0b220 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -4,13 +4,14 @@ use tokio::process::Command; use crate::config::LycheeConfig; use crate::files::FileList; +use crate::linters::LinterOutput; pub async fn run( cfg: &LycheeConfig, file_list: &FileList, project_root: &Path, config_dir: &Path, -) -> (bool, Vec, Vec) { +) -> LinterOutput { let lychee_cfg_raw = cfg.config.as_deref().unwrap_or("lychee.toml"); let lychee_cfg = if Path::new(lychee_cfg_raw).is_relative() { config_dir @@ -42,8 +43,7 @@ pub async fn run( .any(|f| f.as_path() == Path::new(&lychee_cfg)); if config_changed { - let mut stderr = b"Config changes detected, falling back to full check.\n".to_vec(); - let (ok, stdout, extra_stderr) = run_lychee_cmd( + let mut out = run_lychee_cmd( "Checking all links in all files", &lychee_cfg, &remap_args, @@ -51,8 +51,10 @@ pub async fn run( false, ) .await; - stderr.extend_from_slice(&extra_stderr); - return (ok, stdout, stderr); + let mut stderr = b"Config changes detected, falling back to full check.\n".to_vec(); + stderr.extend_from_slice(&out.stderr); + out.stderr = stderr; + return out; } // Diff mode: filter changed files to link-checkable extensions @@ -74,7 +76,7 @@ pub async fn run( if !checkable.is_empty() { let file_refs: Vec<&str> = checkable.iter().map(String::as_str).collect(); - let (ok, stdout, stderr) = run_lychee_cmd( + let out = run_lychee_cmd( "Checking all links in modified files", &lychee_cfg, &remap_args, @@ -82,17 +84,15 @@ pub async fn run( false, ) .await; - if !ok { - all_ok = false; - } - combined_stdout.extend_from_slice(&stdout); - combined_stderr.extend_from_slice(&stderr); + all_ok &= out.ok; + combined_stdout.extend_from_slice(&out.stdout); + combined_stderr.extend_from_slice(&out.stderr); } else { combined_stdout.extend_from_slice(b"No modified files to check for all links.\n"); } if cfg.check_all_local { - let (ok, stdout, stderr) = run_lychee_cmd( + let out = run_lychee_cmd( "Checking local links in all files", &lychee_cfg, &remap_args, @@ -100,14 +100,16 @@ pub async fn run( true, ) .await; - if !ok { - all_ok = false; - } - combined_stdout.extend_from_slice(&stdout); - combined_stderr.extend_from_slice(&stderr); + all_ok &= out.ok; + combined_stdout.extend_from_slice(&out.stdout); + combined_stderr.extend_from_slice(&out.stderr); } - (all_ok, combined_stdout, combined_stderr) + LinterOutput { + ok: all_ok, + stdout: combined_stdout, + stderr: combined_stderr, + } } async fn run_lychee_cmd( @@ -116,7 +118,7 @@ async fn run_lychee_cmd( remap_args: &[String], files: &[&str], local_only: bool, -) -> (bool, Vec, Vec) { +) -> LinterOutput { let mut argv: Vec = vec![ "lychee".to_string(), "--config".to_string(), @@ -133,7 +135,7 @@ async fn run_lychee_cmd( argv.push("--".to_string()); argv.extend(files.iter().map(|s| s.to_string())); - let mut stdout_prefix = format!("==> {description}\n").into_bytes(); + let mut stdout = format!("==> {description}\n").into_bytes(); let result = Command::new(&argv[0]) .args(&argv[1..]) @@ -143,14 +145,18 @@ async fn run_lychee_cmd( match result { Ok(out) => { - stdout_prefix.extend_from_slice(&out.stdout); - let ok = out.status.success(); - (ok, stdout_prefix, out.stderr) - } - Err(e) => { - let stderr = format!("flint: links: failed to spawn lychee: {e}\n").into_bytes(); - (false, stdout_prefix, stderr) + stdout.extend_from_slice(&out.stdout); + LinterOutput { + ok: out.status.success(), + stdout, + stderr: out.stderr, + } } + Err(e) => LinterOutput { + ok: false, + stdout, + stderr: format!("flint: links: failed to spawn lychee: {e}\n").into_bytes(), + }, } } diff --git a/src/linters/mod.rs b/src/linters/mod.rs index b902252..36dd215 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -1,3 +1,28 @@ pub mod license_header; pub mod lychee; pub mod renovate_deps; + +/// Output from a single linter run. +pub struct LinterOutput { + pub ok: bool, + pub stdout: Vec, + pub stderr: Vec, +} + +impl LinterOutput { + pub fn ok() -> Self { + Self { + ok: true, + stdout: vec![], + stderr: vec![], + } + } + + pub fn err(stderr: impl Into>) -> Self { + Self { + ok: false, + stdout: vec![], + stderr: stderr.into(), + } + } +} diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index d210bc4..ce8ddfa 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -4,6 +4,7 @@ use std::process::Stdio; use tokio::process::Command; use crate::config::RenovateDepsConfig; +use crate::linters::LinterOutput; const COMMITTED_DIR: &str = ".github"; const COMMITTED_FILE: &str = "renovate-tracked-deps.json"; @@ -15,31 +16,15 @@ const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-v /// `{file_path: {manager: [dep_name, ...]}}` — all collections sorted. type DepMap = BTreeMap>>; -pub async fn run( - cfg: &RenovateDepsConfig, - fix: bool, - project_root: &Path, -) -> (bool, Vec, Vec) { +pub async fn run(cfg: &RenovateDepsConfig, fix: bool, project_root: &Path) -> LinterOutput { let log_bytes = match run_renovate(project_root).await { Ok(b) => b, - Err(e) => { - return ( - false, - vec![], - format!("flint: renovate-deps: {e}\n").into_bytes(), - ); - } + Err(e) => return LinterOutput::err(format!("flint: renovate-deps: {e}\n")), }; let generated = match extract_deps(&log_bytes, &cfg.exclude_managers) { Ok(d) => d, - Err(e) => { - return ( - false, - vec![], - format!("flint: renovate-deps: {e}\n").into_bytes(), - ); - } + Err(e) => return LinterOutput::err(format!("flint: renovate-deps: {e}\n")), }; let committed_path = project_root.join(COMMITTED_DIR).join(COMMITTED_FILE); @@ -47,26 +32,17 @@ pub async fn run( if !committed_path.exists() { if fix { return match write_snapshot(&committed_path, &generated) { - Ok(()) => ( - true, - format!("{COMMITTED_FILE} has been created.\n").into_bytes(), - vec![], - ), - Err(e) => ( - false, - vec![], - format!("flint: renovate-deps: {e}\n").into_bytes(), - ), + Ok(()) => LinterOutput { + ok: true, + stdout: format!("{COMMITTED_FILE} has been created.\n").into_bytes(), + stderr: vec![], + }, + Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), }; } - return ( - false, - vec![], - format!( - "ERROR: {COMMITTED_DISPLAY} does not exist.\nRun `flint --fix renovate-deps` to create it.\n" - ) - .into_bytes(), - ); + return LinterOutput::err(format!( + "ERROR: {COMMITTED_DISPLAY} does not exist.\nRun `flint --fix renovate-deps` to create it.\n" + )); } let committed: DepMap = match std::fs::read_to_string(&committed_path) @@ -75,21 +51,18 @@ pub async fn run( { Ok(d) => d, Err(e) => { - return ( - false, - vec![], - format!("flint: renovate-deps: failed to read committed snapshot: {e}\n") - .into_bytes(), - ); + return LinterOutput::err(format!( + "flint: renovate-deps: failed to read committed snapshot: {e}\n" + )); } }; if committed == generated { - return ( - true, - format!("{COMMITTED_FILE} is up to date.\n").into_bytes(), - vec![], - ); + return LinterOutput { + ok: true, + stdout: format!("{COMMITTED_FILE} is up to date.\n").into_bytes(), + stderr: vec![], + }; } let diff = unified_diff(&committed, &generated); @@ -100,24 +73,24 @@ pub async fn run( let mut stdout = diff.into_bytes(); stdout .extend_from_slice(format!("{COMMITTED_FILE} has been updated.\n").as_bytes()); - (true, stdout, vec![]) + LinterOutput { + ok: true, + stdout, + stderr: vec![], + } } - Err(e) => ( - false, - vec![], - format!("flint: renovate-deps: {e}\n").into_bytes(), - ), + Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), }; } - ( - false, - diff.into_bytes(), - format!( + LinterOutput { + ok: false, + stdout: diff.into_bytes(), + stderr: format!( "ERROR: {COMMITTED_FILE} is out of date.\nRun `flint --fix renovate-deps` to update.\n" ) .into_bytes(), - ) + } } /// Runs `renovate --platform=local` and returns the combined stdout+stderr log bytes. diff --git a/src/runner.rs b/src/runner.rs index 20fbc59..a7c72ee 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -6,7 +6,7 @@ use tokio::task::JoinSet; use crate::config::{Config, LicenseHeaderConfig, LycheeConfig, RenovateDepsConfig}; use crate::files::FileList; -use crate::linters::{license_header, lychee, renovate_deps}; +use crate::linters::{LinterOutput, license_header, lychee, renovate_deps}; use crate::registry::{Check, CheckKind, Scope, SpecialKind}; pub struct RunOptions { @@ -58,7 +58,7 @@ impl PreparedCheck { async fn execute(self, fix: bool, project_root: &Path) -> CheckResult { let name = self.name().to_string(); - let (ok, stdout, stderr) = match self { + let out: LinterOutput = match self { Self::Invocations { argv_list, .. } => { run_invocations(&name, &argv_list, project_root).await } @@ -75,9 +75,9 @@ impl PreparedCheck { }; CheckResult { name, - ok, - stdout, - stderr, + ok: out.ok, + stdout: out.stdout, + stderr: out.stderr, } } } @@ -293,13 +293,9 @@ fn inject_config(mut argv: Vec, config_args: &[String]) -> Vec { argv } -/// Runs all invocations for one check, returning (ok, stdout, stderr). +/// Runs all invocations for one check. /// Never prints — callers decide when and whether to flush output. -async fn run_invocations( - name: &str, - invocations: &[Vec], - root: &Path, -) -> (bool, Vec, Vec) { +async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) -> LinterOutput { let mut all_ok = true; let mut combined_stdout = Vec::new(); let mut combined_stderr = Vec::new(); @@ -330,7 +326,11 @@ async fn run_invocations( } } - (all_ok, combined_stdout, combined_stderr) + LinterOutput { + ok: all_ok, + stdout: combined_stdout, + stderr: combined_stderr, + } } fn flush_output(stdout: &[u8], stderr: &[u8]) { From b93841bf816c5ee75e3373e197d5b3669f05d18e Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 09:30:59 +0000 Subject: [PATCH 042/141] refactor: extract run_inner to use ? for error propagation in renovate-deps Public run() converts any Err to LinterOutput::err; the inner logic uses ? throughout instead of match/early-return boilerplate. Signed-off-by: Gregor Zeitlinger --- src/linters/renovate_deps.rs | 80 +++++++++++++++--------------------- 1 file changed, 33 insertions(+), 47 deletions(-) diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index ce8ddfa..963f3ee 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -17,80 +17,66 @@ const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-v type DepMap = BTreeMap>>; pub async fn run(cfg: &RenovateDepsConfig, fix: bool, project_root: &Path) -> LinterOutput { - let log_bytes = match run_renovate(project_root).await { - Ok(b) => b, - Err(e) => return LinterOutput::err(format!("flint: renovate-deps: {e}\n")), - }; - - let generated = match extract_deps(&log_bytes, &cfg.exclude_managers) { - Ok(d) => d, - Err(e) => return LinterOutput::err(format!("flint: renovate-deps: {e}\n")), - }; + match run_inner(cfg, fix, project_root).await { + Ok(out) => out, + Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), + } +} +async fn run_inner( + cfg: &RenovateDepsConfig, + fix: bool, + project_root: &Path, +) -> anyhow::Result { + let log_bytes = run_renovate(project_root).await?; + let generated = extract_deps(&log_bytes, &cfg.exclude_managers)?; let committed_path = project_root.join(COMMITTED_DIR).join(COMMITTED_FILE); if !committed_path.exists() { if fix { - return match write_snapshot(&committed_path, &generated) { - Ok(()) => LinterOutput { - ok: true, - stdout: format!("{COMMITTED_FILE} has been created.\n").into_bytes(), - stderr: vec![], - }, - Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), - }; + write_snapshot(&committed_path, &generated)?; + return Ok(LinterOutput { + ok: true, + stdout: format!("{COMMITTED_FILE} has been created.\n").into_bytes(), + stderr: vec![], + }); } - return LinterOutput::err(format!( + return Ok(LinterOutput::err(format!( "ERROR: {COMMITTED_DISPLAY} does not exist.\nRun `flint --fix renovate-deps` to create it.\n" - )); + ))); } - let committed: DepMap = match std::fs::read_to_string(&committed_path) - .map_err(anyhow::Error::from) - .and_then(|s| serde_json::from_str(&s).map_err(anyhow::Error::from)) - { - Ok(d) => d, - Err(e) => { - return LinterOutput::err(format!( - "flint: renovate-deps: failed to read committed snapshot: {e}\n" - )); - } - }; + let committed: DepMap = serde_json::from_str(&std::fs::read_to_string(&committed_path)?)?; if committed == generated { - return LinterOutput { + return Ok(LinterOutput { ok: true, stdout: format!("{COMMITTED_FILE} is up to date.\n").into_bytes(), stderr: vec![], - }; + }); } let diff = unified_diff(&committed, &generated); if fix { - return match write_snapshot(&committed_path, &generated) { - Ok(()) => { - let mut stdout = diff.into_bytes(); - stdout - .extend_from_slice(format!("{COMMITTED_FILE} has been updated.\n").as_bytes()); - LinterOutput { - ok: true, - stdout, - stderr: vec![], - } - } - Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), - }; + write_snapshot(&committed_path, &generated)?; + let mut stdout = diff.into_bytes(); + stdout.extend_from_slice(format!("{COMMITTED_FILE} has been updated.\n").as_bytes()); + return Ok(LinterOutput { + ok: true, + stdout, + stderr: vec![], + }); } - LinterOutput { + Ok(LinterOutput { ok: false, stdout: diff.into_bytes(), stderr: format!( "ERROR: {COMMITTED_FILE} is out of date.\nRun `flint --fix renovate-deps` to update.\n" ) .into_bytes(), - } + }) } /// Runs `renovate --platform=local` and returns the combined stdout+stderr log bytes. From bc70f41b518a0b4fe1b0d14a652bccc692aeac5b Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 09:38:06 +0000 Subject: [PATCH 043/141] feat: e2e tests for renovate-deps; auto-exclude snapshot from linters - Four e2e tests (up-to-date, out-of-date, fix-creates, fix-updates) using a fake renovate binary injected via PATH - BUILTIN_EXCLUDES in files.rs always strips .github/renovate-tracked-deps.json from the file list so prettier, ec, etc. never touch the generated snapshot - Updated AGENTS-V2.md: accurate test section covering fixture cases, programmatic tests, and built-in exclusions Signed-off-by: Gregor Zeitlinger --- AGENTS-V2.md | 75 ++++++++++++++++++++----- src/files.rs | 4 ++ tests/e2e.rs | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 15 deletions(-) diff --git a/AGENTS-V2.md b/AGENTS-V2.md index 7840265..09c99de 100644 --- a/AGENTS-V2.md +++ b/AGENTS-V2.md @@ -155,28 +155,73 @@ template), add a module under `src/linters/` and use template model. Their implementations live in `src/linters/`. -## Testing +7. **Built-in file exclusions**: `src/files.rs` has a + `BUILTIN_EXCLUDES` slice of paths that are always removed + from the file list before any linter sees it. Currently + contains `.github/renovate-tracked-deps.json` (a + generated file that should never be linted by prettier, + ec, etc.). Add entries here — not in user-facing `exclude` + docs — when a file is managed by flint itself. -### Unit tests +## Testing -`src/registry.rs` has a unit test that enforces the -version-range consistency invariant. Run with: +Run all tests with: ```bash cargo test ``` -### End-to-end tests +### Unit tests + +In-module `#[cfg(test)]` blocks in `src/`. Notable: +- `src/registry.rs`: enforces version-range consistency +- `src/runner.rs`: config injection, scope filtering +- `src/linters/renovate_deps.rs`: log parsing, snapshot + read/write, diff output + +### Fixture-based e2e tests + +`tests/cases/` holds one directory per scenario. Each +contains: + +- `files/` — files copied verbatim into a temp git repo + and staged before the run +- `test.toml` — test spec: + +```toml +args = "--full shellcheck" +exit = 1 # optional, default 0 + +[env] # optional extra env vars +FOO = "bar" + +expected_stderr = """ +...golden output... +""" +``` + +The `cases` test in `tests/e2e.rs` runs all of them. +Set `UPDATE_SNAPSHOTS=1` to regenerate `expected_stderr`/ +`expected_stdout` in place. + +Use fixture cases for template-based linters (shellcheck, +prettier, etc.) where the tool is available on PATH in CI. + +### Programmatic e2e tests + +For special checks that require controlled tool output (e.g. +`renovate-deps`, which runs `renovate --platform=local`), +write a test function directly in `tests/e2e.rs` using a +fake binary injected via `PATH`: -`tests/e2e.rs` tests the full binary. Each test: +1. Write a shell script that emits the JSON output your + check expects, make it executable, place it in a `TempDir` +2. Prepend that dir to `PATH` via `flint_with_env` +3. Assert on exit code and stderr content -1. Creates a temp directory initialised as a git repo - (`git_repo()`) -2. Writes a minimal `mise.toml` declaring the tools under - test (`write_mise_toml()`) -3. Writes and stages test files (`stage()`) -4. Runs `flint` via `Command` and asserts on output/exit +See the `renovate_deps` mod in `tests/e2e.rs` for the +pattern. These tests are `#[cfg(unix)]` because the fake +binary is a shell script. -When adding a new linter, add an e2e test that covers at -least: check mode failure output format, and fix mode if -the linter supports it. +When adding a new special check, cover at least: clean pass, +failure with correct output, and fix mode if supported. diff --git a/src/files.rs b/src/files.rs index 06845f1..d8a1990 100644 --- a/src/files.rs +++ b/src/files.rs @@ -4,6 +4,9 @@ use std::process::Command; use crate::config::Config; +/// Files managed by flint itself — always excluded from generic linter checks. +const BUILTIN_EXCLUDES: &[&str] = &[".github/renovate-tracked-deps.json"]; + #[derive(Clone)] pub struct FileList { pub files: Vec, @@ -139,6 +142,7 @@ fn filter_names( ) -> Vec { names .into_iter() + .filter(|name| !BUILTIN_EXCLUDES.contains(&name.as_str())) .filter(|name| exclude_re.is_none_or(|re| !re.is_match(name))) .filter(|name| !exclude_paths.iter().any(|p| name.starts_with(p.as_str()))) .map(|name| project_root.join(name)) diff --git a/tests/e2e.rs b/tests/e2e.rs index da467f4..a6d8938 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -170,6 +170,159 @@ fn strip_ansi(s: &str) -> String { out } +// ── renovate-deps tests ────────────────────────────────────────────────────── +// +// These tests inject a fake `renovate` binary via PATH so they don't need the +// real tool installed. Unix-only because the fake is a shell script. + +#[cfg(unix)] +mod renovate_deps { + use super::*; + use std::os::unix::fs::PermissionsExt; + + // The JSON log line the fake renovate emits. + const RENOVATE_LOG: &str = r#"{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}"#; + + // What write_snapshot produces for that log (serde_json::to_string_pretty + \n). + const SNAPSHOT: &str = "{\n \"package.json\": {\n \"npm\": [\n \"express\",\n \"lodash\"\n ]\n }\n}\n"; + + /// Creates a temp dir containing a fake `renovate` script that emits `log_line`. + fn fake_renovate_bin(log_line: &str) -> TempDir { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("renovate"); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", log_line), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + dir + } + + /// Sets up a minimal repo with renovate declared in mise.toml. + /// Optionally writes a snapshot file. + fn setup_repo(snapshot: Option<&str>) -> TempDir { + let repo = git_repo(); + std::fs::create_dir_all(repo.path().join(".github")).unwrap(); + std::fs::write( + repo.path().join("mise.toml"), + "[tools]\nrenovate = \"latest\"\n", + ) + .unwrap(); + std::fs::write(repo.path().join(".github").join("renovate.json5"), "{}").unwrap(); + std::fs::write(repo.path().join("package.json"), "{}").unwrap(); + if let Some(snap) = snapshot { + std::fs::write( + repo.path() + .join(".github") + .join("renovate-tracked-deps.json"), + snap, + ) + .unwrap(); + } + Command::new("git") + .args(["add", "-A"]) + .current_dir(repo.path()) + .output() + .unwrap(); + repo + } + + fn prepend_path(dir: &Path) -> String { + let orig = std::env::var("PATH").unwrap_or_default(); + format!("{}:{orig}", dir.display()) + } + + #[test] + fn up_to_date() { + let bin = fake_renovate_bin(RENOVATE_LOG); + let repo = setup_repo(Some(SNAPSHOT)); + let path = prepend_path(bin.path()); + let out = flint_with_env( + &["--full", "renovate-deps"], + repo.path(), + &[("PATH", &path)], + ); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + #[test] + fn out_of_date() { + let stale = "{\n \"package.json\": {\n \"npm\": [\n \"old-dep\"\n ]\n }\n}\n"; + let bin = fake_renovate_bin(RENOVATE_LOG); + let repo = setup_repo(Some(stale)); + let path = prepend_path(bin.path()); + let out = flint_with_env( + &["--full", "renovate-deps"], + repo.path(), + &[("PATH", &path)], + ); + assert_eq!(out.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("out of date"), "stderr: {stderr}"); + assert!( + stderr.contains("old-dep"), + "diff missing in stderr: {stderr}" + ); + } + + #[test] + fn fix_creates_snapshot() { + let bin = fake_renovate_bin(RENOVATE_LOG); + let repo = setup_repo(None); + let path = prepend_path(bin.path()); + let out = flint_with_env( + &["--full", "--fix", "renovate-deps"], + repo.path(), + &[("PATH", &path)], + ); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let written = std::fs::read_to_string( + repo.path() + .join(".github") + .join("renovate-tracked-deps.json"), + ) + .unwrap(); + assert_eq!(written, SNAPSHOT); + } + + #[test] + fn fix_updates_stale_snapshot() { + let stale = "{\n \"package.json\": {\n \"npm\": [\n \"old-dep\"\n ]\n }\n}\n"; + let bin = fake_renovate_bin(RENOVATE_LOG); + let repo = setup_repo(Some(stale)); + let path = prepend_path(bin.path()); + let out = flint_with_env( + &["--full", "--fix", "renovate-deps"], + repo.path(), + &[("PATH", &path)], + ); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let written = std::fs::read_to_string( + repo.path() + .join(".github") + .join("renovate-tracked-deps.json"), + ) + .unwrap(); + assert_eq!(written, SNAPSHOT); + } +} + fn copy_dir_into(src: &Path, dst: &Path) { for entry in std::fs::read_dir(src).expect("files/ dir not found") { let entry = entry.unwrap(); From 841fbe7a799a6ccf6ec6d415c45cd58643f65a5d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 09:53:22 +0000 Subject: [PATCH 044/141] test: add e2e fixture cases for renovate-deps Adds four fixture cases (up-to-date, out-of-date, fix-create, fix-update) that exercise the renovate-deps check end-to-end using a fake renovate binary injected via [fake_bins] in test.toml. Also: - Extend the fixture runner with [fake_bins] PATH injection and [expected_files] assertions so fix-mode tests verify written content. - Update write_test_toml to preserve [env]/[fake_bins]/[expected_files] and write scalars before table sections (TOML scoping requirement). - Export COMMITTED_DISPLAY as pub(crate) so BUILTIN_EXCLUDES in files.rs can reference the constant instead of duplicating the string. Signed-off-by: Gregor Zeitlinger --- src/files.rs | 3 +- src/linters/renovate_deps.rs | 2 +- .../files/.github/renovate.json5 | 1 + .../renovate-deps-fix-create/files/mise.toml | 3 + .../files/package.json | 1 + .../cases/renovate-deps-fix-create/test.toml | 20 +++ .../files/.github/renovate-tracked-deps.json | 7 ++ .../files/.github/renovate.json5 | 1 + .../renovate-deps-fix-update/files/mise.toml | 3 + .../files/package.json | 1 + .../cases/renovate-deps-fix-update/test.toml | 20 +++ .../files/.github/renovate-tracked-deps.json | 7 ++ .../files/.github/renovate.json5 | 1 + .../renovate-deps-out-of-date/files/mise.toml | 3 + .../files/package.json | 1 + .../cases/renovate-deps-out-of-date/test.toml | 29 +++++ .../files/.github/renovate-tracked-deps.json | 8 ++ .../files/.github/renovate.json5 | 1 + .../renovate-deps-up-to-date/files/mise.toml | 3 + .../files/package.json | 1 + .../cases/renovate-deps-up-to-date/test.toml | 8 ++ tests/e2e.rs | 115 +++++++++++++++++- 22 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 tests/cases/renovate-deps-fix-create/files/.github/renovate.json5 create mode 100644 tests/cases/renovate-deps-fix-create/files/mise.toml create mode 100644 tests/cases/renovate-deps-fix-create/files/package.json create mode 100644 tests/cases/renovate-deps-fix-create/test.toml create mode 100644 tests/cases/renovate-deps-fix-update/files/.github/renovate-tracked-deps.json create mode 100644 tests/cases/renovate-deps-fix-update/files/.github/renovate.json5 create mode 100644 tests/cases/renovate-deps-fix-update/files/mise.toml create mode 100644 tests/cases/renovate-deps-fix-update/files/package.json create mode 100644 tests/cases/renovate-deps-fix-update/test.toml create mode 100644 tests/cases/renovate-deps-out-of-date/files/.github/renovate-tracked-deps.json create mode 100644 tests/cases/renovate-deps-out-of-date/files/.github/renovate.json5 create mode 100644 tests/cases/renovate-deps-out-of-date/files/mise.toml create mode 100644 tests/cases/renovate-deps-out-of-date/files/package.json create mode 100644 tests/cases/renovate-deps-out-of-date/test.toml create mode 100644 tests/cases/renovate-deps-up-to-date/files/.github/renovate-tracked-deps.json create mode 100644 tests/cases/renovate-deps-up-to-date/files/.github/renovate.json5 create mode 100644 tests/cases/renovate-deps-up-to-date/files/mise.toml create mode 100644 tests/cases/renovate-deps-up-to-date/files/package.json create mode 100644 tests/cases/renovate-deps-up-to-date/test.toml diff --git a/src/files.rs b/src/files.rs index d8a1990..ce02118 100644 --- a/src/files.rs +++ b/src/files.rs @@ -3,9 +3,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::Config; +use crate::linters::renovate_deps::COMMITTED_DISPLAY; /// Files managed by flint itself — always excluded from generic linter checks. -const BUILTIN_EXCLUDES: &[&str] = &[".github/renovate-tracked-deps.json"]; +const BUILTIN_EXCLUDES: &[&str] = &[COMMITTED_DISPLAY]; #[derive(Clone)] pub struct FileList { diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 963f3ee..1453b40 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -8,7 +8,7 @@ use crate::linters::LinterOutput; const COMMITTED_DIR: &str = ".github"; const COMMITTED_FILE: &str = "renovate-tracked-deps.json"; -const COMMITTED_DISPLAY: &str = ".github/renovate-tracked-deps.json"; +pub(crate) const COMMITTED_DISPLAY: &str = ".github/renovate-tracked-deps.json"; const RENOVATE_CONFIG_FILE: &str = "renovate.json5"; const PACKAGE_FILES_MSG: &str = "packageFiles with updates"; const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-version"]; diff --git a/tests/cases/renovate-deps-fix-create/files/.github/renovate.json5 b/tests/cases/renovate-deps-fix-create/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps-fix-create/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps-fix-create/files/mise.toml b/tests/cases/renovate-deps-fix-create/files/mise.toml new file mode 100644 index 0000000..72ae587 --- /dev/null +++ b/tests/cases/renovate-deps-fix-create/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +renovate = "latest" + diff --git a/tests/cases/renovate-deps-fix-create/files/package.json b/tests/cases/renovate-deps-fix-create/files/package.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps-fix-create/files/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps-fix-create/test.toml b/tests/cases/renovate-deps-fix-create/test.toml new file mode 100644 index 0000000..db2c62c --- /dev/null +++ b/tests/cases/renovate-deps-fix-create/test.toml @@ -0,0 +1,20 @@ +args = "--full --fix renovate-deps" +exit = 0 + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' + +[expected_files] +".github/renovate-tracked-deps.json" = """ +{ + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} +""" \ No newline at end of file diff --git a/tests/cases/renovate-deps-fix-update/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps-fix-update/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..167d26b --- /dev/null +++ b/tests/cases/renovate-deps-fix-update/files/.github/renovate-tracked-deps.json @@ -0,0 +1,7 @@ +{ + "package.json": { + "npm": [ + "old-dep" + ] + } +} diff --git a/tests/cases/renovate-deps-fix-update/files/.github/renovate.json5 b/tests/cases/renovate-deps-fix-update/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps-fix-update/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps-fix-update/files/mise.toml b/tests/cases/renovate-deps-fix-update/files/mise.toml new file mode 100644 index 0000000..72ae587 --- /dev/null +++ b/tests/cases/renovate-deps-fix-update/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +renovate = "latest" + diff --git a/tests/cases/renovate-deps-fix-update/files/package.json b/tests/cases/renovate-deps-fix-update/files/package.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps-fix-update/files/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps-fix-update/test.toml b/tests/cases/renovate-deps-fix-update/test.toml new file mode 100644 index 0000000..db2c62c --- /dev/null +++ b/tests/cases/renovate-deps-fix-update/test.toml @@ -0,0 +1,20 @@ +args = "--full --fix renovate-deps" +exit = 0 + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' + +[expected_files] +".github/renovate-tracked-deps.json" = """ +{ + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} +""" \ No newline at end of file diff --git a/tests/cases/renovate-deps-out-of-date/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps-out-of-date/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..167d26b --- /dev/null +++ b/tests/cases/renovate-deps-out-of-date/files/.github/renovate-tracked-deps.json @@ -0,0 +1,7 @@ +{ + "package.json": { + "npm": [ + "old-dep" + ] + } +} diff --git a/tests/cases/renovate-deps-out-of-date/files/.github/renovate.json5 b/tests/cases/renovate-deps-out-of-date/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps-out-of-date/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps-out-of-date/files/mise.toml b/tests/cases/renovate-deps-out-of-date/files/mise.toml new file mode 100644 index 0000000..72ae587 --- /dev/null +++ b/tests/cases/renovate-deps-out-of-date/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +renovate = "latest" + diff --git a/tests/cases/renovate-deps-out-of-date/files/package.json b/tests/cases/renovate-deps-out-of-date/files/package.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps-out-of-date/files/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps-out-of-date/test.toml b/tests/cases/renovate-deps-out-of-date/test.toml new file mode 100644 index 0000000..95fca32 --- /dev/null +++ b/tests/cases/renovate-deps-out-of-date/test.toml @@ -0,0 +1,29 @@ +args = "--full renovate-deps" +exit = 1 + +expected_stderr = """ +[renovate-deps] +--- .github/renovate-tracked-deps.json ++++ generated +@@ -1,7 +1,8 @@ + { + "package.json": { + "npm": [ +- "old-dep" ++ "express", ++ "lodash" + ] + } + } +ERROR: renovate-tracked-deps.json is out of date. +Run `flint --fix renovate-deps` to update. + +flint: 1 check failed (renovate-deps) +šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. +""" + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' diff --git a/tests/cases/renovate-deps-up-to-date/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps-up-to-date/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..b46b339 --- /dev/null +++ b/tests/cases/renovate-deps-up-to-date/files/.github/renovate-tracked-deps.json @@ -0,0 +1,8 @@ +{ + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} diff --git a/tests/cases/renovate-deps-up-to-date/files/.github/renovate.json5 b/tests/cases/renovate-deps-up-to-date/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps-up-to-date/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps-up-to-date/files/mise.toml b/tests/cases/renovate-deps-up-to-date/files/mise.toml new file mode 100644 index 0000000..72ae587 --- /dev/null +++ b/tests/cases/renovate-deps-up-to-date/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +renovate = "latest" + diff --git a/tests/cases/renovate-deps-up-to-date/files/package.json b/tests/cases/renovate-deps-up-to-date/files/package.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps-up-to-date/files/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps-up-to-date/test.toml b/tests/cases/renovate-deps-up-to-date/test.toml new file mode 100644 index 0000000..7319c09 --- /dev/null +++ b/tests/cases/renovate-deps-up-to-date/test.toml @@ -0,0 +1,8 @@ +args = "--full renovate-deps" +exit = 0 + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' diff --git a/tests/e2e.rs b/tests/e2e.rs index a6d8938..531f291 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -48,6 +48,12 @@ fn git_repo() -> TempDir { /// expected_stderr = """...""" # optional, default "" /// expected_stdout = """...""" # optional, default "" /// +/// [env] # optional extra env vars +/// KEY = "value" +/// +/// [fake_bins] # optional fake binaries (Unix only) +/// renovate = "#!/bin/sh\necho '...'\n" # written as executable, prepended to PATH +/// /// Set UPDATE_SNAPSHOTS=1 to regenerate golden output in test.toml. #[test] fn cases() { @@ -100,10 +106,19 @@ fn run_case(case: &Path, name: &str, update: bool) { .collect() }) .unwrap_or_default(); - let env_refs: Vec<(&str, &str)> = env_vars + + // Write fake binaries into a temp dir and prepend it to PATH. + // The tempdir must stay alive until after flint_with_env returns. + let fake_bin_dir = tempfile::tempdir().expect("fake_bin tempdir"); + let fake_path = setup_fake_bins(&cfg, name, fake_bin_dir.path()); + + let mut env_refs: Vec<(&str, &str)> = env_vars .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); + if let Some(ref p) = fake_path { + env_refs.push(("PATH", p.as_str())); + } let out = flint_with_env(&args, repo.path(), &env_refs); @@ -114,7 +129,13 @@ fn run_case(case: &Path, name: &str, update: bool) { strip_ansi(&String::from_utf8_lossy(&out.stdout).replace(repo_str.as_ref(), "")); if update { - write_test_toml(&toml_path, args_str, expected_exit, &stderr, &stdout); + write_test_toml( + &toml_path, + &cfg, + out.status.code().unwrap_or(0) as i32, + &stderr, + &stdout, + ); println!("{name}: snapshots updated"); return; } @@ -134,18 +155,73 @@ fn run_case(case: &Path, name: &str, update: bool) { Some(expected_exit), "{name}: exit code mismatch" ); + + // Assert file contents written by flint (e.g. fix mode snapshots). + if let Some(files) = cfg.get("expected_files").and_then(|v| v.as_table()) { + for (rel_path, expected) in files { + let expected = expected + .as_str() + .unwrap_or_else(|| panic!("{name}: expected_files.{rel_path} must be a string")); + let actual = std::fs::read_to_string(repo.path().join(rel_path)) + .unwrap_or_else(|e| panic!("{name}: expected_files.{rel_path}: {e}")); + assert_eq!(actual, expected, "{name}: {rel_path} content mismatch"); + } + } } -/// Rewrites test.toml preserving args/exit and updating the expected fields. -fn write_test_toml(path: &Path, args: &str, exit: i32, stderr: &str, stdout: &str) { - let mut out = format!("args = \"{}\"\n", args.replace('"', "\\\"")); +/// Rewrites test.toml updating snapshot fields (exit, expected_stderr, expected_stdout) +/// while preserving everything else (args, env, fake_bins, expected_files). +/// +/// Scalars (args, exit, expected_*) are written before table sections ([env], +/// [fake_bins], [expected_files]) to satisfy TOML's scoping rules. +fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdout: &str) { + let args_str = cfg["args"].as_str().unwrap_or(""); + let mut out = format!("args = \"{}\"\n", args_str.replace('"', "\\\"")); out += &format!("exit = {exit}\n"); + if !stderr.is_empty() { out += &format!("\nexpected_stderr = \"\"\"\n{stderr}\"\"\""); } if !stdout.is_empty() { out += &format!("\nexpected_stdout = \"\"\"\n{stdout}\"\"\""); } + + if let Some(env) = cfg.get("env").and_then(|v| v.as_table()) { + if !env.is_empty() { + out += "\n\n[env]\n"; + for (k, v) in env { + if let Some(s) = v.as_str() { + out += &format!("{k} = \"{}\"\n", s.replace('"', "\\\"")); + } + } + } + } + + // Serialize as multiline literal strings so shell scripts stay readable. + // TOML trims the first newline after ''', so '''\n{s}''' roundtrips cleanly. + if let Some(bins) = cfg.get("fake_bins").and_then(|v| v.as_table()) { + if !bins.is_empty() { + out += "\n[fake_bins]\n"; + for (k, v) in bins { + if let Some(s) = v.as_str() { + out += &format!("{k} = '''\n{s}'''\n"); + } + } + } + } + + // Preserve [expected_files] — not updated by UPDATE_SNAPSHOTS, managed manually. + if let Some(files) = cfg.get("expected_files").and_then(|v| v.as_table()) { + if !files.is_empty() { + out += "\n[expected_files]\n"; + for (k, v) in files { + if let Some(s) = v.as_str() { + out += &format!("\"{k}\" = \"\"\"\n{s}\"\"\""); + } + } + } + } + std::fs::write(path, out).unwrap(); } @@ -323,6 +399,35 @@ mod renovate_deps { } } +/// Writes fake binaries from `[fake_bins]` in the test config into `bin_dir`, +/// makes them executable (Unix), and returns a PATH string that prepends +/// `bin_dir` to the current PATH. Returns `None` when no fake_bins are declared. +/// On non-Unix platforms fake_bins are silently ignored. +fn setup_fake_bins(cfg: &toml::Value, case_name: &str, bin_dir: &Path) -> Option { + let table = cfg.get("fake_bins")?.as_table()?; + if table.is_empty() { + return None; + } + + for (bin_name, script) in table { + let content = script + .as_str() + .unwrap_or_else(|| panic!("{case_name}: fake_bins.{bin_name} must be a string")); + let path = bin_dir.join(bin_name); + std::fs::write(&path, content) + .unwrap_or_else(|e| panic!("{case_name}: failed to write fake bin {bin_name}: {e}")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .unwrap_or_else(|e| panic!("{case_name}: chmod failed for {bin_name}: {e}")); + } + } + + let orig = std::env::var("PATH").unwrap_or_default(); + Some(format!("{}:{orig}", bin_dir.display())) +} + fn copy_dir_into(src: &Path, dst: &Path) { for entry in std::fs::read_dir(src).expect("files/ dir not found") { let entry = entry.unwrap(); From 065fc2eca3cd32d149d6028c660a59105dbb1f37 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 09:56:10 +0000 Subject: [PATCH 045/141] refactor: use [expected] table in test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group expected_stderr/stdout under an [expected] TOML table and expected_files under [expected.files]. This eliminates the need to order top-level scalars before table sections to avoid TOML scoping issues — [expected] and [fake_bins] can now appear in any order. Also update AGENTS-V2.md to document the new format and remove the outdated "Programmatic e2e tests" guidance now that [fake_bins] covers the same use case via fixture cases. Signed-off-by: Gregor Zeitlinger --- AGENTS-V2.md | 48 +++++----- tests/cases/auto-fix-and-review/test.toml | 3 +- tests/cases/auto-fix-cargo-fmt/test.toml | 3 +- tests/cases/auto-review-two-linters/test.toml | 3 +- tests/cases/auto-review-unfixable/test.toml | 3 +- tests/cases/cargo-fmt-failure/test.toml | 3 +- .../cases/renovate-deps-fix-create/test.toml | 2 +- .../cases/renovate-deps-fix-update/test.toml | 2 +- .../cases/renovate-deps-out-of-date/test.toml | 3 +- tests/cases/shellcheck-failure/test.toml | 3 +- tests/e2e.rs | 93 +++++++++++-------- 11 files changed, 93 insertions(+), 73 deletions(-) diff --git a/AGENTS-V2.md b/AGENTS-V2.md index 09c99de..436bb30 100644 --- a/AGENTS-V2.md +++ b/AGENTS-V2.md @@ -192,36 +192,34 @@ contains: args = "--full shellcheck" exit = 1 # optional, default 0 -[env] # optional extra env vars -FOO = "bar" - -expected_stderr = """ +[expected] # optional golden output +stderr = """ ...golden output... """ -``` - -The `cases` test in `tests/e2e.rs` runs all of them. -Set `UPDATE_SNAPSHOTS=1` to regenerate `expected_stderr`/ -`expected_stdout` in place. -Use fixture cases for template-based linters (shellcheck, -prettier, etc.) where the tool is available on PATH in CI. +[expected.files] # optional: assert files written by --fix +".github/renovate-tracked-deps.json" = """ +{...} +""" -### Programmatic e2e tests +[env] # optional extra env vars +FOO = "bar" -For special checks that require controlled tool output (e.g. -`renovate-deps`, which runs `renovate --platform=local`), -write a test function directly in `tests/e2e.rs` using a -fake binary injected via `PATH`: +[fake_bins] # optional fake binaries (Unix only) +renovate = ''' +#!/bin/sh +echo '...' +''' +``` -1. Write a shell script that emits the JSON output your - check expects, make it executable, place it in a `TempDir` -2. Prepend that dir to `PATH` via `flint_with_env` -3. Assert on exit code and stderr content +The `cases` test in `tests/e2e.rs` runs all of them. +Set `UPDATE_SNAPSHOTS=1` to regenerate `[expected].stderr`/ +`stdout` in place. `[expected.files]` and `[fake_bins]` are +always preserved by the snapshot writer. -See the `renovate_deps` mod in `tests/e2e.rs` for the -pattern. These tests are `#[cfg(unix)]` because the fake -binary is a shell script. +Use fixture cases for any check — including ones that require +fake external binaries (via `[fake_bins]`). The fixture runner +writes each binary into a tempdir and prepends it to `PATH`. -When adding a new special check, cover at least: clean pass, -failure with correct output, and fix mode if supported. +When adding a new check, cover at least: clean pass, failure +with correct diff/output, and fix mode if supported. diff --git a/tests/cases/auto-fix-and-review/test.toml b/tests/cases/auto-fix-and-review/test.toml index 41fa078..34cb661 100644 --- a/tests/cases/auto-fix-and-review/test.toml +++ b/tests/cases/auto-fix-and-review/test.toml @@ -1,7 +1,8 @@ args = "--full --auto cargo-fmt shellcheck" exit = 1 -expected_stderr = """ +[expected] +stderr = """ [shellcheck] In /bad.sh line 2: diff --git a/tests/cases/auto-fix-cargo-fmt/test.toml b/tests/cases/auto-fix-cargo-fmt/test.toml index f757f83..ec61a48 100644 --- a/tests/cases/auto-fix-cargo-fmt/test.toml +++ b/tests/cases/auto-fix-cargo-fmt/test.toml @@ -1,6 +1,7 @@ args = "--full --auto cargo-fmt" exit = 1 -expected_stderr = """ +[expected] +stderr = """ flint: fixed: cargo-fmt — commit before pushing """ \ No newline at end of file diff --git a/tests/cases/auto-review-two-linters/test.toml b/tests/cases/auto-review-two-linters/test.toml index 5a28b47..46f811c 100644 --- a/tests/cases/auto-review-two-linters/test.toml +++ b/tests/cases/auto-review-two-linters/test.toml @@ -1,7 +1,8 @@ args = "--full --auto shellcheck actionlint" exit = 1 -expected_stderr = """ +[expected] +stderr = """ [actionlint] .github/workflows/ci.yml:6:23: undefined variable "foo". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression] | diff --git a/tests/cases/auto-review-unfixable/test.toml b/tests/cases/auto-review-unfixable/test.toml index f797f78..03d92c2 100644 --- a/tests/cases/auto-review-unfixable/test.toml +++ b/tests/cases/auto-review-unfixable/test.toml @@ -1,7 +1,8 @@ args = "--full --auto shellcheck" exit = 1 -expected_stderr = """ +[expected] +stderr = """ [shellcheck] In /bad.sh line 2: diff --git a/tests/cases/cargo-fmt-failure/test.toml b/tests/cases/cargo-fmt-failure/test.toml index 1a0e874..794710c 100644 --- a/tests/cases/cargo-fmt-failure/test.toml +++ b/tests/cases/cargo-fmt-failure/test.toml @@ -1,7 +1,8 @@ args = "--full cargo-fmt" exit = 1 -expected_stderr = """ +[expected] +stderr = """ [cargo-fmt] Diff in /src/lib.rs:1: -pub struct Foo { pub a: u32, pub b: u32 } diff --git a/tests/cases/renovate-deps-fix-create/test.toml b/tests/cases/renovate-deps-fix-create/test.toml index db2c62c..d83db8b 100644 --- a/tests/cases/renovate-deps-fix-create/test.toml +++ b/tests/cases/renovate-deps-fix-create/test.toml @@ -7,7 +7,7 @@ renovate = ''' printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' ''' -[expected_files] +[expected.files] ".github/renovate-tracked-deps.json" = """ { "package.json": { diff --git a/tests/cases/renovate-deps-fix-update/test.toml b/tests/cases/renovate-deps-fix-update/test.toml index db2c62c..d83db8b 100644 --- a/tests/cases/renovate-deps-fix-update/test.toml +++ b/tests/cases/renovate-deps-fix-update/test.toml @@ -7,7 +7,7 @@ renovate = ''' printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' ''' -[expected_files] +[expected.files] ".github/renovate-tracked-deps.json" = """ { "package.json": { diff --git a/tests/cases/renovate-deps-out-of-date/test.toml b/tests/cases/renovate-deps-out-of-date/test.toml index 95fca32..71eddec 100644 --- a/tests/cases/renovate-deps-out-of-date/test.toml +++ b/tests/cases/renovate-deps-out-of-date/test.toml @@ -1,7 +1,8 @@ args = "--full renovate-deps" exit = 1 -expected_stderr = """ +[expected] +stderr = """ [renovate-deps] --- .github/renovate-tracked-deps.json +++ generated diff --git a/tests/cases/shellcheck-failure/test.toml b/tests/cases/shellcheck-failure/test.toml index 39b54e8..ed83dc5 100644 --- a/tests/cases/shellcheck-failure/test.toml +++ b/tests/cases/shellcheck-failure/test.toml @@ -1,7 +1,8 @@ args = "--full shellcheck" exit = 1 -expected_stderr = """ +[expected] +stderr = """ [shellcheck] In /bad.sh line 2: diff --git a/tests/e2e.rs b/tests/e2e.rs index 531f291..1210f81 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -43,16 +43,24 @@ fn git_repo() -> TempDir { /// test.toml — args, expected exit code, and golden output /// /// test.toml format: -/// args = "--full --auto shellcheck" -/// exit = 1 # optional, default 0 -/// expected_stderr = """...""" # optional, default "" -/// expected_stdout = """...""" # optional, default "" +/// args = "--full --auto shellcheck" +/// exit = 1 # optional, default 0 /// -/// [env] # optional extra env vars +/// [expected] # optional golden output +/// stderr = """...""" +/// stdout = """...""" +/// +/// [expected.files] # optional file contents asserted after run +/// ".github/renovate-tracked-deps.json" = """...""" +/// +/// [env] # optional extra env vars /// KEY = "value" /// -/// [fake_bins] # optional fake binaries (Unix only) -/// renovate = "#!/bin/sh\necho '...'\n" # written as executable, prepended to PATH +/// [fake_bins] # optional fake binaries (Unix only) +/// renovate = ''' +/// #!/bin/sh +/// echo '...' +/// ''' /// /// Set UPDATE_SNAPSHOTS=1 to regenerate golden output in test.toml. #[test] @@ -140,12 +148,13 @@ fn run_case(case: &Path, name: &str, update: bool) { return; } - let exp_stderr = cfg - .get("expected_stderr") + let expected = cfg.get("expected"); + let exp_stderr = expected + .and_then(|v| v.get("stderr")) .and_then(|v| v.as_str()) .unwrap_or(""); - let exp_stdout = cfg - .get("expected_stdout") + let exp_stdout = expected + .and_then(|v| v.get("stdout")) .and_then(|v| v.as_str()) .unwrap_or(""); assert_eq!(stderr, exp_stderr, "{name}: stderr mismatch"); @@ -157,33 +166,51 @@ fn run_case(case: &Path, name: &str, update: bool) { ); // Assert file contents written by flint (e.g. fix mode snapshots). - if let Some(files) = cfg.get("expected_files").and_then(|v| v.as_table()) { - for (rel_path, expected) in files { - let expected = expected + if let Some(files) = expected + .and_then(|v| v.get("files")) + .and_then(|v| v.as_table()) + { + for (rel_path, exp) in files { + let exp = exp .as_str() - .unwrap_or_else(|| panic!("{name}: expected_files.{rel_path} must be a string")); + .unwrap_or_else(|| panic!("{name}: expected.files.{rel_path} must be a string")); let actual = std::fs::read_to_string(repo.path().join(rel_path)) - .unwrap_or_else(|e| panic!("{name}: expected_files.{rel_path}: {e}")); - assert_eq!(actual, expected, "{name}: {rel_path} content mismatch"); + .unwrap_or_else(|e| panic!("{name}: expected.files.{rel_path}: {e}")); + assert_eq!(actual, exp, "{name}: {rel_path} content mismatch"); } } } -/// Rewrites test.toml updating snapshot fields (exit, expected_stderr, expected_stdout) -/// while preserving everything else (args, env, fake_bins, expected_files). -/// -/// Scalars (args, exit, expected_*) are written before table sections ([env], -/// [fake_bins], [expected_files]) to satisfy TOML's scoping rules. +/// Rewrites test.toml updating snapshot fields (exit, [expected].stderr/stdout) +/// while preserving everything else (args, env, fake_bins, expected.files). fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdout: &str) { let args_str = cfg["args"].as_str().unwrap_or(""); let mut out = format!("args = \"{}\"\n", args_str.replace('"', "\\\"")); out += &format!("exit = {exit}\n"); - if !stderr.is_empty() { - out += &format!("\nexpected_stderr = \"\"\"\n{stderr}\"\"\""); - } - if !stdout.is_empty() { - out += &format!("\nexpected_stdout = \"\"\"\n{stdout}\"\"\""); + // [expected] — stderr/stdout updated; files preserved unchanged. + let has_stderr = !stderr.is_empty(); + let has_stdout = !stdout.is_empty(); + let existing_files = cfg + .get("expected") + .and_then(|v| v.get("files")) + .and_then(|v| v.as_table()); + if has_stderr || has_stdout || existing_files.is_some() { + out += "\n[expected]\n"; + if has_stderr { + out += &format!("stderr = \"\"\"\n{stderr}\"\"\""); + } + if has_stdout { + out += &format!("stdout = \"\"\"\n{stdout}\"\"\""); + } + if let Some(files) = existing_files { + out += "\n\n[expected.files]\n"; + for (k, v) in files { + if let Some(s) = v.as_str() { + out += &format!("\"{k}\" = \"\"\"\n{s}\"\"\""); + } + } + } } if let Some(env) = cfg.get("env").and_then(|v| v.as_table()) { @@ -210,18 +237,6 @@ fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdo } } - // Preserve [expected_files] — not updated by UPDATE_SNAPSHOTS, managed manually. - if let Some(files) = cfg.get("expected_files").and_then(|v| v.as_table()) { - if !files.is_empty() { - out += "\n[expected_files]\n"; - for (k, v) in files { - if let Some(s) = v.as_str() { - out += &format!("\"{k}\" = \"\"\"\n{s}\"\"\""); - } - } - } - } - std::fs::write(path, out).unwrap(); } From 63549b2882608f016d83994c15531346146cc2c7 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 10:00:14 +0000 Subject: [PATCH 046/141] refactor: consolidate args/exit into [expected] table Move args and exit from top-level keys into the [expected] table so all test spec fields live in one place. Also adds [expected.files] to renovate-deps-out-of-date asserting the stale file is not modified. Signed-off-by: Gregor Zeitlinger --- AGENTS-V2.md | 9 +-- tests/cases/auto-fix-and-review/test.toml | 5 +- tests/cases/auto-fix-cargo-fmt/test.toml | 5 +- tests/cases/auto-review-two-linters/test.toml | 5 +- tests/cases/auto-review-unfixable/test.toml | 5 +- tests/cases/cargo-fmt-failure/test.toml | 5 +- tests/cases/env-var-exclude/test.toml | 1 + tests/cases/exclude-paths/test.toml | 1 + .../cases/renovate-deps-fix-create/test.toml | 15 ++-- .../cases/renovate-deps-fix-update/test.toml | 15 ++-- .../cases/renovate-deps-out-of-date/test.toml | 14 +++- .../cases/renovate-deps-up-to-date/test.toml | 1 + tests/cases/shellcheck-clean/test.toml | 1 + tests/cases/shellcheck-config-dir/test.toml | 1 + tests/cases/shellcheck-failure/test.toml | 5 +- tests/e2e.rs | 78 +++++++++---------- 16 files changed, 86 insertions(+), 80 deletions(-) diff --git a/AGENTS-V2.md b/AGENTS-V2.md index 436bb30..28cac17 100644 --- a/AGENTS-V2.md +++ b/AGENTS-V2.md @@ -189,10 +189,9 @@ contains: - `test.toml` — test spec: ```toml +[expected] args = "--full shellcheck" exit = 1 # optional, default 0 - -[expected] # optional golden output stderr = """ ...golden output... """ @@ -213,9 +212,9 @@ echo '...' ``` The `cases` test in `tests/e2e.rs` runs all of them. -Set `UPDATE_SNAPSHOTS=1` to regenerate `[expected].stderr`/ -`stdout` in place. `[expected.files]` and `[fake_bins]` are -always preserved by the snapshot writer. +Set `UPDATE_SNAPSHOTS=1` to regenerate `[expected].exit`/ +`stderr`/`stdout` in place. `[expected.files]` and `[fake_bins]` +are always preserved by the snapshot writer. Use fixture cases for any check — including ones that require fake external binaries (via `[fake_bins]`). The fixture runner diff --git a/tests/cases/auto-fix-and-review/test.toml b/tests/cases/auto-fix-and-review/test.toml index 34cb661..becb1e3 100644 --- a/tests/cases/auto-fix-and-review/test.toml +++ b/tests/cases/auto-fix-and-review/test.toml @@ -1,7 +1,6 @@ +[expected] args = "--full --auto cargo-fmt shellcheck" exit = 1 - -[expected] stderr = """ [shellcheck] @@ -15,4 +14,4 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: fixed: cargo-fmt — commit before pushing | review: shellcheck -""" \ No newline at end of file +""" diff --git a/tests/cases/auto-fix-cargo-fmt/test.toml b/tests/cases/auto-fix-cargo-fmt/test.toml index ec61a48..72570a3 100644 --- a/tests/cases/auto-fix-cargo-fmt/test.toml +++ b/tests/cases/auto-fix-cargo-fmt/test.toml @@ -1,7 +1,6 @@ +[expected] args = "--full --auto cargo-fmt" exit = 1 - -[expected] stderr = """ flint: fixed: cargo-fmt — commit before pushing -""" \ No newline at end of file +""" diff --git a/tests/cases/auto-review-two-linters/test.toml b/tests/cases/auto-review-two-linters/test.toml index 46f811c..4160b34 100644 --- a/tests/cases/auto-review-two-linters/test.toml +++ b/tests/cases/auto-review-two-linters/test.toml @@ -1,7 +1,6 @@ +[expected] args = "--full --auto shellcheck actionlint" exit = 1 - -[expected] stderr = """ [actionlint] .github/workflows/ci.yml:6:23: undefined variable "foo". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression] @@ -20,4 +19,4 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: review: actionlint, shellcheck -""" \ No newline at end of file +""" diff --git a/tests/cases/auto-review-unfixable/test.toml b/tests/cases/auto-review-unfixable/test.toml index 03d92c2..b2880ef 100644 --- a/tests/cases/auto-review-unfixable/test.toml +++ b/tests/cases/auto-review-unfixable/test.toml @@ -1,7 +1,6 @@ +[expected] args = "--full --auto shellcheck" exit = 1 - -[expected] stderr = """ [shellcheck] @@ -15,4 +14,4 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: review: shellcheck -""" \ No newline at end of file +""" diff --git a/tests/cases/cargo-fmt-failure/test.toml b/tests/cases/cargo-fmt-failure/test.toml index 794710c..bad6282 100644 --- a/tests/cases/cargo-fmt-failure/test.toml +++ b/tests/cases/cargo-fmt-failure/test.toml @@ -1,7 +1,6 @@ +[expected] args = "--full cargo-fmt" exit = 1 - -[expected] stderr = """ [cargo-fmt] Diff in /src/lib.rs:1: @@ -14,4 +13,4 @@ Diff in /src/lib.rs:1: flint: 1 check failed (cargo-fmt) šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. -""" \ No newline at end of file +""" diff --git a/tests/cases/env-var-exclude/test.toml b/tests/cases/env-var-exclude/test.toml index b7dc52a..6fd5378 100644 --- a/tests/cases/env-var-exclude/test.toml +++ b/tests/cases/env-var-exclude/test.toml @@ -1,3 +1,4 @@ +[expected] args = "--full shellcheck" exit = 0 diff --git a/tests/cases/exclude-paths/test.toml b/tests/cases/exclude-paths/test.toml index 0411110..a4f669e 100644 --- a/tests/cases/exclude-paths/test.toml +++ b/tests/cases/exclude-paths/test.toml @@ -1,2 +1,3 @@ +[expected] args = "--full shellcheck" exit = 0 diff --git a/tests/cases/renovate-deps-fix-create/test.toml b/tests/cases/renovate-deps-fix-create/test.toml index d83db8b..cb7d93a 100644 --- a/tests/cases/renovate-deps-fix-create/test.toml +++ b/tests/cases/renovate-deps-fix-create/test.toml @@ -1,12 +1,7 @@ +[expected] args = "--full --fix renovate-deps" exit = 0 -[fake_bins] -renovate = ''' -#!/bin/sh -printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' -''' - [expected.files] ".github/renovate-tracked-deps.json" = """ { @@ -17,4 +12,10 @@ printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile ] } } -""" \ No newline at end of file +""" + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' diff --git a/tests/cases/renovate-deps-fix-update/test.toml b/tests/cases/renovate-deps-fix-update/test.toml index d83db8b..cb7d93a 100644 --- a/tests/cases/renovate-deps-fix-update/test.toml +++ b/tests/cases/renovate-deps-fix-update/test.toml @@ -1,12 +1,7 @@ +[expected] args = "--full --fix renovate-deps" exit = 0 -[fake_bins] -renovate = ''' -#!/bin/sh -printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' -''' - [expected.files] ".github/renovate-tracked-deps.json" = """ { @@ -17,4 +12,10 @@ printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile ] } } -""" \ No newline at end of file +""" + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' diff --git a/tests/cases/renovate-deps-out-of-date/test.toml b/tests/cases/renovate-deps-out-of-date/test.toml index 71eddec..c1ed343 100644 --- a/tests/cases/renovate-deps-out-of-date/test.toml +++ b/tests/cases/renovate-deps-out-of-date/test.toml @@ -1,7 +1,6 @@ +[expected] args = "--full renovate-deps" exit = 1 - -[expected] stderr = """ [renovate-deps] --- .github/renovate-tracked-deps.json @@ -23,6 +22,17 @@ flint: 1 check failed (renovate-deps) šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. """ +[expected.files] +".github/renovate-tracked-deps.json" = """ +{ + "package.json": { + "npm": [ + "old-dep" + ] + } +} +""" + [fake_bins] renovate = ''' #!/bin/sh diff --git a/tests/cases/renovate-deps-up-to-date/test.toml b/tests/cases/renovate-deps-up-to-date/test.toml index 7319c09..9e83cb6 100644 --- a/tests/cases/renovate-deps-up-to-date/test.toml +++ b/tests/cases/renovate-deps-up-to-date/test.toml @@ -1,3 +1,4 @@ +[expected] args = "--full renovate-deps" exit = 0 diff --git a/tests/cases/shellcheck-clean/test.toml b/tests/cases/shellcheck-clean/test.toml index 0411110..a4f669e 100644 --- a/tests/cases/shellcheck-clean/test.toml +++ b/tests/cases/shellcheck-clean/test.toml @@ -1,2 +1,3 @@ +[expected] args = "--full shellcheck" exit = 0 diff --git a/tests/cases/shellcheck-config-dir/test.toml b/tests/cases/shellcheck-config-dir/test.toml index eb7d7bf..ac61d2c 100644 --- a/tests/cases/shellcheck-config-dir/test.toml +++ b/tests/cases/shellcheck-config-dir/test.toml @@ -1,3 +1,4 @@ +[expected] args = "--full shellcheck" exit = 0 diff --git a/tests/cases/shellcheck-failure/test.toml b/tests/cases/shellcheck-failure/test.toml index ed83dc5..94ef071 100644 --- a/tests/cases/shellcheck-failure/test.toml +++ b/tests/cases/shellcheck-failure/test.toml @@ -1,7 +1,6 @@ +[expected] args = "--full shellcheck" exit = 1 - -[expected] stderr = """ [shellcheck] @@ -17,4 +16,4 @@ For more information: flint: 1 check failed (shellcheck) šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. -""" \ No newline at end of file +""" diff --git a/tests/e2e.rs b/tests/e2e.rs index 1210f81..2a121d7 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -43,20 +43,19 @@ fn git_repo() -> TempDir { /// test.toml — args, expected exit code, and golden output /// /// test.toml format: -/// args = "--full --auto shellcheck" -/// exit = 1 # optional, default 0 +/// [expected] +/// args = "--full --auto shellcheck" +/// exit = 1 # optional, default 0 +/// stderr = """...""" # optional, default "" +/// stdout = """...""" # optional, default "" /// -/// [expected] # optional golden output -/// stderr = """...""" -/// stdout = """...""" -/// -/// [expected.files] # optional file contents asserted after run +/// [expected.files] # optional file contents asserted after run /// ".github/renovate-tracked-deps.json" = """...""" /// -/// [env] # optional extra env vars +/// [env] # optional extra env vars /// KEY = "value" /// -/// [fake_bins] # optional fake binaries (Unix only) +/// [fake_bins] # optional fake binaries (Unix only) /// renovate = ''' /// #!/bin/sh /// echo '...' @@ -89,11 +88,17 @@ fn run_case(case: &Path, name: &str, update: bool) { let cfg: toml::Value = toml::from_str(&raw).unwrap_or_else(|e| panic!("{name}: invalid test.toml: {e}")); - let args_str = cfg["args"] + let expected = cfg + .get("expected") + .unwrap_or_else(|| panic!("{name}: missing [expected] table")); + let args_str = expected["args"] .as_str() - .unwrap_or_else(|| panic!("{name}: missing args")); + .unwrap_or_else(|| panic!("{name}: missing expected.args")); let args: Vec<&str> = args_str.split_whitespace().collect(); - let expected_exit = cfg.get("exit").and_then(|v| v.as_integer()).unwrap_or(0) as i32; + let expected_exit = expected + .get("exit") + .and_then(|v| v.as_integer()) + .unwrap_or(0) as i32; let repo = git_repo(); @@ -148,13 +153,12 @@ fn run_case(case: &Path, name: &str, update: bool) { return; } - let expected = cfg.get("expected"); let exp_stderr = expected - .and_then(|v| v.get("stderr")) + .get("stderr") .and_then(|v| v.as_str()) .unwrap_or(""); let exp_stdout = expected - .and_then(|v| v.get("stdout")) + .get("stdout") .and_then(|v| v.as_str()) .unwrap_or(""); assert_eq!(stderr, exp_stderr, "{name}: stderr mismatch"); @@ -166,10 +170,7 @@ fn run_case(case: &Path, name: &str, update: bool) { ); // Assert file contents written by flint (e.g. fix mode snapshots). - if let Some(files) = expected - .and_then(|v| v.get("files")) - .and_then(|v| v.as_table()) - { + if let Some(files) = expected.get("files").and_then(|v| v.as_table()) { for (rel_path, exp) in files { let exp = exp .as_str() @@ -181,34 +182,29 @@ fn run_case(case: &Path, name: &str, update: bool) { } } -/// Rewrites test.toml updating snapshot fields (exit, [expected].stderr/stdout) +/// Rewrites test.toml updating snapshot fields ([expected].exit/stderr/stdout) /// while preserving everything else (args, env, fake_bins, expected.files). fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdout: &str) { - let args_str = cfg["args"].as_str().unwrap_or(""); - let mut out = format!("args = \"{}\"\n", args_str.replace('"', "\\\"")); - out += &format!("exit = {exit}\n"); - - // [expected] — stderr/stdout updated; files preserved unchanged. - let has_stderr = !stderr.is_empty(); - let has_stdout = !stdout.is_empty(); + let args_str = cfg["expected"]["args"].as_str().unwrap_or(""); let existing_files = cfg .get("expected") .and_then(|v| v.get("files")) .and_then(|v| v.as_table()); - if has_stderr || has_stdout || existing_files.is_some() { - out += "\n[expected]\n"; - if has_stderr { - out += &format!("stderr = \"\"\"\n{stderr}\"\"\""); - } - if has_stdout { - out += &format!("stdout = \"\"\"\n{stdout}\"\"\""); - } - if let Some(files) = existing_files { - out += "\n\n[expected.files]\n"; - for (k, v) in files { - if let Some(s) = v.as_str() { - out += &format!("\"{k}\" = \"\"\"\n{s}\"\"\""); - } + + let mut out = String::from("[expected]\n"); + out += &format!("args = \"{}\"\n", args_str.replace('"', "\\\"")); + out += &format!("exit = {exit}\n"); + if !stderr.is_empty() { + out += &format!("stderr = \"\"\"\n{stderr}\"\"\""); + } + if !stdout.is_empty() { + out += &format!("stdout = \"\"\"\n{stdout}\"\"\""); + } + if let Some(files) = existing_files { + out += "\n\n[expected.files]\n"; + for (k, v) in files { + if let Some(s) = v.as_str() { + out += &format!("\"{k}\" = \"\"\"\n{s}\"\"\""); } } } From 29b515929b861011aa3783af4eb5ce5bf9933890 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 10:04:42 +0000 Subject: [PATCH 047/141] refactor: reorganize test cases into subdirectories by linter Group fixture cases under shellcheck/, cargo-fmt/, renovate-deps/, and general/ instead of a flat list with hyphen-prefixed names. The case runner now recursively finds all dirs containing test.toml, using the relative path as the case name (e.g. shellcheck/failure). Also fix write_test_toml to properly escape backslashes in [env] and [expected].args values (was producing invalid TOML for patterns like "bad\\.sh"). Signed-off-by: Gregor Zeitlinger --- .../auto-fix}/files/Cargo.toml | 0 .../auto-fix}/files/mise.toml | 0 .../auto-fix}/files/src/lib.rs | 0 .../auto-fix}/test.toml | 2 +- .../failure}/files/Cargo.toml | 0 .../failure}/files/mise.toml | 0 .../failure}/files/src/lib.rs | 0 .../failure}/test.toml | 2 +- .../auto-fix-and-review}/files/Cargo.toml | 0 .../auto-fix-and-review/files/bad.sh | 0 .../auto-fix-and-review/files/mise.toml | 0 .../auto-fix-and-review}/files/src/lib.rs | 0 .../auto-fix-and-review/test.toml | 2 +- .../files/.github/workflows/ci.yml | 0 .../auto-review-two-linters/files/bad.sh | 0 .../auto-review-two-linters/files/mise.toml | 0 .../auto-review-two-linters/test.toml | 2 +- .../auto-review-unfixable/files/bad.sh | 0 .../auto-review-unfixable/files/mise.toml | 0 .../auto-review-unfixable/test.toml | 2 +- .../env-var-exclude/files/bad.sh | 0 .../env-var-exclude/files/mise.toml | 0 .../{ => general}/env-var-exclude/test.toml | 1 + .../exclude-paths/files/excluded/bad.sh | 0 .../exclude-paths/files/flint.toml | 0 .../exclude-paths/files/mise.toml | 0 .../{ => general}/exclude-paths/test.toml | 0 .../fix-create}/files/.github/renovate.json5 | 0 .../fix-create}/files/mise.toml | 0 .../fix-create}/files/package.json | 0 .../fix-create}/test.toml | 2 +- .../files/.github/renovate-tracked-deps.json | 0 .../fix-update}/files/.github/renovate.json5 | 0 .../fix-update}/files/mise.toml | 0 .../fix-update}/files/package.json | 0 .../fix-update}/test.toml | 2 +- .../files/.github/renovate-tracked-deps.json | 0 .../out-of-date}/files/.github/renovate.json5 | 0 .../out-of-date}/files/mise.toml | 0 .../out-of-date}/files/package.json | 0 .../out-of-date}/test.toml | 1 - .../files/.github/renovate-tracked-deps.json | 0 .../up-to-date}/files/.github/renovate.json5 | 0 .../up-to-date}/files/mise.toml | 0 .../up-to-date}/files/package.json | 0 .../up-to-date}/test.toml | 0 .../clean}/files/good.sh | 0 .../clean}/files/mise.toml | 0 .../clean}/test.toml | 0 .../config-dir}/files/bad.sh | 0 .../config-dir}/files/config/.shellcheckrc | 0 .../config-dir}/files/mise.toml | 0 .../config-dir}/test.toml | 1 + .../failure}/files/bad.sh | 0 .../failure}/files/mise.toml | 0 .../failure}/test.toml | 2 +- tests/e2e.rs | 47 ++++++++++++++----- 57 files changed, 45 insertions(+), 21 deletions(-) rename tests/cases/{auto-fix-and-review => cargo-fmt/auto-fix}/files/Cargo.toml (100%) rename tests/cases/{auto-fix-cargo-fmt => cargo-fmt/auto-fix}/files/mise.toml (100%) rename tests/cases/{auto-fix-and-review => cargo-fmt/auto-fix}/files/src/lib.rs (100%) rename tests/cases/{auto-fix-cargo-fmt => cargo-fmt/auto-fix}/test.toml (96%) rename tests/cases/{auto-fix-cargo-fmt => cargo-fmt/failure}/files/Cargo.toml (100%) rename tests/cases/{cargo-fmt-failure => cargo-fmt/failure}/files/mise.toml (100%) rename tests/cases/{auto-fix-cargo-fmt => cargo-fmt/failure}/files/src/lib.rs (100%) rename tests/cases/{cargo-fmt-failure => cargo-fmt/failure}/test.toml (98%) rename tests/cases/{cargo-fmt-failure => general/auto-fix-and-review}/files/Cargo.toml (100%) rename tests/cases/{ => general}/auto-fix-and-review/files/bad.sh (100%) rename tests/cases/{ => general}/auto-fix-and-review/files/mise.toml (100%) rename tests/cases/{cargo-fmt-failure => general/auto-fix-and-review}/files/src/lib.rs (100%) rename tests/cases/{ => general}/auto-fix-and-review/test.toml (99%) rename tests/cases/{ => general}/auto-review-two-linters/files/.github/workflows/ci.yml (100%) rename tests/cases/{ => general}/auto-review-two-linters/files/bad.sh (100%) rename tests/cases/{ => general}/auto-review-two-linters/files/mise.toml (100%) rename tests/cases/{ => general}/auto-review-two-linters/test.toml (99%) rename tests/cases/{ => general}/auto-review-unfixable/files/bad.sh (100%) rename tests/cases/{ => general}/auto-review-unfixable/files/mise.toml (100%) rename tests/cases/{ => general}/auto-review-unfixable/test.toml (98%) rename tests/cases/{ => general}/env-var-exclude/files/bad.sh (100%) rename tests/cases/{ => general}/env-var-exclude/files/mise.toml (100%) rename tests/cases/{ => general}/env-var-exclude/test.toml (98%) rename tests/cases/{ => general}/exclude-paths/files/excluded/bad.sh (100%) rename tests/cases/{ => general}/exclude-paths/files/flint.toml (100%) rename tests/cases/{ => general}/exclude-paths/files/mise.toml (100%) rename tests/cases/{ => general}/exclude-paths/test.toml (100%) rename tests/cases/{renovate-deps-fix-create => renovate-deps/fix-create}/files/.github/renovate.json5 (100%) rename tests/cases/{renovate-deps-fix-create => renovate-deps/fix-create}/files/mise.toml (100%) rename tests/cases/{renovate-deps-fix-create => renovate-deps/fix-create}/files/package.json (100%) rename tests/cases/{renovate-deps-fix-update => renovate-deps/fix-create}/test.toml (100%) rename tests/cases/{renovate-deps-fix-update => renovate-deps/fix-update}/files/.github/renovate-tracked-deps.json (100%) rename tests/cases/{renovate-deps-fix-update => renovate-deps/fix-update}/files/.github/renovate.json5 (100%) rename tests/cases/{renovate-deps-fix-update => renovate-deps/fix-update}/files/mise.toml (100%) rename tests/cases/{renovate-deps-fix-update => renovate-deps/fix-update}/files/package.json (100%) rename tests/cases/{renovate-deps-fix-create => renovate-deps/fix-update}/test.toml (100%) rename tests/cases/{renovate-deps-out-of-date => renovate-deps/out-of-date}/files/.github/renovate-tracked-deps.json (100%) rename tests/cases/{renovate-deps-out-of-date => renovate-deps/out-of-date}/files/.github/renovate.json5 (100%) rename tests/cases/{renovate-deps-out-of-date => renovate-deps/out-of-date}/files/mise.toml (100%) rename tests/cases/{renovate-deps-out-of-date => renovate-deps/out-of-date}/files/package.json (100%) rename tests/cases/{renovate-deps-out-of-date => renovate-deps/out-of-date}/test.toml (99%) rename tests/cases/{renovate-deps-up-to-date => renovate-deps/up-to-date}/files/.github/renovate-tracked-deps.json (100%) rename tests/cases/{renovate-deps-up-to-date => renovate-deps/up-to-date}/files/.github/renovate.json5 (100%) rename tests/cases/{renovate-deps-up-to-date => renovate-deps/up-to-date}/files/mise.toml (100%) rename tests/cases/{renovate-deps-up-to-date => renovate-deps/up-to-date}/files/package.json (100%) rename tests/cases/{renovate-deps-up-to-date => renovate-deps/up-to-date}/test.toml (100%) rename tests/cases/{shellcheck-clean => shellcheck/clean}/files/good.sh (100%) rename tests/cases/{shellcheck-clean => shellcheck/clean}/files/mise.toml (100%) rename tests/cases/{shellcheck-clean => shellcheck/clean}/test.toml (100%) rename tests/cases/{shellcheck-config-dir => shellcheck/config-dir}/files/bad.sh (100%) rename tests/cases/{shellcheck-config-dir => shellcheck/config-dir}/files/config/.shellcheckrc (100%) rename tests/cases/{shellcheck-config-dir => shellcheck/config-dir}/files/mise.toml (100%) rename tests/cases/{shellcheck-config-dir => shellcheck/config-dir}/test.toml (98%) rename tests/cases/{shellcheck-failure => shellcheck/failure}/files/bad.sh (100%) rename tests/cases/{shellcheck-failure => shellcheck/failure}/files/mise.toml (100%) rename tests/cases/{shellcheck-failure => shellcheck/failure}/test.toml (99%) diff --git a/tests/cases/auto-fix-and-review/files/Cargo.toml b/tests/cases/cargo-fmt/auto-fix/files/Cargo.toml similarity index 100% rename from tests/cases/auto-fix-and-review/files/Cargo.toml rename to tests/cases/cargo-fmt/auto-fix/files/Cargo.toml diff --git a/tests/cases/auto-fix-cargo-fmt/files/mise.toml b/tests/cases/cargo-fmt/auto-fix/files/mise.toml similarity index 100% rename from tests/cases/auto-fix-cargo-fmt/files/mise.toml rename to tests/cases/cargo-fmt/auto-fix/files/mise.toml diff --git a/tests/cases/auto-fix-and-review/files/src/lib.rs b/tests/cases/cargo-fmt/auto-fix/files/src/lib.rs similarity index 100% rename from tests/cases/auto-fix-and-review/files/src/lib.rs rename to tests/cases/cargo-fmt/auto-fix/files/src/lib.rs diff --git a/tests/cases/auto-fix-cargo-fmt/test.toml b/tests/cases/cargo-fmt/auto-fix/test.toml similarity index 96% rename from tests/cases/auto-fix-cargo-fmt/test.toml rename to tests/cases/cargo-fmt/auto-fix/test.toml index 72570a3..a81be73 100644 --- a/tests/cases/auto-fix-cargo-fmt/test.toml +++ b/tests/cases/cargo-fmt/auto-fix/test.toml @@ -3,4 +3,4 @@ args = "--full --auto cargo-fmt" exit = 1 stderr = """ flint: fixed: cargo-fmt — commit before pushing -""" +""" \ No newline at end of file diff --git a/tests/cases/auto-fix-cargo-fmt/files/Cargo.toml b/tests/cases/cargo-fmt/failure/files/Cargo.toml similarity index 100% rename from tests/cases/auto-fix-cargo-fmt/files/Cargo.toml rename to tests/cases/cargo-fmt/failure/files/Cargo.toml diff --git a/tests/cases/cargo-fmt-failure/files/mise.toml b/tests/cases/cargo-fmt/failure/files/mise.toml similarity index 100% rename from tests/cases/cargo-fmt-failure/files/mise.toml rename to tests/cases/cargo-fmt/failure/files/mise.toml diff --git a/tests/cases/auto-fix-cargo-fmt/files/src/lib.rs b/tests/cases/cargo-fmt/failure/files/src/lib.rs similarity index 100% rename from tests/cases/auto-fix-cargo-fmt/files/src/lib.rs rename to tests/cases/cargo-fmt/failure/files/src/lib.rs diff --git a/tests/cases/cargo-fmt-failure/test.toml b/tests/cases/cargo-fmt/failure/test.toml similarity index 98% rename from tests/cases/cargo-fmt-failure/test.toml rename to tests/cases/cargo-fmt/failure/test.toml index bad6282..ea728a3 100644 --- a/tests/cases/cargo-fmt-failure/test.toml +++ b/tests/cases/cargo-fmt/failure/test.toml @@ -13,4 +13,4 @@ Diff in /src/lib.rs:1: flint: 1 check failed (cargo-fmt) šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. -""" +""" \ No newline at end of file diff --git a/tests/cases/cargo-fmt-failure/files/Cargo.toml b/tests/cases/general/auto-fix-and-review/files/Cargo.toml similarity index 100% rename from tests/cases/cargo-fmt-failure/files/Cargo.toml rename to tests/cases/general/auto-fix-and-review/files/Cargo.toml diff --git a/tests/cases/auto-fix-and-review/files/bad.sh b/tests/cases/general/auto-fix-and-review/files/bad.sh similarity index 100% rename from tests/cases/auto-fix-and-review/files/bad.sh rename to tests/cases/general/auto-fix-and-review/files/bad.sh diff --git a/tests/cases/auto-fix-and-review/files/mise.toml b/tests/cases/general/auto-fix-and-review/files/mise.toml similarity index 100% rename from tests/cases/auto-fix-and-review/files/mise.toml rename to tests/cases/general/auto-fix-and-review/files/mise.toml diff --git a/tests/cases/cargo-fmt-failure/files/src/lib.rs b/tests/cases/general/auto-fix-and-review/files/src/lib.rs similarity index 100% rename from tests/cases/cargo-fmt-failure/files/src/lib.rs rename to tests/cases/general/auto-fix-and-review/files/src/lib.rs diff --git a/tests/cases/auto-fix-and-review/test.toml b/tests/cases/general/auto-fix-and-review/test.toml similarity index 99% rename from tests/cases/auto-fix-and-review/test.toml rename to tests/cases/general/auto-fix-and-review/test.toml index becb1e3..9de0dc0 100644 --- a/tests/cases/auto-fix-and-review/test.toml +++ b/tests/cases/general/auto-fix-and-review/test.toml @@ -14,4 +14,4 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: fixed: cargo-fmt — commit before pushing | review: shellcheck -""" +""" \ No newline at end of file diff --git a/tests/cases/auto-review-two-linters/files/.github/workflows/ci.yml b/tests/cases/general/auto-review-two-linters/files/.github/workflows/ci.yml similarity index 100% rename from tests/cases/auto-review-two-linters/files/.github/workflows/ci.yml rename to tests/cases/general/auto-review-two-linters/files/.github/workflows/ci.yml diff --git a/tests/cases/auto-review-two-linters/files/bad.sh b/tests/cases/general/auto-review-two-linters/files/bad.sh similarity index 100% rename from tests/cases/auto-review-two-linters/files/bad.sh rename to tests/cases/general/auto-review-two-linters/files/bad.sh diff --git a/tests/cases/auto-review-two-linters/files/mise.toml b/tests/cases/general/auto-review-two-linters/files/mise.toml similarity index 100% rename from tests/cases/auto-review-two-linters/files/mise.toml rename to tests/cases/general/auto-review-two-linters/files/mise.toml diff --git a/tests/cases/auto-review-two-linters/test.toml b/tests/cases/general/auto-review-two-linters/test.toml similarity index 99% rename from tests/cases/auto-review-two-linters/test.toml rename to tests/cases/general/auto-review-two-linters/test.toml index 4160b34..d22ab04 100644 --- a/tests/cases/auto-review-two-linters/test.toml +++ b/tests/cases/general/auto-review-two-linters/test.toml @@ -19,4 +19,4 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: review: actionlint, shellcheck -""" +""" \ No newline at end of file diff --git a/tests/cases/auto-review-unfixable/files/bad.sh b/tests/cases/general/auto-review-unfixable/files/bad.sh similarity index 100% rename from tests/cases/auto-review-unfixable/files/bad.sh rename to tests/cases/general/auto-review-unfixable/files/bad.sh diff --git a/tests/cases/auto-review-unfixable/files/mise.toml b/tests/cases/general/auto-review-unfixable/files/mise.toml similarity index 100% rename from tests/cases/auto-review-unfixable/files/mise.toml rename to tests/cases/general/auto-review-unfixable/files/mise.toml diff --git a/tests/cases/auto-review-unfixable/test.toml b/tests/cases/general/auto-review-unfixable/test.toml similarity index 98% rename from tests/cases/auto-review-unfixable/test.toml rename to tests/cases/general/auto-review-unfixable/test.toml index b2880ef..1347965 100644 --- a/tests/cases/auto-review-unfixable/test.toml +++ b/tests/cases/general/auto-review-unfixable/test.toml @@ -14,4 +14,4 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: review: shellcheck -""" +""" \ No newline at end of file diff --git a/tests/cases/env-var-exclude/files/bad.sh b/tests/cases/general/env-var-exclude/files/bad.sh similarity index 100% rename from tests/cases/env-var-exclude/files/bad.sh rename to tests/cases/general/env-var-exclude/files/bad.sh diff --git a/tests/cases/env-var-exclude/files/mise.toml b/tests/cases/general/env-var-exclude/files/mise.toml similarity index 100% rename from tests/cases/env-var-exclude/files/mise.toml rename to tests/cases/general/env-var-exclude/files/mise.toml diff --git a/tests/cases/env-var-exclude/test.toml b/tests/cases/general/env-var-exclude/test.toml similarity index 98% rename from tests/cases/env-var-exclude/test.toml rename to tests/cases/general/env-var-exclude/test.toml index 6fd5378..156f63c 100644 --- a/tests/cases/env-var-exclude/test.toml +++ b/tests/cases/general/env-var-exclude/test.toml @@ -2,5 +2,6 @@ args = "--full shellcheck" exit = 0 + [env] FLINT_EXCLUDE = "bad\\.sh" diff --git a/tests/cases/exclude-paths/files/excluded/bad.sh b/tests/cases/general/exclude-paths/files/excluded/bad.sh similarity index 100% rename from tests/cases/exclude-paths/files/excluded/bad.sh rename to tests/cases/general/exclude-paths/files/excluded/bad.sh diff --git a/tests/cases/exclude-paths/files/flint.toml b/tests/cases/general/exclude-paths/files/flint.toml similarity index 100% rename from tests/cases/exclude-paths/files/flint.toml rename to tests/cases/general/exclude-paths/files/flint.toml diff --git a/tests/cases/exclude-paths/files/mise.toml b/tests/cases/general/exclude-paths/files/mise.toml similarity index 100% rename from tests/cases/exclude-paths/files/mise.toml rename to tests/cases/general/exclude-paths/files/mise.toml diff --git a/tests/cases/exclude-paths/test.toml b/tests/cases/general/exclude-paths/test.toml similarity index 100% rename from tests/cases/exclude-paths/test.toml rename to tests/cases/general/exclude-paths/test.toml diff --git a/tests/cases/renovate-deps-fix-create/files/.github/renovate.json5 b/tests/cases/renovate-deps/fix-create/files/.github/renovate.json5 similarity index 100% rename from tests/cases/renovate-deps-fix-create/files/.github/renovate.json5 rename to tests/cases/renovate-deps/fix-create/files/.github/renovate.json5 diff --git a/tests/cases/renovate-deps-fix-create/files/mise.toml b/tests/cases/renovate-deps/fix-create/files/mise.toml similarity index 100% rename from tests/cases/renovate-deps-fix-create/files/mise.toml rename to tests/cases/renovate-deps/fix-create/files/mise.toml diff --git a/tests/cases/renovate-deps-fix-create/files/package.json b/tests/cases/renovate-deps/fix-create/files/package.json similarity index 100% rename from tests/cases/renovate-deps-fix-create/files/package.json rename to tests/cases/renovate-deps/fix-create/files/package.json diff --git a/tests/cases/renovate-deps-fix-update/test.toml b/tests/cases/renovate-deps/fix-create/test.toml similarity index 100% rename from tests/cases/renovate-deps-fix-update/test.toml rename to tests/cases/renovate-deps/fix-create/test.toml index cb7d93a..57e9cd5 100644 --- a/tests/cases/renovate-deps-fix-update/test.toml +++ b/tests/cases/renovate-deps/fix-create/test.toml @@ -2,6 +2,7 @@ args = "--full --fix renovate-deps" exit = 0 + [expected.files] ".github/renovate-tracked-deps.json" = """ { @@ -13,7 +14,6 @@ exit = 0 } } """ - [fake_bins] renovate = ''' #!/bin/sh diff --git a/tests/cases/renovate-deps-fix-update/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/fix-update/files/.github/renovate-tracked-deps.json similarity index 100% rename from tests/cases/renovate-deps-fix-update/files/.github/renovate-tracked-deps.json rename to tests/cases/renovate-deps/fix-update/files/.github/renovate-tracked-deps.json diff --git a/tests/cases/renovate-deps-fix-update/files/.github/renovate.json5 b/tests/cases/renovate-deps/fix-update/files/.github/renovate.json5 similarity index 100% rename from tests/cases/renovate-deps-fix-update/files/.github/renovate.json5 rename to tests/cases/renovate-deps/fix-update/files/.github/renovate.json5 diff --git a/tests/cases/renovate-deps-fix-update/files/mise.toml b/tests/cases/renovate-deps/fix-update/files/mise.toml similarity index 100% rename from tests/cases/renovate-deps-fix-update/files/mise.toml rename to tests/cases/renovate-deps/fix-update/files/mise.toml diff --git a/tests/cases/renovate-deps-fix-update/files/package.json b/tests/cases/renovate-deps/fix-update/files/package.json similarity index 100% rename from tests/cases/renovate-deps-fix-update/files/package.json rename to tests/cases/renovate-deps/fix-update/files/package.json diff --git a/tests/cases/renovate-deps-fix-create/test.toml b/tests/cases/renovate-deps/fix-update/test.toml similarity index 100% rename from tests/cases/renovate-deps-fix-create/test.toml rename to tests/cases/renovate-deps/fix-update/test.toml index cb7d93a..57e9cd5 100644 --- a/tests/cases/renovate-deps-fix-create/test.toml +++ b/tests/cases/renovate-deps/fix-update/test.toml @@ -2,6 +2,7 @@ args = "--full --fix renovate-deps" exit = 0 + [expected.files] ".github/renovate-tracked-deps.json" = """ { @@ -13,7 +14,6 @@ exit = 0 } } """ - [fake_bins] renovate = ''' #!/bin/sh diff --git a/tests/cases/renovate-deps-out-of-date/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/out-of-date/files/.github/renovate-tracked-deps.json similarity index 100% rename from tests/cases/renovate-deps-out-of-date/files/.github/renovate-tracked-deps.json rename to tests/cases/renovate-deps/out-of-date/files/.github/renovate-tracked-deps.json diff --git a/tests/cases/renovate-deps-out-of-date/files/.github/renovate.json5 b/tests/cases/renovate-deps/out-of-date/files/.github/renovate.json5 similarity index 100% rename from tests/cases/renovate-deps-out-of-date/files/.github/renovate.json5 rename to tests/cases/renovate-deps/out-of-date/files/.github/renovate.json5 diff --git a/tests/cases/renovate-deps-out-of-date/files/mise.toml b/tests/cases/renovate-deps/out-of-date/files/mise.toml similarity index 100% rename from tests/cases/renovate-deps-out-of-date/files/mise.toml rename to tests/cases/renovate-deps/out-of-date/files/mise.toml diff --git a/tests/cases/renovate-deps-out-of-date/files/package.json b/tests/cases/renovate-deps/out-of-date/files/package.json similarity index 100% rename from tests/cases/renovate-deps-out-of-date/files/package.json rename to tests/cases/renovate-deps/out-of-date/files/package.json diff --git a/tests/cases/renovate-deps-out-of-date/test.toml b/tests/cases/renovate-deps/out-of-date/test.toml similarity index 99% rename from tests/cases/renovate-deps-out-of-date/test.toml rename to tests/cases/renovate-deps/out-of-date/test.toml index c1ed343..430b1fd 100644 --- a/tests/cases/renovate-deps-out-of-date/test.toml +++ b/tests/cases/renovate-deps/out-of-date/test.toml @@ -32,7 +32,6 @@ flint: 1 check failed (renovate-deps) } } """ - [fake_bins] renovate = ''' #!/bin/sh diff --git a/tests/cases/renovate-deps-up-to-date/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json similarity index 100% rename from tests/cases/renovate-deps-up-to-date/files/.github/renovate-tracked-deps.json rename to tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json diff --git a/tests/cases/renovate-deps-up-to-date/files/.github/renovate.json5 b/tests/cases/renovate-deps/up-to-date/files/.github/renovate.json5 similarity index 100% rename from tests/cases/renovate-deps-up-to-date/files/.github/renovate.json5 rename to tests/cases/renovate-deps/up-to-date/files/.github/renovate.json5 diff --git a/tests/cases/renovate-deps-up-to-date/files/mise.toml b/tests/cases/renovate-deps/up-to-date/files/mise.toml similarity index 100% rename from tests/cases/renovate-deps-up-to-date/files/mise.toml rename to tests/cases/renovate-deps/up-to-date/files/mise.toml diff --git a/tests/cases/renovate-deps-up-to-date/files/package.json b/tests/cases/renovate-deps/up-to-date/files/package.json similarity index 100% rename from tests/cases/renovate-deps-up-to-date/files/package.json rename to tests/cases/renovate-deps/up-to-date/files/package.json diff --git a/tests/cases/renovate-deps-up-to-date/test.toml b/tests/cases/renovate-deps/up-to-date/test.toml similarity index 100% rename from tests/cases/renovate-deps-up-to-date/test.toml rename to tests/cases/renovate-deps/up-to-date/test.toml diff --git a/tests/cases/shellcheck-clean/files/good.sh b/tests/cases/shellcheck/clean/files/good.sh similarity index 100% rename from tests/cases/shellcheck-clean/files/good.sh rename to tests/cases/shellcheck/clean/files/good.sh diff --git a/tests/cases/shellcheck-clean/files/mise.toml b/tests/cases/shellcheck/clean/files/mise.toml similarity index 100% rename from tests/cases/shellcheck-clean/files/mise.toml rename to tests/cases/shellcheck/clean/files/mise.toml diff --git a/tests/cases/shellcheck-clean/test.toml b/tests/cases/shellcheck/clean/test.toml similarity index 100% rename from tests/cases/shellcheck-clean/test.toml rename to tests/cases/shellcheck/clean/test.toml diff --git a/tests/cases/shellcheck-config-dir/files/bad.sh b/tests/cases/shellcheck/config-dir/files/bad.sh similarity index 100% rename from tests/cases/shellcheck-config-dir/files/bad.sh rename to tests/cases/shellcheck/config-dir/files/bad.sh diff --git a/tests/cases/shellcheck-config-dir/files/config/.shellcheckrc b/tests/cases/shellcheck/config-dir/files/config/.shellcheckrc similarity index 100% rename from tests/cases/shellcheck-config-dir/files/config/.shellcheckrc rename to tests/cases/shellcheck/config-dir/files/config/.shellcheckrc diff --git a/tests/cases/shellcheck-config-dir/files/mise.toml b/tests/cases/shellcheck/config-dir/files/mise.toml similarity index 100% rename from tests/cases/shellcheck-config-dir/files/mise.toml rename to tests/cases/shellcheck/config-dir/files/mise.toml diff --git a/tests/cases/shellcheck-config-dir/test.toml b/tests/cases/shellcheck/config-dir/test.toml similarity index 98% rename from tests/cases/shellcheck-config-dir/test.toml rename to tests/cases/shellcheck/config-dir/test.toml index ac61d2c..9457f57 100644 --- a/tests/cases/shellcheck-config-dir/test.toml +++ b/tests/cases/shellcheck/config-dir/test.toml @@ -2,5 +2,6 @@ args = "--full shellcheck" exit = 0 + [env] FLINT_CONFIG_DIR = "config" diff --git a/tests/cases/shellcheck-failure/files/bad.sh b/tests/cases/shellcheck/failure/files/bad.sh similarity index 100% rename from tests/cases/shellcheck-failure/files/bad.sh rename to tests/cases/shellcheck/failure/files/bad.sh diff --git a/tests/cases/shellcheck-failure/files/mise.toml b/tests/cases/shellcheck/failure/files/mise.toml similarity index 100% rename from tests/cases/shellcheck-failure/files/mise.toml rename to tests/cases/shellcheck/failure/files/mise.toml diff --git a/tests/cases/shellcheck-failure/test.toml b/tests/cases/shellcheck/failure/test.toml similarity index 99% rename from tests/cases/shellcheck-failure/test.toml rename to tests/cases/shellcheck/failure/test.toml index 94ef071..adb9924 100644 --- a/tests/cases/shellcheck-failure/test.toml +++ b/tests/cases/shellcheck/failure/test.toml @@ -16,4 +16,4 @@ For more information: flint: 1 check failed (shellcheck) šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. -""" +""" \ No newline at end of file diff --git a/tests/e2e.rs b/tests/e2e.rs index 2a121d7..93d61c8 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use tempfile::TempDir; @@ -67,20 +67,38 @@ fn cases() { let cases_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); let update = std::env::var("UPDATE_SNAPSHOTS").is_ok(); - let mut entries: Vec<_> = std::fs::read_dir(&cases_dir) - .expect("tests/cases/ not found") - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .collect(); - entries.sort_by_key(|e| e.file_name()); + let mut case_paths = collect_cases(&cases_dir); + case_paths.sort(); - for entry in entries { - let case = entry.path(); - let name = case.file_name().unwrap().to_string_lossy().into_owned(); + for case in &case_paths { + let name = case + .strip_prefix(&cases_dir) + .unwrap() + .to_string_lossy() + .into_owned(); run_case(&case, &name, update); } } +/// Recursively finds all directories containing a `test.toml` file. +fn collect_cases(dir: &Path) -> Vec { + let mut cases = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return cases; + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + if path.join("test.toml").exists() { + cases.push(path); + } else { + cases.extend(collect_cases(&path)); + } + } + } + cases +} + fn run_case(case: &Path, name: &str, update: bool) { let toml_path = case.join("test.toml"); let raw = @@ -192,7 +210,7 @@ fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdo .and_then(|v| v.as_table()); let mut out = String::from("[expected]\n"); - out += &format!("args = \"{}\"\n", args_str.replace('"', "\\\"")); + out += &format!("args = \"{}\"\n", toml_escape(args_str)); out += &format!("exit = {exit}\n"); if !stderr.is_empty() { out += &format!("stderr = \"\"\"\n{stderr}\"\"\""); @@ -214,7 +232,7 @@ fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdo out += "\n\n[env]\n"; for (k, v) in env { if let Some(s) = v.as_str() { - out += &format!("{k} = \"{}\"\n", s.replace('"', "\\\"")); + out += &format!("{k} = \"{}\"\n", toml_escape(s)); } } } @@ -236,6 +254,11 @@ fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdo std::fs::write(path, out).unwrap(); } +/// Escapes a string for use inside TOML basic double-quoted strings. +fn toml_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + /// Strips ANSI escape sequences (e.g. colour codes from cargo fmt diffs). /// TOML strings cannot contain raw control characters, so these must be removed. fn strip_ansi(s: &str) -> String { From e5941ea65895ca20a55542a86bca1dd424e1e356 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 10:05:46 +0000 Subject: [PATCH 048/141] test: remove programmatic renovate-deps tests superseded by fixture cases Signed-off-by: Gregor Zeitlinger --- tests/e2e.rs | 152 --------------------------------------------------- 1 file changed, 152 deletions(-) diff --git a/tests/e2e.rs b/tests/e2e.rs index 93d61c8..bcd7751 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -280,158 +280,6 @@ fn strip_ansi(s: &str) -> String { out } -// ── renovate-deps tests ────────────────────────────────────────────────────── -// -// These tests inject a fake `renovate` binary via PATH so they don't need the -// real tool installed. Unix-only because the fake is a shell script. - -#[cfg(unix)] -mod renovate_deps { - use super::*; - use std::os::unix::fs::PermissionsExt; - - // The JSON log line the fake renovate emits. - const RENOVATE_LOG: &str = r#"{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}"#; - - // What write_snapshot produces for that log (serde_json::to_string_pretty + \n). - const SNAPSHOT: &str = "{\n \"package.json\": {\n \"npm\": [\n \"express\",\n \"lodash\"\n ]\n }\n}\n"; - - /// Creates a temp dir containing a fake `renovate` script that emits `log_line`. - fn fake_renovate_bin(log_line: &str) -> TempDir { - let dir = tempfile::tempdir().unwrap(); - let script = dir.path().join("renovate"); - std::fs::write( - &script, - format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", log_line), - ) - .unwrap(); - std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); - dir - } - - /// Sets up a minimal repo with renovate declared in mise.toml. - /// Optionally writes a snapshot file. - fn setup_repo(snapshot: Option<&str>) -> TempDir { - let repo = git_repo(); - std::fs::create_dir_all(repo.path().join(".github")).unwrap(); - std::fs::write( - repo.path().join("mise.toml"), - "[tools]\nrenovate = \"latest\"\n", - ) - .unwrap(); - std::fs::write(repo.path().join(".github").join("renovate.json5"), "{}").unwrap(); - std::fs::write(repo.path().join("package.json"), "{}").unwrap(); - if let Some(snap) = snapshot { - std::fs::write( - repo.path() - .join(".github") - .join("renovate-tracked-deps.json"), - snap, - ) - .unwrap(); - } - Command::new("git") - .args(["add", "-A"]) - .current_dir(repo.path()) - .output() - .unwrap(); - repo - } - - fn prepend_path(dir: &Path) -> String { - let orig = std::env::var("PATH").unwrap_or_default(); - format!("{}:{orig}", dir.display()) - } - - #[test] - fn up_to_date() { - let bin = fake_renovate_bin(RENOVATE_LOG); - let repo = setup_repo(Some(SNAPSHOT)); - let path = prepend_path(bin.path()); - let out = flint_with_env( - &["--full", "renovate-deps"], - repo.path(), - &[("PATH", &path)], - ); - assert_eq!( - out.status.code(), - Some(0), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - - #[test] - fn out_of_date() { - let stale = "{\n \"package.json\": {\n \"npm\": [\n \"old-dep\"\n ]\n }\n}\n"; - let bin = fake_renovate_bin(RENOVATE_LOG); - let repo = setup_repo(Some(stale)); - let path = prepend_path(bin.path()); - let out = flint_with_env( - &["--full", "renovate-deps"], - repo.path(), - &[("PATH", &path)], - ); - assert_eq!(out.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("out of date"), "stderr: {stderr}"); - assert!( - stderr.contains("old-dep"), - "diff missing in stderr: {stderr}" - ); - } - - #[test] - fn fix_creates_snapshot() { - let bin = fake_renovate_bin(RENOVATE_LOG); - let repo = setup_repo(None); - let path = prepend_path(bin.path()); - let out = flint_with_env( - &["--full", "--fix", "renovate-deps"], - repo.path(), - &[("PATH", &path)], - ); - assert_eq!( - out.status.code(), - Some(0), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - let written = std::fs::read_to_string( - repo.path() - .join(".github") - .join("renovate-tracked-deps.json"), - ) - .unwrap(); - assert_eq!(written, SNAPSHOT); - } - - #[test] - fn fix_updates_stale_snapshot() { - let stale = "{\n \"package.json\": {\n \"npm\": [\n \"old-dep\"\n ]\n }\n}\n"; - let bin = fake_renovate_bin(RENOVATE_LOG); - let repo = setup_repo(Some(stale)); - let path = prepend_path(bin.path()); - let out = flint_with_env( - &["--full", "--fix", "renovate-deps"], - repo.path(), - &[("PATH", &path)], - ); - assert_eq!( - out.status.code(), - Some(0), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - let written = std::fs::read_to_string( - repo.path() - .join(".github") - .join("renovate-tracked-deps.json"), - ) - .unwrap(); - assert_eq!(written, SNAPSHOT); - } -} /// Writes fake binaries from `[fake_bins]` in the test config into `bin_dir`, /// makes them executable (Unix), and returns a PATH string that prepends From 01c4b0bc3992e52f5ca8806903a0a3a5299f64b2 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 10:17:26 +0000 Subject: [PATCH 049/141] docs: split AGENTS-V2.md into .github/agents/knowledge/ files Signed-off-by: Gregor Zeitlinger --- .github/agents/knowledge/README.md | 14 ++ .github/agents/knowledge/architecture.md | 38 ++++ .github/agents/knowledge/design.md | 47 +++++ .github/agents/knowledge/linters.md | 62 +++++++ .github/agents/knowledge/testing.md | 59 ++++++ AGENTS-V2.md | 226 ++--------------------- 6 files changed, 238 insertions(+), 208 deletions(-) create mode 100644 .github/agents/knowledge/README.md create mode 100644 .github/agents/knowledge/architecture.md create mode 100644 .github/agents/knowledge/design.md create mode 100644 .github/agents/knowledge/linters.md create mode 100644 .github/agents/knowledge/testing.md diff --git a/.github/agents/knowledge/README.md b/.github/agents/knowledge/README.md new file mode 100644 index 0000000..9748d2c --- /dev/null +++ b/.github/agents/knowledge/README.md @@ -0,0 +1,14 @@ +# Knowledge Index + +Reusable repository guidance for agents working on flint v2. + +Load only files relevant to the current scope. + +## Topics + +| File | Load when | +| --- | --- | +| `architecture.md` | Navigating the codebase; understanding module roles or check kinds | +| `linters.md` | Adding, modifying, or debugging a linter; `registry.rs` changes; config injection | +| `design.md` | Questioning why something works the way it does; avoiding known pitfalls | +| `testing.md` | Writing or updating tests; adding fixture cases; regenerating snapshots | diff --git a/.github/agents/knowledge/architecture.md b/.github/agents/knowledge/architecture.md new file mode 100644 index 0000000..6248d42 --- /dev/null +++ b/.github/agents/knowledge/architecture.md @@ -0,0 +1,38 @@ +# Architecture + +## Module Map + +- **`src/registry.rs`**: Static linter registry. Defines + `Check` (builder pattern) and `builtin()` which returns + the full list of built-in checks. This is where new + linters are added. +- **`src/runner.rs`**: Executes checks against a file list. + Handles parallel execution (check mode) and serial + execution (fix mode, to avoid concurrent writes). +- **`src/config.rs`**: Loads `flint.toml` from the project + root. All fields have defaults — the file is optional. +- **`src/files.rs`**: Git-aware file discovery. Returns + changed files relative to the merge base, or all files + with `--full`. +- **`src/linters/`**: Custom logic for special checks that + can't be expressed as a simple command template: + - `lychee.rs`: Link checking orchestration + - `renovate_deps.rs`: Renovate snapshot verification +- **`src/main.rs`**: CLI parsing (clap), orchestration, + output formatting. +- **`tests/e2e.rs`**: End-to-end tests. Spin up a temp git + repo, write files, run the flint binary, assert on + stdout/stderr and exit code. + +## Check Kinds + +A `Check` is either a `Template` (a command string with +`{FILE}`, `{FILES}`, or `{MERGE_BASE}` placeholders) or a +`Special` (custom Rust logic in `src/linters/`). + +Template scopes: + +- `File` — invoked once per matched file (`{FILE}`) +- `Files` — invoked once with all matched files (`{FILES}`) +- `Project` — invoked once with no file args; skipped + entirely if no matching files changed diff --git a/.github/agents/knowledge/design.md b/.github/agents/knowledge/design.md new file mode 100644 index 0000000..29de891 --- /dev/null +++ b/.github/agents/knowledge/design.md @@ -0,0 +1,47 @@ +# Key Design Decisions + +1. **Activation via `mise.toml`**: A check is active when + its tool (or `mise_tool_name` override) is declared in + the consuming repo's `mise.toml`. No PATH probing — + mise guarantees declared tools are on PATH. + +2. **`ec` deference**: `ec` (editorconfig-checker) runs on + all files but skips file types owned by active + line-length-enforcing formatters (`cargo-fmt`, + `ruff-format`, `biome-format`, `prettier`). Implemented + via `.excludes(&[...])` on the `ec` entry. This avoids + `ec`'s `max_line_length` check conflicting with + formatter output. + +3. **markdownlint + prettier on `*.md`**: Both checkers are + active when their tools are installed. They cover + different concerns (markdownlint: structural rules; + prettier: formatting). To avoid MD013 (line length) + conflicting with prettier's line wrapping, consuming + repos must disable MD013 in `.markdownlint.json`: + ```json + { "MD013": false } + ``` + +4. **Fix mode runs serially**: `runner.rs` runs checks in + parallel in check mode, but serially in fix mode to + avoid concurrent writes to the same file. + +5. **Version ranges**: When a `bin_name` has any + `version_range` entries, every entry for that binary + must have one (enforced by a registry unit test). This + prevents ambiguous activation when ranges don't cover + all versions. + +6. **Special checks**: `links` and `renovate-deps` have + custom orchestration logic that doesn't fit the command + template model. Their implementations live in + `src/linters/`. + +7. **Built-in file exclusions**: `src/files.rs` has a + `BUILTIN_EXCLUDES` slice of paths that are always removed + from the file list before any linter sees it. Currently + contains `.github/renovate-tracked-deps.json` (a + generated file that should never be linted by prettier, + ec, etc.). Add entries here — not in user-facing `exclude` + docs — when a file is managed by flint itself. diff --git a/.github/agents/knowledge/linters.md b/.github/agents/knowledge/linters.md new file mode 100644 index 0000000..4eda4a9 --- /dev/null +++ b/.github/agents/knowledge/linters.md @@ -0,0 +1,62 @@ +# Adding a New Linter + +Add an entry to `builtin()` in `src/registry.rs` using the +builder pattern: + +```rust +// File scope — invoked per file +Check::file("mytool", "mytool --check {FILE}", &["*.ext"]) + .fix("mytool --fix {FILE}"), + +// Files scope — invoked once with all matched files +Check::files("mytool", "mytool {FILES}", &["*.ext"]) + .fix("mytool --fix {FILES}"), + +// Project scope — invoked once, skipped if no *.ext changed +Check::project("mytool", "mytool run", &["*.ext"]), +``` + +Available builder modifiers: + +| Method | Purpose | +|---|---| +| `.fix(cmd)` | Enable `--fix` mode with this command | +| `.bin(name)` | Override binary name (when check name ≠ binary) | +| `.mise_tool(name)` | Look up availability under a different mise key (e.g. `rust` for `cargo-fmt`) | +| `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | +| `.excludes(names)` | Skip files already owned by these active checks | +| `.slow()` | Mark as slow — skipped by `--fast` | +| `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | + +## Config File Injection (`.linter_config`) + +Use `.linter_config(filename, flag)` when the tool supports an explicit config +file path via a CLI flag. At runtime, if `FLINT_CONFIG_DIR/` exists, +flint injects `flag ` right after the binary name in the command. +If the file is absent the flag is silently omitted — native config discovery +remains in effect. + +```rust +// Example: markdownlint accepts --config +Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) + .fix("markdownlint --fix {FILE}") + .linter_config(".markdownlint.json", "--config"), +// → markdownlint --config /repo/.github/config/.markdownlint.json +``` + +**When NOT to use it:** +- The tool has no explicit `--config`/`--rcfile`/equivalent flag (e.g. `shfmt`) +- The flag accepts a **directory** rather than a file (e.g. biome's + `--config-path

`) — a different injection shape is needed. For biome, + check for `biome.json` existence but pass `config_dir` itself as the arg: + `biome --config-path check `. This requires a variant of + `.linter_config` that injects the directory rather than the full file path + (not yet implemented) +- The tool is project-scoped and its config must live at the project root to + function (e.g. `cargo-fmt` reads `rustfmt.toml` via Cargo, not a direct flag) + +Look up the tool's `--help` or man page for the config flag name and expected +argument type before adding `.linter_config`. + +For checks that need custom logic (not a simple command template), add a module +under `src/linters/` and use `CheckKind::Special`. diff --git a/.github/agents/knowledge/testing.md b/.github/agents/knowledge/testing.md new file mode 100644 index 0000000..533689d --- /dev/null +++ b/.github/agents/knowledge/testing.md @@ -0,0 +1,59 @@ +# Testing + +Run all tests with: + +```bash +cargo test +``` + +## Unit Tests + +In-module `#[cfg(test)]` blocks in `src/`. Notable: +- `src/registry.rs`: enforces version-range consistency +- `src/runner.rs`: config injection, scope filtering +- `src/linters/renovate_deps.rs`: log parsing, snapshot + read/write, diff output + +## Fixture-based E2E Tests + +`tests/cases/` holds one directory per scenario. Each +contains: + +- `files/` — files copied verbatim into a temp git repo + and staged before the run +- `test.toml` — test spec: + +```toml +[expected] +args = "--full shellcheck" +exit = 1 # optional, default 0 +stderr = """ +...golden output... +""" + +[expected.files] # optional: assert files written by --fix +".github/renovate-tracked-deps.json" = """ +{...} +""" + +[env] # optional extra env vars +FOO = "bar" + +[fake_bins] # optional fake binaries (Unix only) +renovate = ''' +#!/bin/sh +echo '...' +''' +``` + +The `cases` test in `tests/e2e.rs` runs all of them. +Set `UPDATE_SNAPSHOTS=1` to regenerate `[expected].exit`/ +`stderr`/`stdout` in place. `[expected.files]` and `[fake_bins]` +are always preserved by the snapshot writer. + +Use fixture cases for any check — including ones that require +fake external binaries (via `[fake_bins]`). The fixture runner +writes each binary into a tempdir and prepends it to `PATH`. + +When adding a new check, cover at least: clean pass, failure +with correct diff/output, and fix mode if supported. diff --git a/AGENTS-V2.md b/AGENTS-V2.md index 28cac17..77a8af8 100644 --- a/AGENTS-V2.md +++ b/AGENTS-V2.md @@ -1,8 +1,15 @@ -# AGENTS-V2.md +# flint v2 + +## Scope Guidance for working on flint v2 — the Rust binary. For v1 (bash task scripts), see [AGENTS-V1.md](AGENTS-V1.md). +## Repository Layout + +- Usage documentation: `README.md` +- Agent knowledge index: `.github/agents/knowledge/README.md` + ## Repository Overview v2 is a single Rust binary (`flint`) that discovers linting @@ -10,215 +17,18 @@ tools from the consuming repo's `mise.toml`, runs them against changed files in parallel, and produces identical output locally and in CI. -See [README.md](README.md) for usage documentation. - -## Architecture - -### Module Map - -- **`src/registry.rs`**: Static linter registry. Defines - `Check` (builder pattern) and `builtin()` which returns - the full list of built-in checks. This is where new - linters are added. -- **`src/runner.rs`**: Executes checks against a file list. - Handles parallel execution (check mode) and serial - execution (fix mode, to avoid concurrent writes). -- **`src/config.rs`**: Loads `flint.toml` from the project - root. All fields have defaults — the file is optional. -- **`src/files.rs`**: Git-aware file discovery. Returns - changed files relative to the merge base, or all files - with `--full`. -- **`src/linters/`**: Custom logic for special checks that - can't be expressed as a simple command template: - - `lychee.rs`: Link checking orchestration - - `renovate_deps.rs`: Renovate snapshot verification -- **`src/main.rs`**: CLI parsing (clap), orchestration, - output formatting. -- **`tests/e2e.rs`**: End-to-end tests. Spin up a temp git - repo, write files, run the flint binary, assert on - stdout/stderr and exit code. - -### Check Kinds - -A `Check` is either a `Template` (a command string with -`{FILE}`, `{FILES}`, or `{MERGE_BASE}` placeholders) or a -`Special` (custom Rust logic in `src/linters/`). - -Template scopes: - -- `File` — invoked once per matched file (`{FILE}`) -- `Files` — invoked once with all matched files (`{FILES}`) -- `Project` — invoked once with no file args; skipped - entirely if no matching files changed - -### Adding a New Linter - -Add an entry to `builtin()` in `src/registry.rs` using the -builder pattern: - -```rust -// File scope — invoked per file -Check::file("mytool", "mytool --check {FILE}", &["*.ext"]) - .fix("mytool --fix {FILE}"), - -// Files scope — invoked once with all matched files -Check::files("mytool", "mytool {FILES}", &["*.ext"]) - .fix("mytool --fix {FILES}"), - -// Project scope — invoked once, skipped if no *.ext changed -Check::project("mytool", "mytool run", &["*.ext"]), -``` - -Available builder modifiers: - -| Method | Purpose | -|---|---| -| `.fix(cmd)` | Enable `--fix` mode with this command | -| `.bin(name)` | Override binary name (when check name ≠ binary) | -| `.mise_tool(name)` | Look up availability under a different mise key (e.g. `rust` for `cargo-fmt`) | -| `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | -| `.excludes(names)` | Skip files already owned by these active checks | -| `.slow()` | Mark as slow — skipped by `--fast` | -| `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | - -#### Config file injection (`.linter_config`) - -Use `.linter_config(filename, flag)` when the tool supports an explicit config -file path via a CLI flag. At runtime, if `FLINT_CONFIG_DIR/` exists, -flint injects `flag ` right after the binary name in the command. -If the file is absent the flag is silently omitted — native config discovery -remains in effect. - -```rust -// Example: markdownlint accepts --config -Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) - .fix("markdownlint --fix {FILE}") - .linter_config(".markdownlint.json", "--config"), -// → markdownlint --config /repo/.github/config/.markdownlint.json -``` - -**When NOT to use it:** -- The tool has no explicit `--config`/`--rcfile`/equivalent flag (e.g. `shfmt`) -- The flag accepts a **directory** rather than a file (e.g. biome's - `--config-path `) — a different injection shape is needed. For biome, - check for `biome.json` existence but pass `config_dir` itself as the arg: - `biome --config-path check `. This requires a variant of - `.linter_config` that injects the directory rather than the full file path - (not yet implemented) -- The tool is project-scoped and its config must live at the project root to - function (e.g. `cargo-fmt` reads `rustfmt.toml` via Cargo, not a direct flag) - -Look up the tool's `--help` or man page for the config flag name and expected -argument type before adding `.linter_config`. - -For checks that need custom logic (not a simple command -template), add a module under `src/linters/` and use -`CheckKind::Special`. - -### Key Design Decisions - -1. **Activation via `mise.toml`**: A check is active when - its tool (or `mise_tool_name` override) is declared in - the consuming repo's `mise.toml`. No PATH probing — - mise guarantees declared tools are on PATH. - -2. **`ec` deference**: `ec` (editorconfig-checker) runs on - all files but skips file types owned by active - line-length-enforcing formatters (`cargo-fmt`, - `ruff-format`, `biome-format`, `prettier`). Implemented - via `.excludes(&[...])` on the `ec` entry. This avoids - `ec`'s `max_line_length` check conflicting with - formatter output. - -3. **markdownlint + prettier on `*.md`**: Both checkers are - active when their tools are installed. They cover - different concerns (markdownlint: structural rules; - prettier: formatting). To avoid MD013 (line length) - conflicting with prettier's line wrapping, consuming - repos must disable MD013 in `.markdownlint.json`: - ```json - { "MD013": false } - ``` - -4. **Fix mode runs serially**: `runner.rs` runs checks in - parallel in check mode, but serially in fix mode to - avoid concurrent writes to the same file. - -5. **Version ranges**: When a `bin_name` has any - `version_range` entries, every entry for that binary - must have one (enforced by a registry unit test). This - prevents ambiguous activation when ranges don't cover - all versions. - -6. **Special checks**: `links` and `renovate-deps` have - custom orchestration logic that doesn't fit the command - template model. Their implementations live in - `src/linters/`. - -7. **Built-in file exclusions**: `src/files.rs` has a - `BUILTIN_EXCLUDES` slice of paths that are always removed - from the file list before any linter sees it. Currently - contains `.github/renovate-tracked-deps.json` (a - generated file that should never be linted by prettier, - ec, etc.). Add entries here — not in user-facing `exclude` - docs — when a file is managed by flint itself. - -## Testing - -Run all tests with: - -```bash -cargo test -``` - -### Unit tests - -In-module `#[cfg(test)]` blocks in `src/`. Notable: -- `src/registry.rs`: enforces version-range consistency -- `src/runner.rs`: config injection, scope filtering -- `src/linters/renovate_deps.rs`: log parsing, snapshot - read/write, diff output - -### Fixture-based e2e tests - -`tests/cases/` holds one directory per scenario. Each -contains: - -- `files/` — files copied verbatim into a temp git repo - and staged before the run -- `test.toml` — test spec: - -```toml -[expected] -args = "--full shellcheck" -exit = 1 # optional, default 0 -stderr = """ -...golden output... -""" - -[expected.files] # optional: assert files written by --fix -".github/renovate-tracked-deps.json" = """ -{...} -""" +## Knowledge Loading -[env] # optional extra env vars -FOO = "bar" +For coding, fix, and refactoring tasks, consult `.github/agents/knowledge/README.md` +before making substantial changes. -[fake_bins] # optional fake binaries (Unix only) -renovate = ''' -#!/bin/sh -echo '...' -''' -``` +Use the knowledge index to load only the article(s) relevant to the current task. +Do not load the entire knowledge folder by default. -The `cases` test in `tests/e2e.rs` runs all of them. -Set `UPDATE_SNAPSHOTS=1` to regenerate `[expected].exit`/ -`stderr`/`stdout` in place. `[expected.files]` and `[fake_bins]` -are always preserved by the snapshot writer. +## Execution Rules -Use fixture cases for any check — including ones that require -fake external binaries (via `[fake_bins]`). The fixture runner -writes each binary into a tempdir and prepends it to `PATH`. +Run tests with `cargo test`. Tests spin up temporary git repos and run the real +`flint` binary — they are integration tests, not unit tests, so they can be slow. -When adding a new check, cover at least: clean pass, failure -with correct diff/output, and fix mode if supported. +Always run `mise run lint:fix` before committing and review auto-fixed files — +auto-fixes may produce unexpected results. From 3597dd23524101968908aafd39f3e217cebe476a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 10:31:25 +0000 Subject: [PATCH 050/141] style: remove double blank line in e2e.rs --- tests/e2e.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e.rs b/tests/e2e.rs index bcd7751..139de63 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -280,7 +280,6 @@ fn strip_ansi(s: &str) -> String { out } - /// Writes fake binaries from `[fake_bins]` in the test config into `bin_dir`, /// makes them executable (Unix), and returns a PATH string that prepends /// `bin_dir` to the current PATH. Returns `None` when no fake_bins are declared. From a85ad73789564d8586e5e132f64f45c95e066ca0 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 10:49:45 +0000 Subject: [PATCH 051/141] test: add lychee e2e fixtures and unit tests for lychee/license-header - Add tests/cases/lychee/clean and broken-link e2e fixtures (fake lychee binary) - Add [expected.files] to cargo-fmt/auto-fix and general/auto-fix-and-review to assert the reformatted src/lib.rs after cargo-fmt fix mode runs - Add unit tests for parse_github_repo, is_link_checkable (lychee.rs) and glob_match, check_file (license_header.rs) - Fix stale reference to tasks/lint/links.sh in test-links.md --- src/linters/license_header.rs | 50 ++++++++++++++ src/linters/lychee.rs | 69 +++++++++++++++++++ tests/cases/cargo-fmt/auto-fix/test.toml | 8 +++ .../general/auto-fix-and-review/test.toml | 8 +++ .../cases/lychee/broken-link/files/README.md | 3 + .../lychee/broken-link/files/lychee.toml | 1 + .../cases/lychee/broken-link/files/mise.toml | 2 + tests/cases/lychee/broken-link/test.toml | 21 ++++++ tests/cases/lychee/clean/files/README.md | 3 + tests/cases/lychee/clean/files/lychee.toml | 1 + tests/cases/lychee/clean/files/mise.toml | 2 + tests/cases/lychee/clean/test.toml | 12 ++++ tests/test-links.md | 2 +- 13 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 tests/cases/lychee/broken-link/files/README.md create mode 100644 tests/cases/lychee/broken-link/files/lychee.toml create mode 100644 tests/cases/lychee/broken-link/files/mise.toml create mode 100644 tests/cases/lychee/broken-link/test.toml create mode 100644 tests/cases/lychee/clean/files/README.md create mode 100644 tests/cases/lychee/clean/files/lychee.toml create mode 100644 tests/cases/lychee/clean/files/mise.toml create mode 100644 tests/cases/lychee/clean/test.toml diff --git a/src/linters/license_header.rs b/src/linters/license_header.rs index 5e5e128..eedee63 100644 --- a/src/linters/license_header.rs +++ b/src/linters/license_header.rs @@ -82,3 +82,53 @@ fn glob_match(pattern: &str, name: &str) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_match_extension() { + assert!(glob_match("*.java", "Foo.java")); + assert!(glob_match("*.java", "src/main/Foo.java")); + assert!(!glob_match("*.java", "Foo.kt")); + } + + #[test] + fn glob_match_exact() { + assert!(glob_match("Makefile", "Makefile")); + assert!(glob_match("Makefile", "src/Makefile")); + assert!(!glob_match("Makefile", "GNUmakefile")); + } + + #[test] + fn check_file_finds_header_in_first_lines() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + std::fs::write(&path, "// Copyright 2024 Acme\npublic class Foo {}\n").unwrap(); + assert!(check_file(&path, "Copyright", 5).unwrap()); + } + + #[test] + fn check_file_missing_header() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + std::fs::write(&path, "public class Foo {}\n").unwrap(); + assert!(!check_file(&path, "Copyright", 5).unwrap()); + } + + #[test] + fn check_file_header_beyond_line_limit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + std::fs::write( + &path, + "line1\nline2\nline3\n// Copyright 2024 Acme\npublic class Foo {}\n", + ) + .unwrap(); + // Header is on line 4; with limit=3 it should not be found. + assert!(!check_file(&path, "Copyright", 3).unwrap()); + // With limit=5 it should be found. + assert!(check_file(&path, "Copyright", 5).unwrap()); + } +} diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 8a0b220..f74469e 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -348,3 +348,72 @@ fn is_link_checkable(path: &Path) -> bool { | "txt" ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_github_repo_https() { + assert_eq!( + parse_github_repo("https://github.com/owner/repo"), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_https_dotgit() { + assert_eq!( + parse_github_repo("https://github.com/owner/repo.git"), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_ssh() { + assert_eq!( + parse_github_repo("git@github.com:owner/repo"), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_ssh_dotgit() { + assert_eq!( + parse_github_repo("git@github.com:owner/repo.git"), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_non_github() { + assert_eq!(parse_github_repo("https://gitlab.com/owner/repo"), None); + } + + #[test] + fn parse_github_repo_empty_path() { + assert_eq!(parse_github_repo("https://github.com/"), None); + } + + #[test] + fn is_link_checkable_md() { + assert!(is_link_checkable(Path::new("README.md"))); + assert!(is_link_checkable(Path::new("docs/page.markdown"))); + assert!(is_link_checkable(Path::new("file.html"))); + assert!(is_link_checkable(Path::new("notes.txt"))); + } + + #[test] + fn is_link_checkable_case_insensitive() { + assert!(is_link_checkable(Path::new("README.MD"))); + assert!(is_link_checkable(Path::new("page.HTML"))); + } + + #[test] + fn is_link_checkable_non_checkable() { + assert!(!is_link_checkable(Path::new("main.rs"))); + assert!(!is_link_checkable(Path::new("config.toml"))); + assert!(!is_link_checkable(Path::new("script.sh"))); + assert!(!is_link_checkable(Path::new("Makefile"))); + } +} diff --git a/tests/cases/cargo-fmt/auto-fix/test.toml b/tests/cases/cargo-fmt/auto-fix/test.toml index a81be73..46f91ad 100644 --- a/tests/cases/cargo-fmt/auto-fix/test.toml +++ b/tests/cases/cargo-fmt/auto-fix/test.toml @@ -3,4 +3,12 @@ args = "--full --auto cargo-fmt" exit = 1 stderr = """ flint: fixed: cargo-fmt — commit before pushing +""" + +[expected.files] +"src/lib.rs" = """ +pub struct Foo { + pub a: u32, + pub b: u32, +} """ \ No newline at end of file diff --git a/tests/cases/general/auto-fix-and-review/test.toml b/tests/cases/general/auto-fix-and-review/test.toml index 9de0dc0..05446f3 100644 --- a/tests/cases/general/auto-fix-and-review/test.toml +++ b/tests/cases/general/auto-fix-and-review/test.toml @@ -14,4 +14,12 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: fixed: cargo-fmt — commit before pushing | review: shellcheck +""" + +[expected.files] +"src/lib.rs" = """ +pub struct Foo { + pub a: u32, + pub b: u32, +} """ \ No newline at end of file diff --git a/tests/cases/lychee/broken-link/files/README.md b/tests/cases/lychee/broken-link/files/README.md new file mode 100644 index 0000000..c7960d7 --- /dev/null +++ b/tests/cases/lychee/broken-link/files/README.md @@ -0,0 +1,3 @@ +# Test + +[broken link](https://example.com/does-not-exist) diff --git a/tests/cases/lychee/broken-link/files/lychee.toml b/tests/cases/lychee/broken-link/files/lychee.toml new file mode 100644 index 0000000..6f3ae43 --- /dev/null +++ b/tests/cases/lychee/broken-link/files/lychee.toml @@ -0,0 +1 @@ +# lychee configuration diff --git a/tests/cases/lychee/broken-link/files/mise.toml b/tests/cases/lychee/broken-link/files/mise.toml new file mode 100644 index 0000000..450fc84 --- /dev/null +++ b/tests/cases/lychee/broken-link/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +lychee = "latest" diff --git a/tests/cases/lychee/broken-link/test.toml b/tests/cases/lychee/broken-link/test.toml new file mode 100644 index 0000000..d67b762 --- /dev/null +++ b/tests/cases/lychee/broken-link/test.toml @@ -0,0 +1,21 @@ +[expected] +args = "--full lychee" +exit = 1 +stderr = """ +[lychee] +==> Checking all links in all files +[404] https://example.com/does-not-exist + +flint: 1 check failed (lychee) +šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. +""" + +[env] +LYCHEE_SKIP_GITHUB_REMAPS = "true" + +[fake_bins] +lychee = ''' +#!/bin/sh +echo "[404] https://example.com/does-not-exist" +exit 1 +''' diff --git a/tests/cases/lychee/clean/files/README.md b/tests/cases/lychee/clean/files/README.md new file mode 100644 index 0000000..1d1f60f --- /dev/null +++ b/tests/cases/lychee/clean/files/README.md @@ -0,0 +1,3 @@ +# Test + +[valid link](https://example.com) diff --git a/tests/cases/lychee/clean/files/lychee.toml b/tests/cases/lychee/clean/files/lychee.toml new file mode 100644 index 0000000..6f3ae43 --- /dev/null +++ b/tests/cases/lychee/clean/files/lychee.toml @@ -0,0 +1 @@ +# lychee configuration diff --git a/tests/cases/lychee/clean/files/mise.toml b/tests/cases/lychee/clean/files/mise.toml new file mode 100644 index 0000000..450fc84 --- /dev/null +++ b/tests/cases/lychee/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +lychee = "latest" diff --git a/tests/cases/lychee/clean/test.toml b/tests/cases/lychee/clean/test.toml new file mode 100644 index 0000000..f2a05a8 --- /dev/null +++ b/tests/cases/lychee/clean/test.toml @@ -0,0 +1,12 @@ +[expected] +args = "--full lychee" +exit = 0 + +[env] +LYCHEE_SKIP_GITHUB_REMAPS = "true" + +[fake_bins] +lychee = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/test-links.md b/tests/test-links.md index 47920c5..afff07d 100644 --- a/tests/test-links.md +++ b/tests/test-links.md @@ -1,6 +1,6 @@ # Link remap smoke test -These links exercise the GitHub URL remap rules in `tasks/lint/links.sh`. +These links exercise the GitHub URL remap rules in `src/linters/lychee.rs`. On PR branches, lychee rewrites `blob/main/` URLs to the PR branch — these links verify that each remap rule works correctly during CI. From 9435177df94f621dec865e49324d6b27f9956266 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 10:55:55 +0000 Subject: [PATCH 052/141] ci: add Rust cache and fix clippy component installation - Pin clippy and rustfmt components in mise.toml so mise installs them alongside the Rust toolchain (fixes 'cargo-clippy is not installed') - Add Swatinem/rust-cache to the lint workflow for faster CI - Trigger lint workflow on push to main to warm the cache for PRs --- .github/workflows/lint.yml | 5 +++++ mise.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d2d3ad9..e27a907 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,7 +2,10 @@ name: Lint on: + push: + branches: [main] # warms the Rust cache so PR branches get a cache hit pull_request: + branches: [main] permissions: {} @@ -26,6 +29,8 @@ jobs: version: v2026.3.16 sha256: 1aadc8f126b0fc588b70ad2296cf7a963ba014ef2fd017a6087329ec6160063e + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Lint env: GITHUB_TOKEN: ${{ github.token }} diff --git a/mise.toml b/mise.toml index b059e7e..ac56a41 100644 --- a/mise.toml +++ b/mise.toml @@ -14,7 +14,7 @@ editorconfig-checker = "v3.6.1" "npm:@biomejs/biome" = "2.3.14" "pipx:ruff" = "0.15.0" "pipx:codespell" = "2.4.1" -rust = "1.94.1" +rust = { version = "1.94.1", components = "clippy,rustfmt" } [tasks."setup:update-super-linter-versions"] description = "Generate super-linter version mapping from the super-linter repo" From 4a7f4e565c171499d477bd00325b1fdfeb5ed904 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 10:58:39 +0000 Subject: [PATCH 053/141] chore: simplify mise tasks to use cargo run instead of build + exec --- mise.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mise.toml b/mise.toml index ac56a41..026148d 100644 --- a/mise.toml +++ b/mise.toml @@ -22,15 +22,15 @@ file = "tasks/setup/update-super-linter-versions.sh" [tasks.lint] description = "Run all lints" -run = "cargo build -q && ./target/debug/flint" +run = "cargo run -q" [tasks."lint:fix"] description = "Auto-fix lint issues" -run = "cargo build -q && ./target/debug/flint --fix" +run = "cargo run -q -- --fix" [tasks."lint:pre-commit"] description = "Fast auto-fix lint pass (skips slow checks like renovate) — intended for pre-commit/pre-push hooks" -run = "cargo build -q && ./target/debug/flint --auto --fast" +run = "cargo run -q -- --auto --fast" [tasks.build] description = "Build the project" From 3ddd1adaf5a6d20617548db35626f37f1b5c3c79 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 11:19:27 +0000 Subject: [PATCH 054/141] refactor: merge --auto into --fix, drop --auto flag --fix now does what --auto did: pre-check all linters, fix what's fixable, report unfixable failures with full output. Exit 1 if anything was fixed (uncommitted) or still needs review; exit 0 only if already clean. Removes FLINT_AUTO env var. Updates all test cases and README. --- README.md | 35 +++++++++---------- src/main.rs | 26 ++++++-------- tests/cases/cargo-fmt/auto-fix/test.toml | 2 +- .../general/auto-fix-and-review/test.toml | 2 +- .../general/auto-review-two-linters/test.toml | 2 +- .../general/auto-review-unfixable/test.toml | 2 +- .../cases/renovate-deps/fix-create/test.toml | 6 ++-- .../cases/renovate-deps/fix-update/test.toml | 6 ++-- 8 files changed, 39 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0c8bfc1..788dccf 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ run = "flint" [tasks."lint:pre-commit"] description = "Fast auto-fix lint pass — for pre-push hooks and agentic pipelines" -run = "flint --auto --fast" +run = "flint --fix --fast" [tasks."lint:fix"] description = "Auto-fix lint issues" @@ -114,19 +114,18 @@ flint [OPTIONS] [LINTERS...] flint list ``` -| Flag | Description | -| ---------------- | -------------------------------------------------- | -| `--fix` | Auto-fix issues instead of checking | -| `--auto` | Fix what's fixable, report what still needs review | -| `--full` | Lint all files instead of only changed files | -| `--fast` | Skip slow checks (e.g. `renovate-deps`) | -| `--short` | Compact summary output, no per-check noise | -| `--verbose` | Show all linter output, not just failures | -| `--from-ref REF` | Diff base (default: merge base with base branch) | -| `--to-ref REF` | Diff head (default: HEAD) | +| Flag | Description | +| ---------------- | ------------------------------------------------------------------------ | +| `--fix` | Fix what's fixable, report what still needs review; exit 1 if anything changed or needs review | +| `--full` | Lint all files instead of only changed files | +| `--fast` | Skip slow checks (e.g. `renovate-deps`) | +| `--short` | Compact summary output, no per-check noise | +| `--verbose` | Show all linter output, not just failures | +| `--from-ref REF` | Diff base (default: merge base with base branch) | +| `--to-ref REF` | Diff head (default: HEAD) | Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_FAST`, -`FLINT_VERBOSE`, `FLINT_SHORT`, `FLINT_AUTO`, `FLINT_FROM_REF`, `FLINT_TO_REF`. +`FLINT_VERBOSE`, `FLINT_SHORT`, `FLINT_FROM_REF`, `FLINT_TO_REF`. #### Intended use by context @@ -134,7 +133,7 @@ Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_FAST`, | ---------------------------- | ------------------------- | ----------------------------------------------------------------- | | Interactive development | `flint` or `flint --fast` | Full output so you can read the details | | Human wanting a summary | `flint --short` | Compact output, no per-check noise | -| Pre-push hook (CC / agentic) | `flint --auto --fast` | Fixes what it can silently, surfaces only what needs human review | +| Pre-push hook (CC / agentic) | `flint --fix --fast` | Fixes what it can silently, surfaces only what needs human review | | CI | `flint` | Full output for humans reading CI logs | **`--short` output** — failed checks partitioned by fixability, fixable ones @@ -144,7 +143,7 @@ expressed as the exact command to run: flint: 2 checks failed — flint --fix prettier cargo-fmt | review: shellcheck ``` -**`--auto` output** — fixes what's fixable, then prints the full output of +**`--fix` output** — fixes what's fixable, then prints the full output of any checks that still need review, followed by a summary line. Exits 1 if anything was fixed (so the caller commits the fixes before pushing) or if anything still needs review. Exits 0 only if everything was already clean: @@ -336,7 +335,7 @@ use everywhere" promise of mise. Container startup also adds latency to every ru 4. **Local same as CI** — one binary, one config, identical behavior. No "native mode subset" distinction. If it passes locally, it passes in CI. -5. **AI-friendly** — `--auto` fixes what's fixable silently, prints output +5. **AI-friendly** — `--fix` fixes what's fixable silently, prints output only for issues needing review, and exits with a structured summary: ``` [shellcheck] @@ -354,9 +353,9 @@ use everywhere" promise of mise. Container startup also adds latency to every ru for CI. `--full` to check everything. Falls back to all files when no merge base is found. -8. **Autofix where possible** — `--fix` flag. Fix mode runs serially to avoid - concurrent writes to the same file. Pass specific linter names to limit which - fixers run (`flint --fix prettier shfmt`). +8. **Autofix where possible** — `--fix` checks first, fixes what's fixable, + reports what needs review. Fix mode runs serially to avoid concurrent writes. + Pass specific linter names to limit which fixers run (`flint --fix prettier shfmt`). ## Versioning diff --git a/src/main.rs b/src/main.rs index 3a49f10..2c312e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,8 @@ struct Cli { #[command(subcommand)] command: Option, - /// Auto-fix issues instead of checking + /// Fix what's fixable, report what still needs review. + /// Exits 1 if anything was fixed (uncommitted) or needs review; 0 if already clean. #[arg(long, env = "FLINT_FIX")] fix: bool, @@ -36,12 +37,6 @@ struct Cli { #[arg(long, env = "FLINT_SHORT")] short: bool, - /// Autonomous mode: fix what's fixable, report what still needs review. - /// Exits 0 if everything passed or was fixed. Intended for pre-push hooks - /// and agentic pipelines that have write access. - #[arg(long, env = "FLINT_AUTO")] - auto: bool, - /// Compare changed files from this ref (default: merge base with base branch) #[arg(long, env = "FLINT_FROM_REF")] from_ref: Option, @@ -118,9 +113,10 @@ async fn main() -> Result<()> { cli.to_ref.as_deref(), )?; - if cli.auto { - // Run checks, fix what's fixable, report outcome. - // Exits 0 if everything passed or was fixed; 1 if anything still needs review. + if cli.fix { + // Pre-check, fix what's fixable, report outcome. + // Exits 0 if everything was already clean; 1 if anything was fixed (uncommitted) + // or still needs review. let check_results = runner::run( &active, &file_list, @@ -212,7 +208,7 @@ async fn main() -> Result<()> { &active, &file_list, RunOptions { - fix: cli.fix, + fix: false, verbose: cli.verbose, short: cli.short, }, @@ -251,11 +247,9 @@ async fn main() -> Result<()> { "\nflint: {n} {noun} failed ({names})", names = failed.join(", ") ); - if !cli.fix { - eprintln!( - "šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify." - ); - } + eprintln!( + "šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify." + ); } std::process::exit(1); } diff --git a/tests/cases/cargo-fmt/auto-fix/test.toml b/tests/cases/cargo-fmt/auto-fix/test.toml index 46f91ad..a9bba2a 100644 --- a/tests/cases/cargo-fmt/auto-fix/test.toml +++ b/tests/cases/cargo-fmt/auto-fix/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --auto cargo-fmt" +args = "--full --fix cargo-fmt" exit = 1 stderr = """ flint: fixed: cargo-fmt — commit before pushing diff --git a/tests/cases/general/auto-fix-and-review/test.toml b/tests/cases/general/auto-fix-and-review/test.toml index 05446f3..957e15e 100644 --- a/tests/cases/general/auto-fix-and-review/test.toml +++ b/tests/cases/general/auto-fix-and-review/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --auto cargo-fmt shellcheck" +args = "--full --fix cargo-fmt shellcheck" exit = 1 stderr = """ [shellcheck] diff --git a/tests/cases/general/auto-review-two-linters/test.toml b/tests/cases/general/auto-review-two-linters/test.toml index d22ab04..dcb6757 100644 --- a/tests/cases/general/auto-review-two-linters/test.toml +++ b/tests/cases/general/auto-review-two-linters/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --auto shellcheck actionlint" +args = "--full --fix shellcheck actionlint" exit = 1 stderr = """ [actionlint] diff --git a/tests/cases/general/auto-review-unfixable/test.toml b/tests/cases/general/auto-review-unfixable/test.toml index 1347965..9ab3dba 100644 --- a/tests/cases/general/auto-review-unfixable/test.toml +++ b/tests/cases/general/auto-review-unfixable/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --auto shellcheck" +args = "--full --fix shellcheck" exit = 1 stderr = """ [shellcheck] diff --git a/tests/cases/renovate-deps/fix-create/test.toml b/tests/cases/renovate-deps/fix-create/test.toml index 57e9cd5..801b3a1 100644 --- a/tests/cases/renovate-deps/fix-create/test.toml +++ b/tests/cases/renovate-deps/fix-create/test.toml @@ -1,7 +1,9 @@ [expected] args = "--full --fix renovate-deps" -exit = 0 - +exit = 1 +stderr = """ +flint: fixed: renovate-deps — commit before pushing +""" [expected.files] ".github/renovate-tracked-deps.json" = """ diff --git a/tests/cases/renovate-deps/fix-update/test.toml b/tests/cases/renovate-deps/fix-update/test.toml index 57e9cd5..801b3a1 100644 --- a/tests/cases/renovate-deps/fix-update/test.toml +++ b/tests/cases/renovate-deps/fix-update/test.toml @@ -1,7 +1,9 @@ [expected] args = "--full --fix renovate-deps" -exit = 0 - +exit = 1 +stderr = """ +flint: fixed: renovate-deps — commit before pushing +""" [expected.files] ".github/renovate-tracked-deps.json" = """ From 6b1537a2223d7fecc5e55ee6e47e495f8433edd9 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 11:36:49 +0000 Subject: [PATCH 055/141] fix: activate renovate-deps via npm:renovate tool name renovate-deps was looking up "renovate" in mise_tools, but the tool is declared as "npm:renovate" in mise.toml. This caused the check to be silently skipped (missing), so the tracked-deps file was never verified. Fixes: - Add .mise_tool("npm:renovate") to the registry entry - Exclude tests/ from renovate's ignorePaths so fixture mise.toml files aren't scanned as real dependencies - Update fixture mise.toml files to declare "npm:renovate" (matching real usage and the updated activation check) - Regenerate renovate-tracked-deps.json: adds rust, drops stale README.md regex entry --- .github/renovate-tracked-deps.json | 6 +----- .github/renovate.json5 | 2 +- src/registry.rs | 4 +++- tests/cases/renovate-deps/fix-create/files/mise.toml | 2 +- tests/cases/renovate-deps/fix-update/files/mise.toml | 2 +- tests/cases/renovate-deps/out-of-date/files/mise.toml | 2 +- tests/cases/renovate-deps/up-to-date/files/mise.toml | 2 +- tests/e2e.rs | 2 +- 8 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 96a5178..b1919c7 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -4,11 +4,6 @@ "mise" ] }, - "README.md": { - "regex": [ - "grafana/flint" - ] - }, "mise.toml": { "mise": [ "actionlint", @@ -21,6 +16,7 @@ "npm:renovate", "pipx:codespell", "pipx:ruff", + "rust", "shellcheck", "shfmt" ] diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 90c7bf0..c10920b 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -5,7 +5,7 @@ dependencyDashboard: true, platformCommit: "enabled", automerge: true, - ignorePaths: [], + ignorePaths: ["tests/"], ignorePresets: [":ignoreModulesAndTests"], ignoreUnstable: true, vulnerabilityAlerts: { diff --git a/src/registry.rs b/src/registry.rs index 8ba02d5..3f8c832 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -324,7 +324,9 @@ pub fn builtin() -> Vec { .slow() .formatter(), Check::special("lychee", "lychee", SpecialKind::Links), - Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps).slow(), + Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) + .mise_tool("npm:renovate") + .slow(), Check::special( "license-header", "license-header", diff --git a/tests/cases/renovate-deps/fix-create/files/mise.toml b/tests/cases/renovate-deps/fix-create/files/mise.toml index 72ae587..bd7b0a1 100644 --- a/tests/cases/renovate-deps/fix-create/files/mise.toml +++ b/tests/cases/renovate-deps/fix-create/files/mise.toml @@ -1,3 +1,3 @@ [tools] -renovate = "latest" +"npm:renovate" = "latest" diff --git a/tests/cases/renovate-deps/fix-update/files/mise.toml b/tests/cases/renovate-deps/fix-update/files/mise.toml index 72ae587..bd7b0a1 100644 --- a/tests/cases/renovate-deps/fix-update/files/mise.toml +++ b/tests/cases/renovate-deps/fix-update/files/mise.toml @@ -1,3 +1,3 @@ [tools] -renovate = "latest" +"npm:renovate" = "latest" diff --git a/tests/cases/renovate-deps/out-of-date/files/mise.toml b/tests/cases/renovate-deps/out-of-date/files/mise.toml index 72ae587..bd7b0a1 100644 --- a/tests/cases/renovate-deps/out-of-date/files/mise.toml +++ b/tests/cases/renovate-deps/out-of-date/files/mise.toml @@ -1,3 +1,3 @@ [tools] -renovate = "latest" +"npm:renovate" = "latest" diff --git a/tests/cases/renovate-deps/up-to-date/files/mise.toml b/tests/cases/renovate-deps/up-to-date/files/mise.toml index 72ae587..bd7b0a1 100644 --- a/tests/cases/renovate-deps/up-to-date/files/mise.toml +++ b/tests/cases/renovate-deps/up-to-date/files/mise.toml @@ -1,3 +1,3 @@ [tools] -renovate = "latest" +"npm:renovate" = "latest" diff --git a/tests/e2e.rs b/tests/e2e.rs index 139de63..174c424 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -44,7 +44,7 @@ fn git_repo() -> TempDir { /// /// test.toml format: /// [expected] -/// args = "--full --auto shellcheck" +/// args = "--full --fix shellcheck" /// exit = 1 # optional, default 0 /// stderr = """...""" # optional, default "" /// stdout = """...""" # optional, default "" From 718d238752ddc4cb18ec8d526160ed0171c2f61f Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 15:02:07 +0200 Subject: [PATCH 056/141] refactor: redesign CLI to follow golangci-lint conventions (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `run`, `linters`, `version` subcommands; `flint` alone now shows help (no implicit default) - Rename `list` → `linters` (golangci-lint parity) - Rename `--fast` → `--fast-only`, `--from-ref` → `--new-from-rev` - `--fix` stays as a flag on `run` (simpler mental model, same as golangci-lint) - Fix silent bug: explicit linter names now override `--fast-only` — previously a slow linter named explicitly would be silently skipped - Add test fixture covering the `--fast-only` + explicit linter override - Fix `auto-review-two-linters` fixture to use a fake `actionlint` binary so it passes in any environment - Update all references in README, docs, fixtures, error messages ## Design rationale Follows [golangci-lint](https://golangci-lint.run/) CLI conventions where possible: - `run` / `linters` / `version` subcommands - `--fast-only` skips slow linters from the auto-discovered set; explicit linter names override it (same behaviour as golangci-lint's `-E` + `--fast-only`) - `--new-from-rev` for diff base (mirrors `--new-from-rev` in golangci-lint) A `fmt` subcommand is reserved for a future "format only" command if the format/fix distinction ever matters. ## Test plan - [ ] All tests pass (`cargo test`) - [ ] `flint` alone shows help - [ ] `flint run --fast-only renovate-deps` runs `renovate-deps` (explicit override) - [ ] `flint run --fast-only` skips `renovate-deps` when not explicitly named - [ ] `flint linters` shows the linter table - [ ] `flint version` prints the version --------- Signed-off-by: Gregor Zeitlinger --- .github/agents/knowledge/linters.md | 2 +- .github/workflows/lint.yml | 1 - README.md | 69 +++++----- mise.toml | 6 +- src/linters/renovate_deps.rs | 4 +- src/main.rs | 127 +++++++++++------- src/registry.rs | 4 +- tests/cases/cargo-fmt/auto-fix/test.toml | 2 +- tests/cases/cargo-fmt/failure/test.toml | 4 +- .../general/auto-fix-and-review/test.toml | 2 +- .../general/auto-review-two-linters/test.toml | 11 +- .../general/auto-review-unfixable/test.toml | 2 +- tests/cases/general/env-var-exclude/test.toml | 2 +- tests/cases/general/exclude-paths/test.toml | 2 +- .../files/.github/renovate-tracked-deps.json | 8 ++ .../files/.github/renovate.json5 | 1 + .../files/mise.toml | 3 + .../files/package.json | 1 + .../fast-only-explicit-override/test.toml | 12 ++ tests/cases/lychee/broken-link/test.toml | 4 +- tests/cases/lychee/clean/test.toml | 2 +- .../cases/renovate-deps/fix-create/test.toml | 2 +- .../cases/renovate-deps/fix-update/test.toml | 2 +- .../cases/renovate-deps/out-of-date/test.toml | 6 +- .../cases/renovate-deps/up-to-date/test.toml | 2 +- tests/cases/shellcheck/clean/test.toml | 2 +- tests/cases/shellcheck/config-dir/test.toml | 2 +- tests/cases/shellcheck/failure/test.toml | 4 +- 28 files changed, 177 insertions(+), 112 deletions(-) create mode 100644 tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json create mode 100644 tests/cases/general/fast-only-explicit-override/files/.github/renovate.json5 create mode 100644 tests/cases/general/fast-only-explicit-override/files/mise.toml create mode 100644 tests/cases/general/fast-only-explicit-override/files/package.json create mode 100644 tests/cases/general/fast-only-explicit-override/test.toml diff --git a/.github/agents/knowledge/linters.md b/.github/agents/knowledge/linters.md index 4eda4a9..811d0de 100644 --- a/.github/agents/knowledge/linters.md +++ b/.github/agents/knowledge/linters.md @@ -25,7 +25,7 @@ Available builder modifiers: | `.mise_tool(name)` | Look up availability under a different mise key (e.g. `rust` for `cargo-fmt`) | | `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | | `.excludes(names)` | Skip files already owned by these active checks | -| `.slow()` | Mark as slow — skipped by `--fast` | +| `.slow()` | Mark as slow — skipped by `--fast-only` | | `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | ## Config File Injection (`.linter_config`) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e27a907..e388f39 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,7 +5,6 @@ on: push: branches: [main] # warms the Rust cache so PR branches get a cache hit pull_request: - branches: [main] permissions: {} diff --git a/README.md b/README.md index 788dccf..0f91a20 100644 --- a/README.md +++ b/README.md @@ -71,15 +71,15 @@ Then wire up lint tasks: ```toml [tasks.lint] description = "Run all lints" -run = "flint" +run = "flint run" [tasks."lint:pre-commit"] description = "Fast auto-fix lint pass — for pre-push hooks and agentic pipelines" -run = "flint --fix --fast" +run = "flint run --fix --fast-only" [tasks."lint:fix"] description = "Auto-fix lint issues" -run = "flint --fix" +run = "flint run --fix" ``` ### CI setup @@ -110,37 +110,40 @@ run = "flint --fix" ### CLI ```text -flint [OPTIONS] [LINTERS...] -flint list +flint run [OPTIONS] [LINTERS...] +flint linters +flint version ``` -| Flag | Description | -| ---------------- | ------------------------------------------------------------------------ | -| `--fix` | Fix what's fixable, report what still needs review; exit 1 if anything changed or needs review | -| `--full` | Lint all files instead of only changed files | -| `--fast` | Skip slow checks (e.g. `renovate-deps`) | -| `--short` | Compact summary output, no per-check noise | -| `--verbose` | Show all linter output, not just failures | -| `--from-ref REF` | Diff base (default: merge base with base branch) | -| `--to-ref REF` | Diff head (default: HEAD) | +`flint run` flags: -Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_FAST`, -`FLINT_VERBOSE`, `FLINT_SHORT`, `FLINT_FROM_REF`, `FLINT_TO_REF`. +| Flag | Description | +| ------------------- | ------------------------------------------------------------------------ | +| `--fix` | Fix what's fixable, report what still needs review; exit 1 if anything changed or needs review | +| `--full` | Lint all files instead of only changed files | +| `--fast-only` | Skip slow checks (e.g. `renovate-deps`). Overridden by explicit linter names. | +| `--short` | Compact summary output, no per-check noise | +| `--verbose` | Show all linter output, not just failures | +| `--new-from-rev REV` | Diff base (default: merge base with base branch) | +| `--to-ref REF` | Diff head (default: HEAD) | + +Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_FAST_ONLY`, +`FLINT_VERBOSE`, `FLINT_SHORT`, `FLINT_NEW_FROM_REV`, `FLINT_TO_REF`. #### Intended use by context -| Context | Command | Why | -| ---------------------------- | ------------------------- | ----------------------------------------------------------------- | -| Interactive development | `flint` or `flint --fast` | Full output so you can read the details | -| Human wanting a summary | `flint --short` | Compact output, no per-check noise | -| Pre-push hook (CC / agentic) | `flint --fix --fast` | Fixes what it can silently, surfaces only what needs human review | -| CI | `flint` | Full output for humans reading CI logs | +| Context | Command | Why | +| ---------------------------- | ------------------------------------ | ----------------------------------------------------------------- | +| Interactive development | `flint run` or `flint run --fast-only` | Full output so you can read the details | +| Human wanting a summary | `flint run --short` | Compact output, no per-check noise | +| Pre-push hook (CC / agentic) | `flint run --fix --fast-only` | Fixes what it can silently, surfaces only what needs human review | +| CI | `flint run` | Full output for humans reading CI logs | **`--short` output** — failed checks partitioned by fixability, fixable ones expressed as the exact command to run: ```text -flint: 2 checks failed — flint --fix prettier cargo-fmt | review: shellcheck +flint: 2 checks failed — flint run --fix prettier cargo-fmt | review: shellcheck ``` **`--fix` output** — fixes what's fixable, then prints the full output of @@ -161,11 +164,11 @@ flint: fixed: cargo-fmt — commit before pushing | review: shellcheck Pass one or more linter names to run only those: ```bash -flint shellcheck shfmt # run only shellcheck and shfmt -flint --fix prettier # fix only prettier +flint run shellcheck shfmt # run only shellcheck and shfmt +flint run --fix prettier # fix only prettier ``` -`flint list` shows every check with its status: +`flint linters` shows every check with its status: ```text NAME BINARY STATUS SPEED PATTERNS @@ -249,7 +252,7 @@ file path — requires a directory-injection variant of the config mechanism. - `project` — invoked once with no file args; for checks with patterns set (e.g. `cargo-clippy`), skipped entirely if no matching files changed -**Slow checks** (`renovate-deps`) are skipped by `--fast`. Use `--fast` for +**Slow checks** (`renovate-deps`) are skipped by `--fast-only`. Use `--fast-only` for local/pre-push feedback and the full set in CI. **`ec` deference**: `ec` (editorconfig-checker) runs on all files, but @@ -285,7 +288,7 @@ Verifies `.github/renovate-tracked-deps.json` is up to date by running Renovate locally and comparing its output against the committed snapshot. Same purpose as the v1 `lint:renovate-deps` task. Requires `renovate` in `[tools]`. -Tagged `slow = true` — skipped by `--fast`. With `--fix`, automatically regenerates +Tagged `slow = true` — skipped by `--fast-only`. With `--fix`, automatically regenerates and commits the snapshot. Configure via `flint.toml`: @@ -327,7 +330,7 @@ use everywhere" promise of mise. Container startup also adds latency to every ru 2. **Fast** — native execution only (no Docker). Linters run in parallel. Designed to be the default `mise run lint`, not a slow fallback. - Slow checks (e.g. `renovate-deps`) can be skipped with `--fast`. + Slow checks (e.g. `renovate-deps`) can be skipped with `--fast-only`. 3. **Cross-platform** — runs on Linux, macOS, and Windows. The built-in registry accounts for platform differences (e.g. binary names, path quoting). @@ -349,13 +352,17 @@ use everywhere" promise of mise. Container startup also adds latency to every ru in `mise.toml`. `flint.toml` adds detail (config paths, exclusions) but is not required to activate anything. -7. **Changed files by default** — git-aware diff detection. `--from-ref`/`--to-ref` +7. **Changed files by default** — git-aware diff detection. `--new-from-rev`/`--to-ref` for CI. `--full` to check everything. Falls back to all files when no merge base is found. 8. **Autofix where possible** — `--fix` checks first, fixes what's fixable, reports what needs review. Fix mode runs serially to avoid concurrent writes. - Pass specific linter names to limit which fixers run (`flint --fix prettier shfmt`). + Pass specific linter names to limit which fixers run (`flint run --fix prettier shfmt`). + +9. **Familiar CLI** — commands and flags follow [golangci-lint](https://golangci-lint.run/) + conventions (`run`, `linters`, `--fast-only`, `--new-from-rev`) so teams + already familiar with golangci-lint don't need to re-learn the interface. ## Versioning diff --git a/mise.toml b/mise.toml index 026148d..a223431 100644 --- a/mise.toml +++ b/mise.toml @@ -22,15 +22,15 @@ file = "tasks/setup/update-super-linter-versions.sh" [tasks.lint] description = "Run all lints" -run = "cargo run -q" +run = "cargo run -q -- run" [tasks."lint:fix"] description = "Auto-fix lint issues" -run = "cargo run -q -- --fix" +run = "cargo run -q -- run --fix" [tasks."lint:pre-commit"] description = "Fast auto-fix lint pass (skips slow checks like renovate) — intended for pre-commit/pre-push hooks" -run = "cargo run -q -- --auto --fast" +run = "cargo run -q -- run --fix --fast-only" [tasks.build] description = "Build the project" diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 1453b40..2404e9c 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -42,7 +42,7 @@ async fn run_inner( }); } return Ok(LinterOutput::err(format!( - "ERROR: {COMMITTED_DISPLAY} does not exist.\nRun `flint --fix renovate-deps` to create it.\n" + "ERROR: {COMMITTED_DISPLAY} does not exist.\nRun `flint run --fix renovate-deps` to create it.\n" ))); } @@ -73,7 +73,7 @@ async fn run_inner( ok: false, stdout: diff.into_bytes(), stderr: format!( - "ERROR: {COMMITTED_FILE} is out of date.\nRun `flint --fix renovate-deps` to update.\n" + "ERROR: {COMMITTED_FILE} is out of date.\nRun `flint run --fix renovate-deps` to update.\n" ) .into_bytes(), }) diff --git a/src/main.rs b/src/main.rs index 2c312e4..dfe38c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,56 +5,64 @@ mod registry; mod runner; use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use runner::{CheckResult, RunOptions}; use std::collections::HashMap; #[derive(Parser, Debug)] #[command(name = "flint", about = "flint — fast lint")] -#[command(args_conflicts_with_subcommands = true)] +#[command(subcommand_required = true, arg_required_else_help = true)] struct Cli { #[command(subcommand)] - command: Option, + command: SubCommand, +} + +#[derive(Subcommand, Debug)] +enum SubCommand { + /// Lint the code. + Run(RunArgs), + /// List available linters and their status. + Linters, + /// Display the flint version. + Version, +} +#[derive(Args, Debug)] +struct RunArgs { /// Fix what's fixable, report what still needs review. /// Exits 1 if anything was fixed (uncommitted) or needs review; 0 if already clean. #[arg(long, env = "FLINT_FIX")] fix: bool, - /// Lint all files instead of only changed files + /// Lint all files instead of only changed files. #[arg(long, env = "FLINT_FULL")] full: bool, - /// Skip slow checks - #[arg(long, env = "FLINT_FAST")] - fast: bool, + /// Run only fast linters. Overridden by explicitly named linters. + #[arg(long, env = "FLINT_FAST_ONLY")] + fast_only: bool, - /// Show all linter output, not just failures + /// Show all linter output, not just failures. #[arg(long, env = "FLINT_VERBOSE")] verbose: bool, - /// Compact summary output — no per-check noise (human) or read-only AI review + /// Compact summary output — no per-check noise (human) or read-only AI review. #[arg(long, env = "FLINT_SHORT")] short: bool, - /// Compare changed files from this ref (default: merge base with base branch) - #[arg(long, env = "FLINT_FROM_REF")] - from_ref: Option, + /// Show only new issues created after git revision REV + /// (default: merge base with base branch). + #[arg(long, value_name = "REV", env = "FLINT_NEW_FROM_REV")] + new_from_rev: Option, - /// Compare changed files to this ref (default: HEAD) - #[arg(long, env = "FLINT_TO_REF")] + /// Compare changed files to this ref (default: HEAD). + #[arg(long, value_name = "REF", env = "FLINT_TO_REF")] to_ref: Option, - /// Linters to run (default: all discovered) + /// Linters to run (default: all discovered). Explicit linters override --fast-only. linters: Vec, } -#[derive(Subcommand, Debug)] -enum SubCommand { - /// List all available checks with their status - List, -} - #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -71,20 +79,36 @@ async fn main() -> Result<()> { let registry = registry::builtin(); - if let Some(SubCommand::List) = cli.command { - let mise_tools = registry::read_mise_tools(&project_root); - print_list(®istry, &mise_tools); - return Ok(()); + match cli.command { + SubCommand::Version => { + println!("flint {}", env!("CARGO_PKG_VERSION")); + } + SubCommand::Linters => { + let mise_tools = registry::read_mise_tools(&project_root); + print_linters(®istry, &mise_tools); + } + SubCommand::Run(args) => { + run(args, &project_root, &config_dir, ®istry).await?; + } } - let cfg = config::load(&config_dir)?; + Ok(()) +} + +async fn run( + args: RunArgs, + project_root: &std::path::Path, + config_dir: &std::path::Path, + registry: &[registry::Check], +) -> Result<()> { + let cfg = config::load(config_dir)?; // Filter registry to requested linters (or all if none specified). - let checks: Vec<®istry::Check> = if cli.linters.is_empty() { - registry.iter().collect() - } else { + // Explicit linter names override --fast-only (same behaviour as golangci-lint). + let explicit = !args.linters.is_empty(); + let checks: Vec<®istry::Check> = if explicit { let mut out = vec![]; - for name in &cli.linters { + for name in &args.linters { match registry.iter().find(|c| c.name == name.as_str()) { Some(c) => out.push(c), None => { @@ -94,26 +118,29 @@ async fn main() -> Result<()> { } } out + } else { + registry.iter().collect() }; // Discover which checks are declared in the consuming repo's mise.toml, and apply - // --fast filter. mise guarantees declared tools are on PATH, so no PATH check needed. - let mise_tools = registry::read_mise_tools(&project_root); + // --fast-only filter (skipped when linters are named explicitly). + // mise guarantees declared tools are on PATH, so no PATH check needed. + let mise_tools = registry::read_mise_tools(project_root); let active: Vec<®istry::Check> = checks .into_iter() .filter(|c| registry::check_active(c, &mise_tools)) - .filter(|c| !cli.fast || !c.slow) + .filter(|c| explicit || !args.fast_only || !c.slow) .collect(); let file_list = files::changed( - &project_root, + project_root, &cfg, - cli.full, - cli.from_ref.as_deref(), - cli.to_ref.as_deref(), + args.full, + args.new_from_rev.as_deref(), + args.to_ref.as_deref(), )?; - if cli.fix { + if args.fix { // Pre-check, fix what's fixable, report outcome. // Exits 0 if everything was already clean; 1 if anything was fixed (uncommitted) // or still needs review. @@ -125,9 +152,9 @@ async fn main() -> Result<()> { verbose: false, short: true, }, - &project_root, + project_root, &cfg, - &config_dir, + config_dir, ) .await?; @@ -153,9 +180,9 @@ async fn main() -> Result<()> { verbose: false, short: true, }, - &project_root, + project_root, &cfg, - &config_dir, + config_dir, ) .await?; for r in fix_results { @@ -209,12 +236,12 @@ async fn main() -> Result<()> { &file_list, RunOptions { fix: false, - verbose: cli.verbose, - short: cli.short, + verbose: args.verbose, + short: args.short, }, - &project_root, + project_root, &cfg, - &config_dir, + config_dir, ) .await?; @@ -227,7 +254,7 @@ async fn main() -> Result<()> { if !failed.is_empty() { let n = failed.len(); let noun = if n == 1 { "check" } else { "checks" }; - if cli.short { + if args.short { // Partition by fixability. Emit the exact command for fixable checks // so AI callers can act without a reasoning step. let (fixable, reviewable): (Vec<&str>, Vec<&str>) = failed @@ -236,7 +263,7 @@ async fn main() -> Result<()> { .partition(|name| is_fixable(name, &active)); let mut segments = vec![]; if !fixable.is_empty() { - segments.push(format!("flint --fix {}", fixable.join(" "))); + segments.push(format!("flint run --fix {}", fixable.join(" "))); } if !reviewable.is_empty() { segments.push(format!("review: {}", reviewable.join(", "))); @@ -248,7 +275,7 @@ async fn main() -> Result<()> { names = failed.join(", ") ); eprintln!( - "šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify." + "šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify." ); } std::process::exit(1); @@ -261,7 +288,7 @@ fn is_fixable(name: &str, active: &[®istry::Check]) -> bool { active.iter().any(|c| c.name == name && c.has_fix()) } -fn print_list(registry: &[registry::Check], mise_tools: &HashMap) { +fn print_linters(registry: &[registry::Check], mise_tools: &HashMap) { // Column widths. let name_w = registry .iter() diff --git a/src/registry.rs b/src/registry.rs index 3f8c832..09dece3 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -48,7 +48,7 @@ pub struct Check { /// this check's file list. Used to avoid double-checking files that a /// dedicated formatter already owns. pub excludes_if_active: &'static [&'static str], - /// Slow checks are skipped when `--fast` is passed. + /// Slow checks are skipped when `--fast-only` is passed. pub slow: bool, /// When set, look for `(filename, flag)` in config_dir: if the file exists, inject /// `flag ` into the command right after the binary name. @@ -179,7 +179,7 @@ impl Check { self } - /// Mark as slow — skipped when `--fast` is passed. + /// Mark as slow — skipped when `--fast-only` is passed. pub fn slow(mut self) -> Self { self.slow = true; self diff --git a/tests/cases/cargo-fmt/auto-fix/test.toml b/tests/cases/cargo-fmt/auto-fix/test.toml index a9bba2a..85fd6d4 100644 --- a/tests/cases/cargo-fmt/auto-fix/test.toml +++ b/tests/cases/cargo-fmt/auto-fix/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --fix cargo-fmt" +args = "run --full --fix cargo-fmt" exit = 1 stderr = """ flint: fixed: cargo-fmt — commit before pushing diff --git a/tests/cases/cargo-fmt/failure/test.toml b/tests/cases/cargo-fmt/failure/test.toml index ea728a3..4b167d6 100644 --- a/tests/cases/cargo-fmt/failure/test.toml +++ b/tests/cases/cargo-fmt/failure/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full cargo-fmt" +args = "run --full cargo-fmt" exit = 1 stderr = """ [cargo-fmt] @@ -12,5 +12,5 @@ Diff in /src/lib.rs:1: flint: 1 check failed (cargo-fmt) -šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. """ \ No newline at end of file diff --git a/tests/cases/general/auto-fix-and-review/test.toml b/tests/cases/general/auto-fix-and-review/test.toml index 957e15e..0969d79 100644 --- a/tests/cases/general/auto-fix-and-review/test.toml +++ b/tests/cases/general/auto-fix-and-review/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --fix cargo-fmt shellcheck" +args = "run --full --fix cargo-fmt shellcheck" exit = 1 stderr = """ [shellcheck] diff --git a/tests/cases/general/auto-review-two-linters/test.toml b/tests/cases/general/auto-review-two-linters/test.toml index dcb6757..86789de 100644 --- a/tests/cases/general/auto-review-two-linters/test.toml +++ b/tests/cases/general/auto-review-two-linters/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --fix shellcheck actionlint" +args = "run --full --fix shellcheck actionlint" exit = 1 stderr = """ [actionlint] @@ -19,4 +19,11 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: review: actionlint, shellcheck -""" \ No newline at end of file +""" + +[fake_bins] +actionlint = ''' +#!/bin/sh +printf '.github/workflows/ci.yml:6:23: undefined variable "foo". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression]\n |\n6 | - run: echo ${{ foo.bar }}\n | ^~~~~~~\n' +exit 1 +''' \ No newline at end of file diff --git a/tests/cases/general/auto-review-unfixable/test.toml b/tests/cases/general/auto-review-unfixable/test.toml index 9ab3dba..078054a 100644 --- a/tests/cases/general/auto-review-unfixable/test.toml +++ b/tests/cases/general/auto-review-unfixable/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --fix shellcheck" +args = "run --full --fix shellcheck" exit = 1 stderr = """ [shellcheck] diff --git a/tests/cases/general/env-var-exclude/test.toml b/tests/cases/general/env-var-exclude/test.toml index 156f63c..fed794b 100644 --- a/tests/cases/general/env-var-exclude/test.toml +++ b/tests/cases/general/env-var-exclude/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full shellcheck" +args = "run --full shellcheck" exit = 0 diff --git a/tests/cases/general/exclude-paths/test.toml b/tests/cases/general/exclude-paths/test.toml index a4f669e..0ce0ca5 100644 --- a/tests/cases/general/exclude-paths/test.toml +++ b/tests/cases/general/exclude-paths/test.toml @@ -1,3 +1,3 @@ [expected] -args = "--full shellcheck" +args = "run --full shellcheck" exit = 0 diff --git a/tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json b/tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..b46b339 --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json @@ -0,0 +1,8 @@ +{ + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} diff --git a/tests/cases/general/fast-only-explicit-override/files/.github/renovate.json5 b/tests/cases/general/fast-only-explicit-override/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/general/fast-only-explicit-override/files/mise.toml b/tests/cases/general/fast-only-explicit-override/files/mise.toml new file mode 100644 index 0000000..bd7b0a1 --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +"npm:renovate" = "latest" + diff --git a/tests/cases/general/fast-only-explicit-override/files/package.json b/tests/cases/general/fast-only-explicit-override/files/package.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/files/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/general/fast-only-explicit-override/test.toml b/tests/cases/general/fast-only-explicit-override/test.toml new file mode 100644 index 0000000..fd1cb77 --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/test.toml @@ -0,0 +1,12 @@ +# --fast-only is overridden when linters are named explicitly. +# renovate-deps is marked slow, so it would be skipped by --fast-only without explicit naming. +# Naming it explicitly must run it regardless. +[expected] +args = "run --full --fast-only renovate-deps" +exit = 0 + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' diff --git a/tests/cases/lychee/broken-link/test.toml b/tests/cases/lychee/broken-link/test.toml index d67b762..41cb96c 100644 --- a/tests/cases/lychee/broken-link/test.toml +++ b/tests/cases/lychee/broken-link/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full lychee" +args = "run --full lychee" exit = 1 stderr = """ [lychee] @@ -7,7 +7,7 @@ stderr = """ [404] https://example.com/does-not-exist flint: 1 check failed (lychee) -šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. """ [env] diff --git a/tests/cases/lychee/clean/test.toml b/tests/cases/lychee/clean/test.toml index f2a05a8..0d3f704 100644 --- a/tests/cases/lychee/clean/test.toml +++ b/tests/cases/lychee/clean/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full lychee" +args = "run --full lychee" exit = 0 [env] diff --git a/tests/cases/renovate-deps/fix-create/test.toml b/tests/cases/renovate-deps/fix-create/test.toml index 801b3a1..16fa24b 100644 --- a/tests/cases/renovate-deps/fix-create/test.toml +++ b/tests/cases/renovate-deps/fix-create/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --fix renovate-deps" +args = "run --full --fix renovate-deps" exit = 1 stderr = """ flint: fixed: renovate-deps — commit before pushing diff --git a/tests/cases/renovate-deps/fix-update/test.toml b/tests/cases/renovate-deps/fix-update/test.toml index 801b3a1..16fa24b 100644 --- a/tests/cases/renovate-deps/fix-update/test.toml +++ b/tests/cases/renovate-deps/fix-update/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full --fix renovate-deps" +args = "run --full --fix renovate-deps" exit = 1 stderr = """ flint: fixed: renovate-deps — commit before pushing diff --git a/tests/cases/renovate-deps/out-of-date/test.toml b/tests/cases/renovate-deps/out-of-date/test.toml index 430b1fd..0e49656 100644 --- a/tests/cases/renovate-deps/out-of-date/test.toml +++ b/tests/cases/renovate-deps/out-of-date/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full renovate-deps" +args = "run --full renovate-deps" exit = 1 stderr = """ [renovate-deps] @@ -16,10 +16,10 @@ stderr = """ } } ERROR: renovate-tracked-deps.json is out of date. -Run `flint --fix renovate-deps` to update. +Run `flint run --fix renovate-deps` to update. flint: 1 check failed (renovate-deps) -šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. """ [expected.files] diff --git a/tests/cases/renovate-deps/up-to-date/test.toml b/tests/cases/renovate-deps/up-to-date/test.toml index 9e83cb6..05e0a36 100644 --- a/tests/cases/renovate-deps/up-to-date/test.toml +++ b/tests/cases/renovate-deps/up-to-date/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full renovate-deps" +args = "run --full renovate-deps" exit = 0 [fake_bins] diff --git a/tests/cases/shellcheck/clean/test.toml b/tests/cases/shellcheck/clean/test.toml index a4f669e..0ce0ca5 100644 --- a/tests/cases/shellcheck/clean/test.toml +++ b/tests/cases/shellcheck/clean/test.toml @@ -1,3 +1,3 @@ [expected] -args = "--full shellcheck" +args = "run --full shellcheck" exit = 0 diff --git a/tests/cases/shellcheck/config-dir/test.toml b/tests/cases/shellcheck/config-dir/test.toml index 9457f57..10cb873 100644 --- a/tests/cases/shellcheck/config-dir/test.toml +++ b/tests/cases/shellcheck/config-dir/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full shellcheck" +args = "run --full shellcheck" exit = 0 diff --git a/tests/cases/shellcheck/failure/test.toml b/tests/cases/shellcheck/failure/test.toml index adb9924..64d581d 100644 --- a/tests/cases/shellcheck/failure/test.toml +++ b/tests/cases/shellcheck/failure/test.toml @@ -1,5 +1,5 @@ [expected] -args = "--full shellcheck" +args = "run --full shellcheck" exit = 1 stderr = """ [shellcheck] @@ -15,5 +15,5 @@ For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: 1 check failed (shellcheck) -šŸ’” Try `mise run lint:fix` to auto-fix lint issues, then re-run `mise run lint` to verify. +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. """ \ No newline at end of file From c2ff8f6549b7e49854f02f72afaa8b6fdacca894 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 15:37:17 +0200 Subject: [PATCH 057/141] feat: smoke-test all registry linters are detected and their binaries found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace markdownlint with markdownlint-cli2 (the actively maintained successor) - Fix tool detection for npm/pipx/github-prefixed mise keys: expand aliases in read_mise_tools so "npm:prettier" also registers "prettier", etc. - Fix explicit mise_tool mismatches: ec→editorconfig-checker, markdownlint→markdownlint-cli, ubi:→github: for ktlint and google-java-format, dotnet-format bin_name dotnet-format→dotnet - Add verbose mode listing of active linters (--verbose) - Merge STATUS+FOUND into single STATUS column in flint list (active/no binary/missing) - Add all_flint_repo_linters_detected unit test: verifies every tool declared in mise.toml is correctly detected as active - Add all_registry_binaries_found unit test: checks every registry binary is on PATH — intentionally fails on machines missing tools - Add flint list e2e smoke test with fake binaries - Add hadolint, ktlint, dotnet to mise.toml so all registry linters are covered - Fix strip_ansi to handle ESC(B character-set sequences emitted by newer rustfmt - Add MIGRATION.md with markdownlint-cli → markdownlint-cli2 upgrade guide --- .github/renovate-tracked-deps.json | 5 +- MIGRATION.md | 24 ++++++ mise.toml | 5 +- src/main.rs | 19 ++++- src/registry.rs | 104 +++++++++++++++++++++-- tests/cases/general/list/files/mise.toml | 13 +++ tests/cases/general/list/test.toml | 69 +++++++++++++++ tests/e2e.rs | 33 +++++-- 8 files changed, 254 insertions(+), 18 deletions(-) create mode 100644 MIGRATION.md create mode 100644 tests/cases/general/list/files/mise.toml create mode 100644 tests/cases/general/list/test.toml diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index b1919c7..9f1464c 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -7,11 +7,14 @@ "mise.toml": { "mise": [ "actionlint", + "dotnet", "editorconfig-checker", + "github:pinterest/ktlint", + "hadolint", "lychee", "node", "npm:@biomejs/biome", - "npm:markdownlint-cli", + "npm:markdownlint-cli2", "npm:prettier", "npm:renovate", "pipx:codespell", diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..083ce15 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,24 @@ +# Migration Guide + +## Replacing `markdownlint-cli` with `markdownlint-cli2` + +`markdownlint-cli2` is the actively maintained successor to `markdownlint-cli`. +It is faster, supports more configuration options, and is the direction the +markdownlint ecosystem is moving. flint only supports `markdownlint-cli2`. + +**Before** (`mise.toml`): +```toml +"npm:markdownlint-cli" = "0.47.0" +``` + +**After**: +```toml +"npm:markdownlint-cli2" = "0.17.2" +``` + +Configuration files remain compatible — both tools read `.markdownlint.json` +(and `.markdownlint.yaml`, `.markdownlint.jsonc`). No changes to your config +file are required. + +The fix command changes from `markdownlint --fix` to `markdownlint-cli2 --fix`, +but flint handles this automatically. diff --git a/mise.toml b/mise.toml index a223431..a2442b3 100644 --- a/mise.toml +++ b/mise.toml @@ -9,12 +9,15 @@ shellcheck = "v0.11.0" shfmt = "v3.12.0" actionlint = "1.7.10" editorconfig-checker = "v3.6.1" -"npm:markdownlint-cli" = "0.47.0" +"npm:markdownlint-cli2" = "0.17.2" "npm:prettier" = "3.8.1" "npm:@biomejs/biome" = "2.3.14" "pipx:ruff" = "0.15.0" "pipx:codespell" = "2.4.1" rust = { version = "1.94.1", components = "clippy,rustfmt" } +hadolint = "2.14.0" +"github:pinterest/ktlint" = "1.8.0" +dotnet = "10.0.201" [tasks."setup:update-super-linter-versions"] description = "Generate super-linter version mapping from the super-linter repo" diff --git a/src/main.rs b/src/main.rs index dfe38c6..5198b2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,6 +132,15 @@ async fn run( .filter(|c| explicit || !args.fast_only || !c.slow) .collect(); + if cli.verbose { + let names: Vec<&str> = active.iter().map(|c| c.name).collect(); + if names.is_empty() { + eprintln!("flint: no active linters"); + } else { + eprintln!("flint: active linters: {}", names.join(", ")); + } + } + let file_list = files::changed( project_root, &cfg, @@ -304,7 +313,7 @@ fn print_linters(registry: &[registry::Check], mise_tools: &HashMap bool { + !matches!(self.kind, CheckKind::Special(SpecialKind::LicenseHeader)) + } + // --- Constructors --- /// Check invoked once per matched file (`{FILE}`). `name` is also used as `bin_name`. @@ -223,9 +228,6 @@ pub fn builtin() -> Vec { Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) .fix("shfmt -w {FILE}") .formatter(), - Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) - .fix("markdownlint --fix {FILE}") - .linter_config(".markdownlint.json", "--config"), Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) .fix("markdownlint-cli2 --fix {FILE}") .linter_config(".markdownlint.json", "--config"), @@ -256,6 +258,7 @@ pub fn builtin() -> Vec { // that conflict with ec's max_line_length editorconfig check. // Note: ec's -config flag controls ec's own JSON config, not .editorconfig itself. Check::files("ec", "ec {FILES}", &["*"]) + .mise_tool("editorconfig-checker") .defer_to_formatters() .linter_config(".editorconfig-checker.json", "-config"), Check::project( @@ -303,11 +306,11 @@ pub fn builtin() -> Vec { &["*.java"], ) .fix("google-java-format -i {FILES}") - .mise_tool("ubi:google/google-java-format") + .mise_tool("github:google/google-java-format") .formatter(), Check::files("ktlint", "ktlint {FILES}", &["*.kt", "*.kts"]) .fix("ktlint --format {FILES}") - .mise_tool("ubi:pinterest/ktlint") + .mise_tool("github:pinterest/ktlint") .bin(if cfg!(windows) { "ktlint.bat" } else { @@ -320,6 +323,7 @@ pub fn builtin() -> Vec { &["*.cs"], ) .fix("dotnet format") + .bin("dotnet") .mise_tool("dotnet") .slow() .formatter(), @@ -338,6 +342,14 @@ pub fn builtin() -> Vec { /// Reads `[tools]` from the consuming repo's mise.toml and returns a map of /// tool name → declared version string. +/// +/// Also registers normalized aliases for backend-prefixed tools so that checks +/// can match by their bare package/binary name. For example: +/// - `"npm:prettier"` → also registers `"prettier"` +/// - `"npm:@biomejs/biome"` → also registers `"biome"` (last path component) +/// - `"github:google/google-java-format"` → also registers `"google-java-format"` +/// +/// The original key is always preserved; aliases only fill in missing entries. pub fn read_mise_tools(project_root: &Path) -> HashMap { let path = project_root.join("mise.toml"); let content = match std::fs::read_to_string(&path) { @@ -363,6 +375,20 @@ pub fn read_mise_tools(project_root: &Path) -> HashMap { } } } + // Add normalized aliases: strip the backend prefix (e.g. "npm:", "pipx:", "ubi:") + // and take the last path component (e.g. "@biomejs/biome" → "biome"). + // Aliases never override an explicitly declared entry. + let aliases: Vec<(String, String)> = tools + .iter() + .filter_map(|(k, v)| { + let (_, rest) = k.split_once(':')?; + let base = rest.rsplit('/').next().unwrap_or(rest); + Some((base.to_string(), v.clone())) + }) + .collect(); + for (alias, version) in aliases { + tools.entry(alias).or_insert(version); + } tools } @@ -385,6 +411,18 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool coerce_version(declared).is_some_and(|v| req.matches(&v)) } +/// Returns true if `bin_name` exists as a file in any directory in `path_var` +/// (a `:`-separated PATH string). Accepts the PATH string as a parameter so +/// callers can substitute a test-controlled path without mutating env vars. +pub fn binary_on_path_var(bin_name: &str, path_var: &str) -> bool { + std::env::split_paths(path_var).any(|dir| dir.join(bin_name).is_file()) +} + +/// Returns true if `bin_name` is found in the current `PATH`. +pub fn binary_on_path(bin_name: &str) -> bool { + binary_on_path_var(bin_name, &std::env::var("PATH").unwrap_or_default()) +} + /// Parses a version string, padding with `.0` components if needed to satisfy /// semver's three-part requirement (e.g. `"20"` → `20.0.0`, `"3.12"` → `3.12.0`). fn coerce_version(s: &str) -> Option { @@ -428,4 +466,60 @@ mod tests { } } } + + /// Checks that every linter in the registry that uses an external binary + /// actually has that binary on PATH. Covers all registry entries, not just + /// those active in this repo — so tools like ktlint and hadolint are checked + /// even if they are not declared in this repo's mise.toml. + /// + /// This test will fail on machines where not all linter tools are installed, + /// which is intentional: it identifies what is missing. + #[test] + fn all_registry_binaries_found() { + let registry = builtin(); + + let not_found: Vec<&str> = registry + .iter() + .filter(|c| c.uses_binary()) + .filter(|c| !binary_on_path(c.bin_name)) + .map(|c| c.name) + .collect(); + + assert!( + not_found.is_empty(), + "registry linters missing binary on PATH: {}", + not_found.join(", ") + ); + } + + /// Smoke test: every check whose tool key resolves in this repo's expanded + /// mise_tools map must pass check_active. This catches tool-name mismatches + /// (wrong lookup key) and version-range violations without a hardcoded list — + /// new registry entries are covered automatically. + #[test] + fn all_flint_repo_linters_detected() { + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mise_tools = read_mise_tools(project_root); + let registry = builtin(); + + let inactive: Vec<&str> = registry + .iter() + .filter(|c| { + // A check is "expected" if its lookup key appears in the expanded + // mise_tools map, or if it activates unconditionally. + c.activate_unconditionally || { + let lookup = c.mise_tool_name.unwrap_or(c.bin_name); + mise_tools.contains_key(lookup) + } + }) + .filter(|c| !check_active(c, &mise_tools)) + .map(|c| c.name) + .collect(); + + assert!( + inactive.is_empty(), + "linters not detected in flint repo: {}", + inactive.join(", ") + ); + } } diff --git a/tests/cases/general/list/files/mise.toml b/tests/cases/general/list/files/mise.toml new file mode 100644 index 0000000..005149a --- /dev/null +++ b/tests/cases/general/list/files/mise.toml @@ -0,0 +1,13 @@ +[tools] +lychee = "0.22.0" +"npm:renovate" = "43.92.1" +shellcheck = "v0.11.0" +shfmt = "v3.12.0" +actionlint = "1.7.10" +editorconfig-checker = "v3.6.1" +"npm:markdownlint-cli2" = "0.17.2" +"npm:prettier" = "3.8.1" +"npm:@biomejs/biome" = "2.3.14" +"pipx:ruff" = "0.15.0" +"pipx:codespell" = "2.4.1" +rust = { version = "1.94.1", components = "clippy,rustfmt" } diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml new file mode 100644 index 0000000..adcd142 --- /dev/null +++ b/tests/cases/general/list/test.toml @@ -0,0 +1,69 @@ +[expected] +args = "list" +exit = 0 +stdout = """ +NAME BINARY STATUS SPEED PATTERNS +----------------------------------------------------------------------- +shellcheck shellcheck active fast *.sh *.bash *.bats +shfmt shfmt active fast *.sh *.bash +markdownlint-cli2 markdownlint-cli2 active fast *.md +prettier prettier active fast *.md *.yml *.yaml +actionlint actionlint active fast .github/workflows/*.yml .github/workflows/*.yaml +hadolint hadolint missing fast Dockerfile Dockerfile.* *.dockerfile +codespell codespell active fast * +ec ec active fast * +golangci-lint golangci-lint missing fast *.go +ruff ruff active fast *.py +ruff-format ruff active fast *.py +biome biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx +biome-format biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx +cargo-clippy cargo-clippy active fast *.rs +cargo-fmt cargo-fmt active fast *.rs +gofmt gofmt missing fast *.go +google-java-format google-java-format missing fast *.java +ktlint ktlint missing fast *.kt *.kts +dotnet-format dotnet missing slow *.cs +lychee lychee active fast +renovate-deps renovate active slow +license-header license-header active fast +""" +[fake_bins] +actionlint = ''' +#!/bin/sh +''' +biome = ''' +#!/bin/sh +''' +cargo-clippy = ''' +#!/bin/sh +''' +cargo-fmt = ''' +#!/bin/sh +''' +codespell = ''' +#!/bin/sh +''' +ec = ''' +#!/bin/sh +''' +lychee = ''' +#!/bin/sh +''' +markdownlint-cli2 = ''' +#!/bin/sh +''' +prettier = ''' +#!/bin/sh +''' +renovate = ''' +#!/bin/sh +''' +ruff = ''' +#!/bin/sh +''' +shellcheck = ''' +#!/bin/sh +''' +shfmt = ''' +#!/bin/sh +''' diff --git a/tests/e2e.rs b/tests/e2e.rs index 174c424..a0439ef 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -259,22 +259,39 @@ fn toml_escape(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } -/// Strips ANSI escape sequences (e.g. colour codes from cargo fmt diffs). +/// Strips ANSI/VT escape sequences (colour codes, character-set switches, etc.). /// TOML strings cannot contain raw control characters, so these must be removed. fn strip_ansi(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { - if c == '\x1b' && chars.peek() == Some(&'[') { - chars.next(); // consume '[' - while let Some(&next) = chars.peek() { + if c != '\x1b' { + out.push(c); + continue; + } + match chars.peek().copied() { + Some('[') => { + // CSI sequence: ESC [ chars.next(); - if next.is_ascii_alphabetic() { - break; + while let Some(&next) = chars.peek() { + chars.next(); + if next.is_ascii_alphabetic() { + break; + } } } - } else { - out.push(c); + Some(next) if ('\x20'..='\x2f').contains(&next) => { + // Two-byte sequence with intermediate: ESC + // e.g. ESC(B (select ASCII character set) + chars.next(); + while let Some(&next) = chars.peek() { + chars.next(); + if ('\x30'..='\x7e').contains(&next) { + break; + } + } + } + _ => {} // bare ESC — drop it } } out From 1b87df660c9c59b59114bb6c60adb97eb3e5a065 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 15:37:33 +0200 Subject: [PATCH 058/141] chore: reformat tables and fix minor whitespace in docs and tests --- .github/agents/knowledge/README.md | 12 ++++++------ .github/agents/knowledge/design.md | 1 + .github/agents/knowledge/testing.md | 1 + tests/cases/lychee/clean/test.toml | 1 + 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/agents/knowledge/README.md b/.github/agents/knowledge/README.md index 9748d2c..978136e 100644 --- a/.github/agents/knowledge/README.md +++ b/.github/agents/knowledge/README.md @@ -6,9 +6,9 @@ Load only files relevant to the current scope. ## Topics -| File | Load when | -| --- | --- | -| `architecture.md` | Navigating the codebase; understanding module roles or check kinds | -| `linters.md` | Adding, modifying, or debugging a linter; `registry.rs` changes; config injection | -| `design.md` | Questioning why something works the way it does; avoiding known pitfalls | -| `testing.md` | Writing or updating tests; adding fixture cases; regenerating snapshots | +| File | Load when | +| ----------------- | --------------------------------------------------------------------------------- | +| `architecture.md` | Navigating the codebase; understanding module roles or check kinds | +| `linters.md` | Adding, modifying, or debugging a linter; `registry.rs` changes; config injection | +| `design.md` | Questioning why something works the way it does; avoiding known pitfalls | +| `testing.md` | Writing or updating tests; adding fixture cases; regenerating snapshots | diff --git a/.github/agents/knowledge/design.md b/.github/agents/knowledge/design.md index 29de891..cdb7bb0 100644 --- a/.github/agents/knowledge/design.md +++ b/.github/agents/knowledge/design.md @@ -19,6 +19,7 @@ prettier: formatting). To avoid MD013 (line length) conflicting with prettier's line wrapping, consuming repos must disable MD013 in `.markdownlint.json`: + ```json { "MD013": false } ``` diff --git a/.github/agents/knowledge/testing.md b/.github/agents/knowledge/testing.md index 533689d..f5c90e7 100644 --- a/.github/agents/knowledge/testing.md +++ b/.github/agents/knowledge/testing.md @@ -9,6 +9,7 @@ cargo test ## Unit Tests In-module `#[cfg(test)]` blocks in `src/`. Notable: + - `src/registry.rs`: enforces version-range consistency - `src/runner.rs`: config injection, scope filtering - `src/linters/renovate_deps.rs`: log parsing, snapshot diff --git a/tests/cases/lychee/clean/test.toml b/tests/cases/lychee/clean/test.toml index 0d3f704..8254f27 100644 --- a/tests/cases/lychee/clean/test.toml +++ b/tests/cases/lychee/clean/test.toml @@ -2,6 +2,7 @@ args = "run --full lychee" exit = 0 + [env] LYCHEE_SKIP_GITHUB_REMAPS = "true" From 6911e57f187f58f2f721b7a21716d0174878f32c Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sat, 4 Apr 2026 15:45:25 +0200 Subject: [PATCH 059/141] fix: use args.verbose instead of out-of-scope cli variable in run fn --- .github/agents/knowledge/linters.md | 19 ++++---- MIGRATION.md | 2 + README.md | 70 +++++++++++++++-------------- src/main.rs | 2 +- 4 files changed, 49 insertions(+), 44 deletions(-) diff --git a/.github/agents/knowledge/linters.md b/.github/agents/knowledge/linters.md index 811d0de..2844472 100644 --- a/.github/agents/knowledge/linters.md +++ b/.github/agents/knowledge/linters.md @@ -18,15 +18,15 @@ Check::project("mytool", "mytool run", &["*.ext"]), Available builder modifiers: -| Method | Purpose | -|---|---| -| `.fix(cmd)` | Enable `--fix` mode with this command | -| `.bin(name)` | Override binary name (when check name ≠ binary) | -| `.mise_tool(name)` | Look up availability under a different mise key (e.g. `rust` for `cargo-fmt`) | -| `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | -| `.excludes(names)` | Skip files already owned by these active checks | -| `.slow()` | Mark as slow — skipped by `--fast-only` | -| `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | +| Method | Purpose | +| ---------------------------- | ----------------------------------------------------------------------------- | +| `.fix(cmd)` | Enable `--fix` mode with this command | +| `.bin(name)` | Override binary name (when check name ≠ binary) | +| `.mise_tool(name)` | Look up availability under a different mise key (e.g. `rust` for `cargo-fmt`) | +| `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | +| `.excludes(names)` | Skip files already owned by these active checks | +| `.slow()` | Mark as slow — skipped by `--fast-only` | +| `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | ## Config File Injection (`.linter_config`) @@ -45,6 +45,7 @@ Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) ``` **When NOT to use it:** + - The tool has no explicit `--config`/`--rcfile`/equivalent flag (e.g. `shfmt`) - The flag accepts a **directory** rather than a file (e.g. biome's `--config-path `) — a different injection shape is needed. For biome, diff --git a/MIGRATION.md b/MIGRATION.md index 083ce15..5bd5d3b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,11 +7,13 @@ It is faster, supports more configuration options, and is the direction the markdownlint ecosystem is moving. flint only supports `markdownlint-cli2`. **Before** (`mise.toml`): + ```toml "npm:markdownlint-cli" = "0.47.0" ``` **After**: + ```toml "npm:markdownlint-cli2" = "0.17.2" ``` diff --git a/README.md b/README.md index 0f91a20..7aac341 100644 --- a/README.md +++ b/README.md @@ -117,27 +117,27 @@ flint version `flint run` flags: -| Flag | Description | -| ------------------- | ------------------------------------------------------------------------ | -| `--fix` | Fix what's fixable, report what still needs review; exit 1 if anything changed or needs review | -| `--full` | Lint all files instead of only changed files | -| `--fast-only` | Skip slow checks (e.g. `renovate-deps`). Overridden by explicit linter names. | -| `--short` | Compact summary output, no per-check noise | -| `--verbose` | Show all linter output, not just failures | -| `--new-from-rev REV` | Diff base (default: merge base with base branch) | -| `--to-ref REF` | Diff head (default: HEAD) | +| Flag | Description | +| -------------------- | ---------------------------------------------------------------------------------------------- | +| `--fix` | Fix what's fixable, report what still needs review; exit 1 if anything changed or needs review | +| `--full` | Lint all files instead of only changed files | +| `--fast-only` | Skip slow checks (e.g. `renovate-deps`). Overridden by explicit linter names. | +| `--short` | Compact summary output, no per-check noise | +| `--verbose` | Show all linter output, not just failures | +| `--new-from-rev REV` | Diff base (default: merge base with base branch) | +| `--to-ref REF` | Diff head (default: HEAD) | Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_FAST_ONLY`, `FLINT_VERBOSE`, `FLINT_SHORT`, `FLINT_NEW_FROM_REV`, `FLINT_TO_REF`. #### Intended use by context -| Context | Command | Why | -| ---------------------------- | ------------------------------------ | ----------------------------------------------------------------- | -| Interactive development | `flint run` or `flint run --fast-only` | Full output so you can read the details | -| Human wanting a summary | `flint run --short` | Compact output, no per-check noise | -| Pre-push hook (CC / agentic) | `flint run --fix --fast-only` | Fixes what it can silently, surfaces only what needs human review | -| CI | `flint run` | Full output for humans reading CI logs | +| Context | Command | Why | +| ---------------------------- | -------------------------------------- | ----------------------------------------------------------------- | +| Interactive development | `flint run` or `flint run --fast-only` | Full output so you can read the details | +| Human wanting a summary | `flint run --short` | Compact output, no per-check noise | +| Pre-push hook (CC / agentic) | `flint run --fix --fast-only` | Fixes what it can silently, surfaces only what needs human review | +| CI | `flint run` | Full output for humans reading CI logs | **`--short` output** — failed checks partitioned by fixability, fixable ones expressed as the exact command to run: @@ -220,25 +220,25 @@ being linted and cannot be redirected via a flag. -| Name | Binary | Patterns | Fix | Scope | Config file | -| --------------- | --------------- | -------------------------------------------------- | --- | ------- | ------------------------------ | -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | `.shellcheckrc` | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | — | -| `markdownlint` | `markdownlint` | `*.md` | yes | file | `.markdownlint.json` | -| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | files | `.prettierrc` | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | `actionlint.yml` | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | `.hadolint.yaml` | -| `codespell` | `codespell` | `*` | yes | files | `.codespellrc` | -| `ec` | `ec` | `*` | no | files | `.editorconfig-checker.json` | -| `golangci-lint` | `golangci-lint` | `*.go` | no | project | `.golangci.yml` | -| `ruff` | `ruff` | `*.py` | yes | file | `ruff.toml` | -| `ruff-format` | `ruff` | `*.py` | yes | file | `ruff.toml` | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | — | -| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | — | +| Name | Binary | Patterns | Fix | Scope | Config file | +| --------------- | --------------- | -------------------------------------------------- | --- | ------- | ---------------------------------- | +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | `.shellcheckrc` | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | — | +| `markdownlint` | `markdownlint` | `*.md` | yes | file | `.markdownlint.json` | +| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | files | `.prettierrc` | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | `actionlint.yml` | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | `.hadolint.yaml` | +| `codespell` | `codespell` | `*` | yes | files | `.codespellrc` | +| `ec` | `ec` | `*` | no | files | `.editorconfig-checker.json` | +| `golangci-lint` | `golangci-lint` | `*.go` | no | project | `.golangci.yml` | +| `ruff` | `ruff` | `*.py` | yes | file | `ruff.toml` | +| `ruff-format` | `ruff` | `*.py` | yes | file | `ruff.toml` | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | — | +| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | — | | `links` | `lychee` | (all files) | no | special | via `[checks.links]` in flint.toml | -| `renovate-deps` | `renovate` | (all files) | yes | special | — | +| `renovate-deps` | `renovate` | (all files) | yes | special | — | ¹ Not yet implemented. Biome's flag (`--config-path`) takes a directory, not a file path — requires a directory-injection variant of the config mechanism. @@ -340,11 +340,13 @@ use everywhere" promise of mise. Container startup also adds latency to every ru 5. **AI-friendly** — `--fix` fixes what's fixable silently, prints output only for issues needing review, and exits with a structured summary: - ``` + + ```text [shellcheck] ... flint: fixed: cargo-fmt — commit before pushing | review: shellcheck ``` + Only unfixable issues surface for review — no reasoning step required. Also runnable containerised — no host tool dependencies required. diff --git a/src/main.rs b/src/main.rs index 5198b2c..b1d0c2a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,7 +132,7 @@ async fn run( .filter(|c| explicit || !args.fast_only || !c.slow) .collect(); - if cli.verbose { + if args.verbose { let names: Vec<&str> = active.iter().map(|c| c.name).collect(); if names.is_empty() { eprintln!("flint: no active linters"); From c903b5f5e27ff5414c87b431083593454a4eca94 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sun, 5 Apr 2026 07:29:46 +0000 Subject: [PATCH 060/141] docs: generate README linter table from registry with sync test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `flint linters --json` for structured output (name, binary, patterns, fix, slow, scope, config_file) - Add `readme_linter_table_in_sync` test that generates the table from the registry and diffs it against README; fails if out of sync - `UPDATE_README=1 cargo test readme_linter_table_in_sync` regenerates the table in-place (guarded by markers) - Add Slow column to the table; add missing linters (gofmt, google-java-format, ktlint, dotnet-format, license-header) - Fix markdownlint → markdownlint-cli2 in table and Getting Started example - Fix lychee check name (was incorrectly shown as `links` in the table) --- README.md | 58 +++++++++++---------- src/main.rs | 45 +++++++++++++++-- src/registry.rs | 132 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7aac341..739d006 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ flint = "0.x.y" shellcheck = "v0.11.0" shfmt = "v3.12.0" actionlint = "1.7.10" -"npm:markdownlint-cli" = "0.47.0" +"npm:markdownlint-cli2" = "0.47.0" "npm:prettier" = "3.5.0" rust = "1.87.0" # activates cargo-fmt + cargo-clippy go = "1.24.0" # activates gofmt @@ -219,32 +219,38 @@ being linted and cannot be redirected via a flag. ### Built-in linter registry - -| Name | Binary | Patterns | Fix | Scope | Config file | -| --------------- | --------------- | -------------------------------------------------- | --- | ------- | ---------------------------------- | -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | file | `.shellcheckrc` | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | file | — | -| `markdownlint` | `markdownlint` | `*.md` | yes | file | `.markdownlint.json` | -| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | files | `.prettierrc` | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | file | `actionlint.yml` | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | file | `.hadolint.yaml` | -| `codespell` | `codespell` | `*` | yes | files | `.codespellrc` | -| `ec` | `ec` | `*` | no | files | `.editorconfig-checker.json` | -| `golangci-lint` | `golangci-lint` | `*.go` | no | project | `.golangci.yml` | -| `ruff` | `ruff` | `*.py` | yes | file | `ruff.toml` | -| `ruff-format` | `ruff` | `*.py` | yes | file | `ruff.toml` | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | file | `biome.json` ¹ | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | project | — | -| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | project | — | -| `links` | `lychee` | (all files) | no | special | via `[checks.links]` in flint.toml | -| `renovate-deps` | `renovate` | (all files) | yes | special | — | - -¹ Not yet implemented. Biome's flag (`--config-path`) takes a directory, not a -file path — requires a directory-injection variant of the config mechanism. - + + +| Name | Binary | Patterns | Fix | Slow | Scope | Config file | +| -------------------- | -------------------- | -------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | — | file | — | +| `markdownlint-cli2` | `markdownlint-cli2` | `*.md` | yes | — | file | `.markdownlint.json` | +| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | — | files | `.prettierrc` | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | — | file | `actionlint.yml` | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | — | file | `.hadolint.yaml` | +| `codespell` | `codespell` | `*` | yes | — | files | `.codespellrc` | +| `ec` | `ec` | `*` | no | — | files | `.editorconfig-checker.json` | +| `golangci-lint` | `golangci-lint` | `*.go` | no | — | project | `.golangci.yml` | +| `ruff` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | +| `ruff-format` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | +| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | — | project | — | +| `gofmt` | `gofmt` | `*.go` | yes | — | file | — | +| `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | +| `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | +| `dotnet-format` | `dotnet` | `*.cs` | yes | yes | project | — | +| `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | +| `renovate-deps` | `renovate` | (all files) | yes | yes | special | — | +| `license-header` | (built-in) | (all files) | no | — | special | — | + +**Note:** Biome's config flag (`--config-path`) takes a directory, not a file path — +config injection for `biome` and `biome-format` is not yet implemented. + **Scopes:** - `file` — invoked once per matched file @@ -252,7 +258,7 @@ file path — requires a directory-injection variant of the config mechanism. - `project` — invoked once with no file args; for checks with patterns set (e.g. `cargo-clippy`), skipped entirely if no matching files changed -**Slow checks** (`renovate-deps`) are skipped by `--fast-only`. Use `--fast-only` for +**Slow checks** (Slow = yes) are skipped by `--fast-only`. Use `--fast-only` for local/pre-push feedback and the full set in CI. **`ec` deference**: `ec` (editorconfig-checker) runs on all files, but diff --git a/src/main.rs b/src/main.rs index b1d0c2a..de3ec7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod runner; use anyhow::Result; use clap::{Args, Parser, Subcommand}; +use registry::{CheckKind, Scope}; use runner::{CheckResult, RunOptions}; use std::collections::HashMap; @@ -22,11 +23,18 @@ enum SubCommand { /// Lint the code. Run(RunArgs), /// List available linters and their status. - Linters, + Linters(LintersArgs), /// Display the flint version. Version, } +#[derive(Args, Debug)] +struct LintersArgs { + /// Output as JSON instead of the human-readable table. + #[arg(long)] + json: bool, +} + #[derive(Args, Debug)] struct RunArgs { /// Fix what's fixable, report what still needs review. @@ -83,9 +91,13 @@ async fn main() -> Result<()> { SubCommand::Version => { println!("flint {}", env!("CARGO_PKG_VERSION")); } - SubCommand::Linters => { + SubCommand::Linters(args) => { let mise_tools = registry::read_mise_tools(&project_root); - print_linters(®istry, &mise_tools); + if args.json { + print_linters_json(®istry); + } else { + print_linters(®istry, &mise_tools); + } } SubCommand::Run(args) => { run(args, &project_root, &config_dir, ®istry).await?; @@ -293,6 +305,33 @@ async fn run( Ok(()) } +fn print_linters_json(registry: &[registry::Check]) { + let entries: Vec = registry.iter().map(linter_json).collect(); + println!("{}", serde_json::to_string_pretty(&entries).unwrap()); +} + +pub fn linter_json(check: ®istry::Check) -> serde_json::Value { + let scope = match &check.kind { + CheckKind::Template { scope, .. } => match scope { + Scope::File => "file", + Scope::Files => "files", + Scope::Project => "project", + }, + CheckKind::Special(_) => "special", + }; + let patterns: Vec<&str> = check.patterns.to_vec(); + let config_file: Option<&str> = check.linter_config.map(|(filename, _)| filename); + serde_json::json!({ + "name": check.name, + "binary": if check.uses_binary() { check.bin_name } else { "(built-in)" }, + "patterns": patterns, + "fix": check.has_fix(), + "slow": check.slow, + "scope": scope, + "config_file": config_file, + }) +} + fn is_fixable(name: &str, active: &[®istry::Check]) -> bool { active.iter().any(|c| c.name == name && c.has_fix()) } diff --git a/src/registry.rs b/src/registry.rs index daf2eec..111b5d6 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -492,6 +492,138 @@ mod tests { ); } + /// Verifies the README linter table is in sync with the registry. + /// Every column is checked against the registry except `config_file`, which + /// may contain hand-written footnotes or prose (e.g. the lychee config note). + /// + /// Run `UPDATE_README=1 cargo test readme_linter_table_in_sync` to regenerate. + #[test] + fn readme_linter_table_in_sync() { + let readme_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"); + let readme = std::fs::read_to_string(&readme_path).expect("README.md must be readable"); + let registry = builtin(); + + let expected = generate_readme_table(®istry); + + if std::env::var("UPDATE_README").is_ok() { + let updated = replace_readme_table(&readme, &expected); + std::fs::write(&readme_path, updated).expect("failed to write README.md"); + return; + } + + let actual = extract_readme_table(&readme); + if actual != expected { + panic!( + "README linter table is out of sync with the registry.\n\ + Run `UPDATE_README=1 cargo test readme_linter_table_in_sync` to regenerate.\n\n\ + Expected:\n{expected}\n\nActual:\n{actual}" + ); + } + } + + const README_TABLE_START: &str = ""; + const README_TABLE_END: &str = ""; + + fn extract_readme_table(readme: &str) -> String { + let start = readme + .find(README_TABLE_START) + .expect("README missing marker") + + README_TABLE_START.len(); + let end = readme + .find(README_TABLE_END) + .expect("README missing marker"); + readme[start..end].trim().to_string() + } + + fn replace_readme_table(readme: &str, table: &str) -> String { + // `start` points just after the opening marker; `&readme[..start]` includes it. + let start = readme + .find(README_TABLE_START) + .expect("README missing marker") + + README_TABLE_START.len(); + let end = readme + .find(README_TABLE_END) + .expect("README missing marker"); + format!( + "{}\n{}\n{}{}", + &readme[..start], + table, + README_TABLE_END, + &readme[end + README_TABLE_END.len()..] + ) + } + + fn generate_readme_table(registry: &[Check]) -> String { + // Build raw cell values for every row (header + data). + let headers = ["Name", "Binary", "Patterns", "Fix", "Slow", "Scope", "Config file"]; + let rows: Vec<[String; 7]> = registry.iter().map(table_row).collect(); + + // Compute column widths. + let mut widths = headers.map(|h| h.len()); + for row in &rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.len()); + } + } + + let fmt_row = |cells: &[&str]| -> String { + let cols: Vec = cells + .iter() + .enumerate() + .map(|(i, cell)| format!("{: = widths.iter().map(|&w| "-".repeat(w)).collect(); + let sep_row = format!("| {} |", separator.join(" | ")); + + let header_strs: Vec<&str> = headers.iter().copied().collect(); + let generated_comment = + ""; + let mut lines = vec![generated_comment.to_string(), fmt_row(&header_strs), sep_row]; + for row in &rows { + let strs: Vec<&str> = row.iter().map(|s| s.as_str()).collect(); + lines.push(fmt_row(&strs)); + } + lines.join("\n") + } + + fn table_row(check: &Check) -> [String; 7] { + let name = format!("`{}`", check.name); + let binary = if check.uses_binary() { + format!("`{}`", check.bin_name) + } else { + "(built-in)".to_string() + }; + let patterns = if check.patterns.is_empty() { + "(all files)".to_string() + } else { + format!("`{}`", check.patterns.join(" ")) + }; + let fix = if check.has_fix() { "yes" } else { "no" }.to_string(); + let slow = if check.slow { "yes" } else { "—" }.to_string(); + let scope = match &check.kind { + CheckKind::Template { scope, .. } => match scope { + Scope::File => "file", + Scope::Files => "files", + Scope::Project => "project", + }, + CheckKind::Special(_) => "special", + } + .to_string(); + let config_file = match check.linter_config { + Some((filename, _)) => format!("`{filename}`"), + None => match &check.kind { + CheckKind::Special(SpecialKind::Links) => { + "via `[checks.links]` in flint.toml".to_string() + } + _ => "—".to_string(), + }, + }; + [name, binary, patterns, fix, slow, scope, config_file] + } + /// Smoke test: every check whose tool key resolves in this repo's expanded /// mise_tools map must pass check_active. This catches tool-name mismatches /// (wrong lookup key) and version-range violations without a hardcoded list — From 353e535b89770eb2c4f617d439657b34b5076166 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sun, 5 Apr 2026 07:36:51 +0000 Subject: [PATCH 061/141] refactor: rename ec check to editorconfig-checker The check name should be the last segment of the mise tool key, not the binary name. `editorconfig-checker` is the mise tool; `ec` is just the binary it installs. Add a naming convention comment to the registry documenting this rule and its exception for shared toolchain keys (rust, go, dotnet) where the binary name is used instead. --- .github/agents/knowledge/design.md | 12 +++--- README.md | 60 +++++++++++++++--------------- src/registry.rs | 22 ++++++++++- 3 files changed, 57 insertions(+), 37 deletions(-) diff --git a/.github/agents/knowledge/design.md b/.github/agents/knowledge/design.md index cdb7bb0..e5568ad 100644 --- a/.github/agents/knowledge/design.md +++ b/.github/agents/knowledge/design.md @@ -5,13 +5,13 @@ the consuming repo's `mise.toml`. No PATH probing — mise guarantees declared tools are on PATH. -2. **`ec` deference**: `ec` (editorconfig-checker) runs on - all files but skips file types owned by active - line-length-enforcing formatters (`cargo-fmt`, +2. **`editorconfig-checker` deference**: `editorconfig-checker` + (binary: `ec`) runs on all files but skips file types owned + by active line-length-enforcing formatters (`cargo-fmt`, `ruff-format`, `biome-format`, `prettier`). Implemented - via `.excludes(&[...])` on the `ec` entry. This avoids - `ec`'s `max_line_length` check conflicting with - formatter output. + via `.defer_to_formatters()` on the `editorconfig-checker` + entry. This avoids its `max_line_length` check conflicting + with formatter output. 3. **markdownlint + prettier on `*.md`**: Both checkers are active when their tools are installed. They cover diff --git a/README.md b/README.md index 739d006..f0d9c78 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ the corresponding file exists there (see the "Config file" column in the table b Files that are absent are silently skipped — existing project-root configs remain in effect. -**Note:** `ec`'s config file (`.editorconfig-checker.json`) controls ec's own settings, +**Note:** `editorconfig-checker`'s config file (`.editorconfig-checker.json`) controls its own settings, not `.editorconfig` itself — editorconfig discovery always walks up from the file being linted and cannot be redirected via a flag. @@ -221,30 +221,32 @@ being linted and cannot be redirected via a flag. -| Name | Binary | Patterns | Fix | Slow | Scope | Config file | -| -------------------- | -------------------- | -------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | — | file | — | -| `markdownlint-cli2` | `markdownlint-cli2` | `*.md` | yes | — | file | `.markdownlint.json` | -| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | — | files | `.prettierrc` | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | — | file | `actionlint.yml` | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | — | file | `.hadolint.yaml` | -| `codespell` | `codespell` | `*` | yes | — | files | `.codespellrc` | -| `ec` | `ec` | `*` | no | — | files | `.editorconfig-checker.json` | -| `golangci-lint` | `golangci-lint` | `*.go` | no | — | project | `.golangci.yml` | -| `ruff` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | -| `ruff-format` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | -| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | — | project | — | -| `gofmt` | `gofmt` | `*.go` | yes | — | file | — | -| `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | -| `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | -| `dotnet-format` | `dotnet` | `*.cs` | yes | yes | project | — | -| `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | -| `renovate-deps` | `renovate` | (all files) | yes | yes | special | — | -| `license-header` | (built-in) | (all files) | no | — | special | — | + +| Name | Binary | Patterns | Fix | Slow | Scope | Config file | +| ---------------------- | -------------------- | -------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | — | file | — | +| `markdownlint-cli2` | `markdownlint-cli2` | `*.md` | yes | — | file | `.markdownlint.json` | +| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | — | files | `.prettierrc` | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | — | file | `actionlint.yml` | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | — | file | `.hadolint.yaml` | +| `codespell` | `codespell` | `*` | yes | — | files | `.codespellrc` | +| `editorconfig-checker` | `ec` | `*` | no | — | files | `.editorconfig-checker.json` | +| `golangci-lint` | `golangci-lint` | `*.go` | no | — | project | `.golangci.yml` | +| `ruff` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | +| `ruff-format` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | +| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | — | project | — | +| `gofmt` | `gofmt` | `*.go` | yes | — | file | — | +| `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | +| `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | +| `dotnet-format` | `dotnet` | `*.cs` | yes | yes | project | — | +| `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | +| `renovate-deps` | `renovate` | (all files) | yes | yes | special | — | +| `license-header` | (built-in) | (all files) | no | — | special | — | + @@ -261,13 +263,13 @@ config injection for `biome` and `biome-format` is not yet implemented. **Slow checks** (Slow = yes) are skipped by `--fast-only`. Use `--fast-only` for local/pre-push feedback and the full set in CI. -**`ec` deference**: `ec` (editorconfig-checker) runs on all files, but +**`editorconfig-checker` deference**: `editorconfig-checker` runs on all files, but automatically skips file types owned by an active line-length-enforcing formatter. When `cargo-fmt`, `ruff-format`, `biome-format`, or `prettier` -are active, their file types are excluded from `ec` — those formatters -already enforce line length and would conflict with `ec`'s +are active, their file types are excluded from `editorconfig-checker` — those formatters +already enforce line length and would conflict with `editorconfig-checker`'s `max_line_length` editorconfig check. If none of those formatters are -installed, `ec` checks those files itself. +installed, `editorconfig-checker` checks those files itself. ### Special checks diff --git a/src/registry.rs b/src/registry.rs index 111b5d6..5dc43da 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -217,6 +217,18 @@ impl Check { } } +/// Built-in linter registry. +/// +/// # Naming convention +/// +/// A check's `name` is the last path segment of its mise tool key (after `:` or `/`): +/// - `editorconfig-checker` → name `editorconfig-checker` (not the binary `ec`) +/// - `npm:markdownlint-cli2` → name `markdownlint-cli2` +/// - `github:pinterest/ktlint` → name `ktlint` +/// +/// Exception: when the mise tool key is a language toolchain shared across multiple +/// binaries (e.g. `rust`, `go`, `dotnet`), use the binary name instead — the toolchain +/// name would be ambiguous (`rust` can't name both `cargo-fmt` and `cargo-clippy`). pub fn builtin() -> Vec { vec![ Check::file( @@ -257,7 +269,8 @@ pub fn builtin() -> Vec { // Defer to formatters that enforce line length — those are the ones // that conflict with ec's max_line_length editorconfig check. // Note: ec's -config flag controls ec's own JSON config, not .editorconfig itself. - Check::files("ec", "ec {FILES}", &["*"]) + Check::files("editorconfig-checker", "ec {FILES}", &["*"]) + .bin("ec") .mise_tool("editorconfig-checker") .defer_to_formatters() .linter_config(".editorconfig-checker.json", "-config"), @@ -532,7 +545,12 @@ mod tests { let end = readme .find(README_TABLE_END) .expect("README missing marker"); - readme[start..end].trim().to_string() + // Strip blank lines that prettier inserts around comments and tables. + readme[start..end] + .lines() + .filter(|l| !l.trim().is_empty()) + .collect::>() + .join("\n") } fn replace_readme_table(readme: &str, table: &str) -> String { From 41dad73fcca43f018f40dfa06ed3718650e0c2bd Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sun, 5 Apr 2026 07:50:36 +0000 Subject: [PATCH 062/141] test: parallel e2e cases, FLINT_CASES filter, and fix stale list snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run top-level case groups in parallel threads. Add FLINT_CASES= env var to filter cases by prefix and print a rerun hint on failure. Fix general/list snapshot: rename subcommand arg list→linters and update ec→editorconfig-checker in the output. --- AGENTS-V2.md | 11 +++++ tests/cases/general/list/test.toml | 50 ++++++++++----------- tests/e2e.rs | 72 ++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 28 deletions(-) diff --git a/AGENTS-V2.md b/AGENTS-V2.md index 77a8af8..8e541ec 100644 --- a/AGENTS-V2.md +++ b/AGENTS-V2.md @@ -30,5 +30,16 @@ Do not load the entire knowledge folder by default. Run tests with `cargo test`. Tests spin up temporary git repos and run the real `flint` binary — they are integration tests, not unit tests, so they can be slow. +The `cases` test runs all fixture cases under `tests/cases/` in parallel by +top-level directory (linter group). Two env vars control its behaviour: + +- `FLINT_CASES=` — run only cases matching that prefix, e.g. + `FLINT_CASES=shellcheck` or `FLINT_CASES=shellcheck/clean`. +- `UPDATE_SNAPSHOTS=1` — regenerate golden stdout/stderr/exit in `test.toml` + instead of asserting. Always review the diff before committing. + +On failure the test prints a rerun hint, e.g.: +`FLINT_CASES=shellcheck/clean cargo test cases` + Always run `mise run lint:fix` before committing and review auto-fixed files — auto-fixes may produce unexpected results. diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index adcd142..bf58c7e 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -1,31 +1,31 @@ [expected] -args = "list" +args = "linters" exit = 0 stdout = """ -NAME BINARY STATUS SPEED PATTERNS ------------------------------------------------------------------------ -shellcheck shellcheck active fast *.sh *.bash *.bats -shfmt shfmt active fast *.sh *.bash -markdownlint-cli2 markdownlint-cli2 active fast *.md -prettier prettier active fast *.md *.yml *.yaml -actionlint actionlint active fast .github/workflows/*.yml .github/workflows/*.yaml -hadolint hadolint missing fast Dockerfile Dockerfile.* *.dockerfile -codespell codespell active fast * -ec ec active fast * -golangci-lint golangci-lint missing fast *.go -ruff ruff active fast *.py -ruff-format ruff active fast *.py -biome biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx -biome-format biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx -cargo-clippy cargo-clippy active fast *.rs -cargo-fmt cargo-fmt active fast *.rs -gofmt gofmt missing fast *.go -google-java-format google-java-format missing fast *.java -ktlint ktlint missing fast *.kt *.kts -dotnet-format dotnet missing slow *.cs -lychee lychee active fast -renovate-deps renovate active slow -license-header license-header active fast +NAME BINARY STATUS SPEED PATTERNS +------------------------------------------------------------------------- +shellcheck shellcheck active fast *.sh *.bash *.bats +shfmt shfmt active fast *.sh *.bash +markdownlint-cli2 markdownlint-cli2 active fast *.md +prettier prettier active fast *.md *.yml *.yaml +actionlint actionlint active fast .github/workflows/*.yml .github/workflows/*.yaml +hadolint hadolint missing fast Dockerfile Dockerfile.* *.dockerfile +codespell codespell active fast * +editorconfig-checker ec active fast * +golangci-lint golangci-lint missing fast *.go +ruff ruff active fast *.py +ruff-format ruff active fast *.py +biome biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx +biome-format biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx +cargo-clippy cargo-clippy active fast *.rs +cargo-fmt cargo-fmt active fast *.rs +gofmt gofmt missing fast *.go +google-java-format google-java-format missing fast *.java +ktlint ktlint missing fast *.kt *.kts +dotnet-format dotnet missing slow *.cs +lychee lychee active fast +renovate-deps renovate active slow +license-header license-header active fast """ [fake_bins] actionlint = ''' diff --git a/tests/e2e.rs b/tests/e2e.rs index a0439ef..1e9a14e 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,5 +1,7 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use std::sync::{Arc, Mutex}; use tempfile::TempDir; /// Runs the flint binary in the given directory with the given args. @@ -62,21 +64,85 @@ fn git_repo() -> TempDir { /// ''' /// /// Set UPDATE_SNAPSHOTS=1 to regenerate golden output in test.toml. +/// Set FLINT_CASES= to run only cases under that directory (e.g. FLINT_CASES=shellcheck +/// or FLINT_CASES=shellcheck/clean). Top-level groups run in parallel. #[test] fn cases() { let cases_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); let update = std::env::var("UPDATE_SNAPSHOTS").is_ok(); + let filter = std::env::var("FLINT_CASES").ok(); let mut case_paths = collect_cases(&cases_dir); case_paths.sort(); - for case in &case_paths { - let name = case + if let Some(ref f) = filter { + case_paths.retain(|p| { + let name = p.strip_prefix(&cases_dir).unwrap().to_string_lossy(); + name.starts_with(f.as_str()) + }); + if case_paths.is_empty() { + panic!("FLINT_CASES={f}: no matching cases found"); + } + } + + // Group by top-level directory (linter name) so each group runs in its own thread. + let mut groups: BTreeMap> = BTreeMap::new(); + for path in case_paths { + let top = path .strip_prefix(&cases_dir) .unwrap() + .components() + .next() + .unwrap() + .as_os_str() .to_string_lossy() .into_owned(); - run_case(&case, &name, update); + groups.entry(top).or_default().push(path); + } + + let failures: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let handles: Vec<_> = groups + .into_values() + .map(|paths| { + let cases_dir = cases_dir.clone(); + let failures = Arc::clone(&failures); + std::thread::spawn(move || { + for case in &paths { + let name = case + .strip_prefix(&cases_dir) + .unwrap() + .to_string_lossy() + .into_owned(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_case(case, &name, update); + })); + if let Err(e) = result { + let msg = e + .downcast_ref::() + .cloned() + .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| format!("panic in {name}")); + failures.lock().unwrap().push(format!( + "FAILED: {name}\n{msg}\n → rerun: FLINT_CASES={name} cargo test cases" + )); + } + } + }) + }) + .collect(); + + for h in handles { + h.join().unwrap(); + } + + let failures = failures.lock().unwrap(); + if !failures.is_empty() { + panic!( + "\n\n{}\n\n{} case(s) failed", + failures.join("\n\n"), + failures.len() + ); } } From c2afb1ce4f0cd05aa47202bcdeb285b598aec91e Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sun, 5 Apr 2026 08:31:32 +0000 Subject: [PATCH 063/141] fix: dotnet-format is fast, not slow 177ms startup time doesn't warrant slow classification. slow is reserved for CI-only checks like renovate-deps. --- src/registry.rs | 20 +++++++++++++++----- tests/cases/general/list/test.toml | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 5dc43da..d96bf1a 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -338,7 +338,6 @@ pub fn builtin() -> Vec { .fix("dotnet format") .bin("dotnet") .mise_tool("dotnet") - .slow() .formatter(), Check::special("lychee", "lychee", SpecialKind::Links), Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) @@ -573,7 +572,15 @@ mod tests { fn generate_readme_table(registry: &[Check]) -> String { // Build raw cell values for every row (header + data). - let headers = ["Name", "Binary", "Patterns", "Fix", "Slow", "Scope", "Config file"]; + let headers = [ + "Name", + "Binary", + "Patterns", + "Fix", + "Slow", + "Scope", + "Config file", + ]; let rows: Vec<[String; 7]> = registry.iter().map(table_row).collect(); // Compute column widths. @@ -597,9 +604,12 @@ mod tests { let sep_row = format!("| {} |", separator.join(" | ")); let header_strs: Vec<&str> = headers.iter().copied().collect(); - let generated_comment = - ""; - let mut lines = vec![generated_comment.to_string(), fmt_row(&header_strs), sep_row]; + let generated_comment = ""; + let mut lines = vec![ + generated_comment.to_string(), + fmt_row(&header_strs), + sep_row, + ]; for row in &rows { let strs: Vec<&str> = row.iter().map(|s| s.as_str()).collect(); lines.push(fmt_row(&strs)); diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index bf58c7e..033814c 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -22,7 +22,7 @@ cargo-fmt cargo-fmt active fast *.rs gofmt gofmt missing fast *.go google-java-format google-java-format missing fast *.java ktlint ktlint missing fast *.kt *.kts -dotnet-format dotnet missing slow *.cs +dotnet-format dotnet missing fast *.cs lychee lychee active fast renovate-deps renovate active slow license-header license-header active fast From 26f0d81a40eee17e9f345e23e607be0d21ee2a97 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sun, 5 Apr 2026 09:15:00 +0000 Subject: [PATCH 064/141] test: e2e coverage for all batch-1 linters Adds dedicated test cases for 13 linters that had no e2e coverage: shfmt, markdownlint-cli2, prettier, actionlint, hadolint, codespell, editorconfig-checker, ruff, ruff-format, biome, biome-format, gofmt, and license-header. Each linter gets clean + failure cases; formatters (shfmt, markdownlint- cli2, prettier, ruff-format, biome-format, gofmt) also get an auto-fix case asserting file content after fix. editorconfig-checker gets a third formatter-exclusion case verifying that ec skips .sh files when shfmt is active as a formatter. biome and biome-format use fake_bins to avoid flaky timing output. --- .../clean/files/.github/workflows/ci.yml | 8 ++++++ tests/cases/actionlint/clean/files/mise.toml | 2 ++ tests/cases/actionlint/clean/test.toml | 3 +++ .../failure/files/.github/workflows/ci.yml | 9 +++++++ .../cases/actionlint/failure/files/mise.toml | 2 ++ tests/cases/actionlint/failure/test.toml | 13 ++++++++++ .../biome-format/auto-fix/files/data.json | 1 + .../biome-format/auto-fix/files/mise.toml | 2 ++ tests/cases/biome-format/auto-fix/test.toml | 26 +++++++++++++++++++ .../cases/biome-format/clean/files/data.json | 1 + .../cases/biome-format/clean/files/mise.toml | 2 ++ tests/cases/biome-format/clean/test.toml | 3 +++ .../biome-format/failure/files/data.json | 1 + .../biome-format/failure/files/mise.toml | 2 ++ tests/cases/biome-format/failure/test.toml | 17 ++++++++++++ tests/cases/biome/clean/files/main.js | 1 + tests/cases/biome/clean/files/mise.toml | 2 ++ tests/cases/biome/clean/test.toml | 9 +++++++ tests/cases/biome/failure/files/main.js | 2 ++ tests/cases/biome/failure/files/mise.toml | 2 ++ tests/cases/biome/failure/test.toml | 20 ++++++++++++++ tests/cases/codespell/clean/files/README.md | 3 +++ tests/cases/codespell/clean/files/mise.toml | 2 ++ tests/cases/codespell/clean/test.toml | 3 +++ tests/cases/codespell/failure/files/README.md | 3 +++ tests/cases/codespell/failure/files/mise.toml | 2 ++ tests/cases/codespell/failure/test.toml | 11 ++++++++ .../clean/files/.editorconfig | 8 ++++++ .../clean/files/hello.txt | 2 ++ .../clean/files/mise.toml | 2 ++ .../editorconfig-checker/clean/test.toml | 3 +++ .../failure/files/.editorconfig | 8 ++++++ .../failure/files/hello.txt | 2 ++ .../failure/files/mise.toml | 2 ++ .../editorconfig-checker/failure/test.toml | 13 ++++++++++ .../formatter-exclusion/files/.editorconfig | 8 ++++++ .../formatter-exclusion/files/mise.toml | 3 +++ .../formatter-exclusion/files/script.sh | 4 +++ .../formatter-exclusion/test.toml | 3 +++ tests/cases/gofmt/auto-fix/files/main.go | 5 ++++ tests/cases/gofmt/auto-fix/files/mise.toml | 2 ++ tests/cases/gofmt/auto-fix/test.toml | 15 +++++++++++ tests/cases/gofmt/clean/files/main.go | 5 ++++ tests/cases/gofmt/clean/files/mise.toml | 2 ++ tests/cases/gofmt/clean/test.toml | 3 +++ tests/cases/gofmt/failure/files/main.go | 5 ++++ tests/cases/gofmt/failure/files/mise.toml | 2 ++ tests/cases/gofmt/failure/test.toml | 20 ++++++++++++++ tests/cases/hadolint/clean/files/Dockerfile | 3 +++ tests/cases/hadolint/clean/files/mise.toml | 2 ++ tests/cases/hadolint/clean/test.toml | 3 +++ tests/cases/hadolint/failure/files/Dockerfile | 2 ++ tests/cases/hadolint/failure/files/mise.toml | 2 ++ tests/cases/hadolint/failure/test.toml | 12 +++++++++ .../license-header/clean/files/Main.java | 6 +++++ .../license-header/clean/files/flint.toml | 3 +++ tests/cases/license-header/clean/test.toml | 3 +++ .../license-header/failure/files/Main.java | 5 ++++ .../license-header/failure/files/flint.toml | 3 +++ tests/cases/license-header/failure/test.toml | 10 +++++++ .../auto-fix/files/README.md | 5 ++++ .../auto-fix/files/mise.toml | 2 ++ .../markdownlint-cli2/auto-fix/test.toml | 15 +++++++++++ .../markdownlint-cli2/clean/files/README.md | 7 +++++ .../markdownlint-cli2/clean/files/mise.toml | 2 ++ tests/cases/markdownlint-cli2/clean/test.toml | 3 +++ .../markdownlint-cli2/failure/files/README.md | 5 ++++ .../markdownlint-cli2/failure/files/mise.toml | 2 ++ .../cases/markdownlint-cli2/failure/test.toml | 14 ++++++++++ .../cases/prettier/auto-fix/files/config.yml | 3 +++ tests/cases/prettier/auto-fix/files/mise.toml | 2 ++ tests/cases/prettier/auto-fix/test.toml | 13 ++++++++++ tests/cases/prettier/clean/files/config.yml | 2 ++ tests/cases/prettier/clean/files/mise.toml | 2 ++ tests/cases/prettier/clean/test.toml | 3 +++ tests/cases/prettier/failure/files/config.yml | 3 +++ tests/cases/prettier/failure/files/mise.toml | 2 ++ tests/cases/prettier/failure/test.toml | 12 +++++++++ .../cases/ruff-format/auto-fix/files/main.py | 3 +++ .../ruff-format/auto-fix/files/mise.toml | 2 ++ tests/cases/ruff-format/auto-fix/test.toml | 13 ++++++++++ tests/cases/ruff-format/clean/files/main.py | 3 +++ tests/cases/ruff-format/clean/files/mise.toml | 2 ++ tests/cases/ruff-format/clean/test.toml | 3 +++ tests/cases/ruff-format/failure/files/main.py | 3 +++ .../cases/ruff-format/failure/files/mise.toml | 2 ++ tests/cases/ruff-format/failure/test.toml | 11 ++++++++ tests/cases/ruff/clean/files/main.py | 2 ++ tests/cases/ruff/clean/files/mise.toml | 2 ++ tests/cases/ruff/clean/test.toml | 3 +++ tests/cases/ruff/failure/files/main.py | 3 +++ tests/cases/ruff/failure/files/mise.toml | 2 ++ tests/cases/ruff/failure/test.toml | 21 +++++++++++++++ tests/cases/shfmt/auto-fix/files/mise.toml | 2 ++ tests/cases/shfmt/auto-fix/files/script.sh | 4 +++ tests/cases/shfmt/auto-fix/test.toml | 14 ++++++++++ tests/cases/shfmt/clean/files/mise.toml | 2 ++ tests/cases/shfmt/clean/files/script.sh | 4 +++ tests/cases/shfmt/clean/test.toml | 3 +++ tests/cases/shfmt/failure/files/mise.toml | 2 ++ tests/cases/shfmt/failure/files/script.sh | 4 +++ tests/cases/shfmt/failure/test.toml | 18 +++++++++++++ 102 files changed, 548 insertions(+) create mode 100644 tests/cases/actionlint/clean/files/.github/workflows/ci.yml create mode 100644 tests/cases/actionlint/clean/files/mise.toml create mode 100644 tests/cases/actionlint/clean/test.toml create mode 100644 tests/cases/actionlint/failure/files/.github/workflows/ci.yml create mode 100644 tests/cases/actionlint/failure/files/mise.toml create mode 100644 tests/cases/actionlint/failure/test.toml create mode 100644 tests/cases/biome-format/auto-fix/files/data.json create mode 100644 tests/cases/biome-format/auto-fix/files/mise.toml create mode 100644 tests/cases/biome-format/auto-fix/test.toml create mode 100644 tests/cases/biome-format/clean/files/data.json create mode 100644 tests/cases/biome-format/clean/files/mise.toml create mode 100644 tests/cases/biome-format/clean/test.toml create mode 100644 tests/cases/biome-format/failure/files/data.json create mode 100644 tests/cases/biome-format/failure/files/mise.toml create mode 100644 tests/cases/biome-format/failure/test.toml create mode 100644 tests/cases/biome/clean/files/main.js create mode 100644 tests/cases/biome/clean/files/mise.toml create mode 100644 tests/cases/biome/clean/test.toml create mode 100644 tests/cases/biome/failure/files/main.js create mode 100644 tests/cases/biome/failure/files/mise.toml create mode 100644 tests/cases/biome/failure/test.toml create mode 100644 tests/cases/codespell/clean/files/README.md create mode 100644 tests/cases/codespell/clean/files/mise.toml create mode 100644 tests/cases/codespell/clean/test.toml create mode 100644 tests/cases/codespell/failure/files/README.md create mode 100644 tests/cases/codespell/failure/files/mise.toml create mode 100644 tests/cases/codespell/failure/test.toml create mode 100644 tests/cases/editorconfig-checker/clean/files/.editorconfig create mode 100644 tests/cases/editorconfig-checker/clean/files/hello.txt create mode 100644 tests/cases/editorconfig-checker/clean/files/mise.toml create mode 100644 tests/cases/editorconfig-checker/clean/test.toml create mode 100644 tests/cases/editorconfig-checker/failure/files/.editorconfig create mode 100644 tests/cases/editorconfig-checker/failure/files/hello.txt create mode 100644 tests/cases/editorconfig-checker/failure/files/mise.toml create mode 100644 tests/cases/editorconfig-checker/failure/test.toml create mode 100644 tests/cases/editorconfig-checker/formatter-exclusion/files/.editorconfig create mode 100644 tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml create mode 100644 tests/cases/editorconfig-checker/formatter-exclusion/files/script.sh create mode 100644 tests/cases/editorconfig-checker/formatter-exclusion/test.toml create mode 100644 tests/cases/gofmt/auto-fix/files/main.go create mode 100644 tests/cases/gofmt/auto-fix/files/mise.toml create mode 100644 tests/cases/gofmt/auto-fix/test.toml create mode 100644 tests/cases/gofmt/clean/files/main.go create mode 100644 tests/cases/gofmt/clean/files/mise.toml create mode 100644 tests/cases/gofmt/clean/test.toml create mode 100644 tests/cases/gofmt/failure/files/main.go create mode 100644 tests/cases/gofmt/failure/files/mise.toml create mode 100644 tests/cases/gofmt/failure/test.toml create mode 100644 tests/cases/hadolint/clean/files/Dockerfile create mode 100644 tests/cases/hadolint/clean/files/mise.toml create mode 100644 tests/cases/hadolint/clean/test.toml create mode 100644 tests/cases/hadolint/failure/files/Dockerfile create mode 100644 tests/cases/hadolint/failure/files/mise.toml create mode 100644 tests/cases/hadolint/failure/test.toml create mode 100644 tests/cases/license-header/clean/files/Main.java create mode 100644 tests/cases/license-header/clean/files/flint.toml create mode 100644 tests/cases/license-header/clean/test.toml create mode 100644 tests/cases/license-header/failure/files/Main.java create mode 100644 tests/cases/license-header/failure/files/flint.toml create mode 100644 tests/cases/license-header/failure/test.toml create mode 100644 tests/cases/markdownlint-cli2/auto-fix/files/README.md create mode 100644 tests/cases/markdownlint-cli2/auto-fix/files/mise.toml create mode 100644 tests/cases/markdownlint-cli2/auto-fix/test.toml create mode 100644 tests/cases/markdownlint-cli2/clean/files/README.md create mode 100644 tests/cases/markdownlint-cli2/clean/files/mise.toml create mode 100644 tests/cases/markdownlint-cli2/clean/test.toml create mode 100644 tests/cases/markdownlint-cli2/failure/files/README.md create mode 100644 tests/cases/markdownlint-cli2/failure/files/mise.toml create mode 100644 tests/cases/markdownlint-cli2/failure/test.toml create mode 100644 tests/cases/prettier/auto-fix/files/config.yml create mode 100644 tests/cases/prettier/auto-fix/files/mise.toml create mode 100644 tests/cases/prettier/auto-fix/test.toml create mode 100644 tests/cases/prettier/clean/files/config.yml create mode 100644 tests/cases/prettier/clean/files/mise.toml create mode 100644 tests/cases/prettier/clean/test.toml create mode 100644 tests/cases/prettier/failure/files/config.yml create mode 100644 tests/cases/prettier/failure/files/mise.toml create mode 100644 tests/cases/prettier/failure/test.toml create mode 100644 tests/cases/ruff-format/auto-fix/files/main.py create mode 100644 tests/cases/ruff-format/auto-fix/files/mise.toml create mode 100644 tests/cases/ruff-format/auto-fix/test.toml create mode 100644 tests/cases/ruff-format/clean/files/main.py create mode 100644 tests/cases/ruff-format/clean/files/mise.toml create mode 100644 tests/cases/ruff-format/clean/test.toml create mode 100644 tests/cases/ruff-format/failure/files/main.py create mode 100644 tests/cases/ruff-format/failure/files/mise.toml create mode 100644 tests/cases/ruff-format/failure/test.toml create mode 100644 tests/cases/ruff/clean/files/main.py create mode 100644 tests/cases/ruff/clean/files/mise.toml create mode 100644 tests/cases/ruff/clean/test.toml create mode 100644 tests/cases/ruff/failure/files/main.py create mode 100644 tests/cases/ruff/failure/files/mise.toml create mode 100644 tests/cases/ruff/failure/test.toml create mode 100644 tests/cases/shfmt/auto-fix/files/mise.toml create mode 100644 tests/cases/shfmt/auto-fix/files/script.sh create mode 100644 tests/cases/shfmt/auto-fix/test.toml create mode 100644 tests/cases/shfmt/clean/files/mise.toml create mode 100644 tests/cases/shfmt/clean/files/script.sh create mode 100644 tests/cases/shfmt/clean/test.toml create mode 100644 tests/cases/shfmt/failure/files/mise.toml create mode 100644 tests/cases/shfmt/failure/files/script.sh create mode 100644 tests/cases/shfmt/failure/test.toml diff --git a/tests/cases/actionlint/clean/files/.github/workflows/ci.yml b/tests/cases/actionlint/clean/files/.github/workflows/ci.yml new file mode 100644 index 0000000..975ef76 --- /dev/null +++ b/tests/cases/actionlint/clean/files/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: echo "hello" diff --git a/tests/cases/actionlint/clean/files/mise.toml b/tests/cases/actionlint/clean/files/mise.toml new file mode 100644 index 0000000..fd57bb2 --- /dev/null +++ b/tests/cases/actionlint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +actionlint = "latest" diff --git a/tests/cases/actionlint/clean/test.toml b/tests/cases/actionlint/clean/test.toml new file mode 100644 index 0000000..c454ffb --- /dev/null +++ b/tests/cases/actionlint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full actionlint" +exit = 0 diff --git a/tests/cases/actionlint/failure/files/.github/workflows/ci.yml b/tests/cases/actionlint/failure/files/.github/workflows/ci.yml new file mode 100644 index 0000000..c3e5b7d --- /dev/null +++ b/tests/cases/actionlint/failure/files/.github/workflows/ci.yml @@ -0,0 +1,9 @@ +name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + invalid_input: foo diff --git a/tests/cases/actionlint/failure/files/mise.toml b/tests/cases/actionlint/failure/files/mise.toml new file mode 100644 index 0000000..fd57bb2 --- /dev/null +++ b/tests/cases/actionlint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +actionlint = "latest" diff --git a/tests/cases/actionlint/failure/test.toml b/tests/cases/actionlint/failure/test.toml new file mode 100644 index 0000000..c8ecde8 --- /dev/null +++ b/tests/cases/actionlint/failure/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full actionlint" +exit = 1 +stderr = """ +[actionlint] +.github/workflows/ci.yml:9:11: input "invalid_input" is not defined in action "actions/checkout@v4". available inputs are "clean", "fetch-depth", "fetch-tags", "filter", "github-server-url", "lfs", "path", "persist-credentials", "ref", "repository", "set-safe-directory", "show-progress", "sparse-checkout", "sparse-checkout-cone-mode", "ssh-key", "ssh-known-hosts", "ssh-strict", "ssh-user", "submodules", "token" [action] + | +9 | invalid_input: foo + | ^~~~~~~~~~~~~~ + +flint: 1 check failed (actionlint) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/biome-format/auto-fix/files/data.json b/tests/cases/biome-format/auto-fix/files/data.json new file mode 100644 index 0000000..8716584 --- /dev/null +++ b/tests/cases/biome-format/auto-fix/files/data.json @@ -0,0 +1 @@ +{"foo":"bar","baz":1} diff --git a/tests/cases/biome-format/auto-fix/files/mise.toml b/tests/cases/biome-format/auto-fix/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome-format/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome-format/auto-fix/test.toml b/tests/cases/biome-format/auto-fix/test.toml new file mode 100644 index 0000000..7da013c --- /dev/null +++ b/tests/cases/biome-format/auto-fix/test.toml @@ -0,0 +1,26 @@ +[expected] +args = "run --full --fix biome-format" +exit = 1 +stderr = """ +flint: fixed: biome-format — commit before pushing +""" + +[expected.files] +"data.json" = """ +{ "foo": "bar", "baz": 1 } +""" + +[fake_bins] +biome = ''' +#!/bin/sh +case "$*" in + *--write*) + for last; do :; done + printf '{ "foo": "bar", "baz": 1 }\n' > "$last" + ;; + *) + printf 'data.json: Formatter would have printed different content\n' >&2 + exit 1 + ;; +esac +''' diff --git a/tests/cases/biome-format/clean/files/data.json b/tests/cases/biome-format/clean/files/data.json new file mode 100644 index 0000000..dd272dd --- /dev/null +++ b/tests/cases/biome-format/clean/files/data.json @@ -0,0 +1 @@ +{ "foo": "bar", "baz": 1 } diff --git a/tests/cases/biome-format/clean/files/mise.toml b/tests/cases/biome-format/clean/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome-format/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome-format/clean/test.toml b/tests/cases/biome-format/clean/test.toml new file mode 100644 index 0000000..ab40b20 --- /dev/null +++ b/tests/cases/biome-format/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full biome-format" +exit = 0 diff --git a/tests/cases/biome-format/failure/files/data.json b/tests/cases/biome-format/failure/files/data.json new file mode 100644 index 0000000..8716584 --- /dev/null +++ b/tests/cases/biome-format/failure/files/data.json @@ -0,0 +1 @@ +{"foo":"bar","baz":1} diff --git a/tests/cases/biome-format/failure/files/mise.toml b/tests/cases/biome-format/failure/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome-format/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome-format/failure/test.toml b/tests/cases/biome-format/failure/test.toml new file mode 100644 index 0000000..99ca2d1 --- /dev/null +++ b/tests/cases/biome-format/failure/test.toml @@ -0,0 +1,17 @@ +[expected] +args = "run --full biome-format" +exit = 1 +stderr = """ +[biome-format] +data.json: Formatter would have printed different content + +flint: 1 check failed (biome-format) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" + +[fake_bins] +biome = ''' +#!/bin/sh +printf 'data.json: Formatter would have printed different content\n' >&2 +exit 1 +''' diff --git a/tests/cases/biome/clean/files/main.js b/tests/cases/biome/clean/files/main.js new file mode 100644 index 0000000..702f428 --- /dev/null +++ b/tests/cases/biome/clean/files/main.js @@ -0,0 +1 @@ +console.log("hello"); diff --git a/tests/cases/biome/clean/files/mise.toml b/tests/cases/biome/clean/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome/clean/test.toml b/tests/cases/biome/clean/test.toml new file mode 100644 index 0000000..4c94c58 --- /dev/null +++ b/tests/cases/biome/clean/test.toml @@ -0,0 +1,9 @@ +[expected] +args = "run --full biome" +exit = 0 + +[fake_bins] +biome = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/cases/biome/failure/files/main.js b/tests/cases/biome/failure/files/main.js new file mode 100644 index 0000000..1a238c2 --- /dev/null +++ b/tests/cases/biome/failure/files/main.js @@ -0,0 +1,2 @@ +debugger; +console.log("hello"); diff --git a/tests/cases/biome/failure/files/mise.toml b/tests/cases/biome/failure/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome/failure/test.toml b/tests/cases/biome/failure/test.toml new file mode 100644 index 0000000..a860e64 --- /dev/null +++ b/tests/cases/biome/failure/test.toml @@ -0,0 +1,20 @@ +[expected] +args = "run --full biome" +exit = 1 +stderr = """ +[biome] +main.js:1:1 lint/suspicious/noDebugger + + x This is an unexpected use of the debugger statement. + + +flint: 1 check failed (biome) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" + +[fake_bins] +biome = ''' +#!/bin/sh +printf 'main.js:1:1 lint/suspicious/noDebugger\n\n x This is an unexpected use of the debugger statement.\n\n' >&2 +exit 1 +''' diff --git a/tests/cases/codespell/clean/files/README.md b/tests/cases/codespell/clean/files/README.md new file mode 100644 index 0000000..8e9a457 --- /dev/null +++ b/tests/cases/codespell/clean/files/README.md @@ -0,0 +1,3 @@ +# Project + +This is the documentation for the project. diff --git a/tests/cases/codespell/clean/files/mise.toml b/tests/cases/codespell/clean/files/mise.toml new file mode 100644 index 0000000..06f01dc --- /dev/null +++ b/tests/cases/codespell/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +codespell = "latest" diff --git a/tests/cases/codespell/clean/test.toml b/tests/cases/codespell/clean/test.toml new file mode 100644 index 0000000..b4f3585 --- /dev/null +++ b/tests/cases/codespell/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full codespell" +exit = 0 diff --git a/tests/cases/codespell/failure/files/README.md b/tests/cases/codespell/failure/files/README.md new file mode 100644 index 0000000..7d1092d --- /dev/null +++ b/tests/cases/codespell/failure/files/README.md @@ -0,0 +1,3 @@ +# Project + +This is teh documentation for teh project. diff --git a/tests/cases/codespell/failure/files/mise.toml b/tests/cases/codespell/failure/files/mise.toml new file mode 100644 index 0000000..06f01dc --- /dev/null +++ b/tests/cases/codespell/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +codespell = "latest" diff --git a/tests/cases/codespell/failure/test.toml b/tests/cases/codespell/failure/test.toml new file mode 100644 index 0000000..bb63857 --- /dev/null +++ b/tests/cases/codespell/failure/test.toml @@ -0,0 +1,11 @@ +[expected] +args = "run --full codespell" +exit = 1 +stderr = """ +[codespell] +/README.md:3: teh ==> the +/README.md:3: teh ==> the + +flint: 1 check failed (codespell) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/editorconfig-checker/clean/files/.editorconfig b/tests/cases/editorconfig-checker/clean/files/.editorconfig new file mode 100644 index 0000000..9da00de --- /dev/null +++ b/tests/cases/editorconfig-checker/clean/files/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/tests/cases/editorconfig-checker/clean/files/hello.txt b/tests/cases/editorconfig-checker/clean/files/hello.txt new file mode 100644 index 0000000..94954ab --- /dev/null +++ b/tests/cases/editorconfig-checker/clean/files/hello.txt @@ -0,0 +1,2 @@ +hello +world diff --git a/tests/cases/editorconfig-checker/clean/files/mise.toml b/tests/cases/editorconfig-checker/clean/files/mise.toml new file mode 100644 index 0000000..972b254 --- /dev/null +++ b/tests/cases/editorconfig-checker/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +editorconfig-checker = "latest" diff --git a/tests/cases/editorconfig-checker/clean/test.toml b/tests/cases/editorconfig-checker/clean/test.toml new file mode 100644 index 0000000..a70bf54 --- /dev/null +++ b/tests/cases/editorconfig-checker/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full editorconfig-checker" +exit = 0 diff --git a/tests/cases/editorconfig-checker/failure/files/.editorconfig b/tests/cases/editorconfig-checker/failure/files/.editorconfig new file mode 100644 index 0000000..9da00de --- /dev/null +++ b/tests/cases/editorconfig-checker/failure/files/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/tests/cases/editorconfig-checker/failure/files/hello.txt b/tests/cases/editorconfig-checker/failure/files/hello.txt new file mode 100644 index 0000000..5d1cd3a --- /dev/null +++ b/tests/cases/editorconfig-checker/failure/files/hello.txt @@ -0,0 +1,2 @@ +hello +world diff --git a/tests/cases/editorconfig-checker/failure/files/mise.toml b/tests/cases/editorconfig-checker/failure/files/mise.toml new file mode 100644 index 0000000..972b254 --- /dev/null +++ b/tests/cases/editorconfig-checker/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +editorconfig-checker = "latest" diff --git a/tests/cases/editorconfig-checker/failure/test.toml b/tests/cases/editorconfig-checker/failure/test.toml new file mode 100644 index 0000000..3088007 --- /dev/null +++ b/tests/cases/editorconfig-checker/failure/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full editorconfig-checker" +exit = 1 +stderr = """ +[editorconfig-checker] +hello.txt: + 2: Trailing whitespace + +1 errors found + +flint: 1 check failed (editorconfig-checker) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/files/.editorconfig b/tests/cases/editorconfig-checker/formatter-exclusion/files/.editorconfig new file mode 100644 index 0000000..9da00de --- /dev/null +++ b/tests/cases/editorconfig-checker/formatter-exclusion/files/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml b/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml new file mode 100644 index 0000000..3d1328f --- /dev/null +++ b/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +editorconfig-checker = "latest" +shfmt = "latest" diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/files/script.sh b/tests/cases/editorconfig-checker/formatter-exclusion/files/script.sh new file mode 100644 index 0000000..e66e97f --- /dev/null +++ b/tests/cases/editorconfig-checker/formatter-exclusion/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/test.toml b/tests/cases/editorconfig-checker/formatter-exclusion/test.toml new file mode 100644 index 0000000..04e6e4f --- /dev/null +++ b/tests/cases/editorconfig-checker/formatter-exclusion/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full" +exit = 0 diff --git a/tests/cases/gofmt/auto-fix/files/main.go b/tests/cases/gofmt/auto-fix/files/main.go new file mode 100644 index 0000000..4e6c810 --- /dev/null +++ b/tests/cases/gofmt/auto-fix/files/main.go @@ -0,0 +1,5 @@ +package main + +func add(x int,y int) int { +return x+y +} diff --git a/tests/cases/gofmt/auto-fix/files/mise.toml b/tests/cases/gofmt/auto-fix/files/mise.toml new file mode 100644 index 0000000..b55f8c1 --- /dev/null +++ b/tests/cases/gofmt/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "latest" diff --git a/tests/cases/gofmt/auto-fix/test.toml b/tests/cases/gofmt/auto-fix/test.toml new file mode 100644 index 0000000..a358624 --- /dev/null +++ b/tests/cases/gofmt/auto-fix/test.toml @@ -0,0 +1,15 @@ +[expected] +args = "run --full --fix gofmt" +exit = 1 +stderr = """ +flint: fixed: gofmt — commit before pushing +""" + +[expected.files] +"main.go" = """ +package main + +func add(x int, y int) int { + return x + y +} +""" \ No newline at end of file diff --git a/tests/cases/gofmt/clean/files/main.go b/tests/cases/gofmt/clean/files/main.go new file mode 100644 index 0000000..4e5bf83 --- /dev/null +++ b/tests/cases/gofmt/clean/files/main.go @@ -0,0 +1,5 @@ +package main + +func add(x int, y int) int { + return x + y +} diff --git a/tests/cases/gofmt/clean/files/mise.toml b/tests/cases/gofmt/clean/files/mise.toml new file mode 100644 index 0000000..b55f8c1 --- /dev/null +++ b/tests/cases/gofmt/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "latest" diff --git a/tests/cases/gofmt/clean/test.toml b/tests/cases/gofmt/clean/test.toml new file mode 100644 index 0000000..05ec9ca --- /dev/null +++ b/tests/cases/gofmt/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full gofmt" +exit = 0 diff --git a/tests/cases/gofmt/failure/files/main.go b/tests/cases/gofmt/failure/files/main.go new file mode 100644 index 0000000..4e6c810 --- /dev/null +++ b/tests/cases/gofmt/failure/files/main.go @@ -0,0 +1,5 @@ +package main + +func add(x int,y int) int { +return x+y +} diff --git a/tests/cases/gofmt/failure/files/mise.toml b/tests/cases/gofmt/failure/files/mise.toml new file mode 100644 index 0000000..b55f8c1 --- /dev/null +++ b/tests/cases/gofmt/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "latest" diff --git a/tests/cases/gofmt/failure/test.toml b/tests/cases/gofmt/failure/test.toml new file mode 100644 index 0000000..82f1ff5 --- /dev/null +++ b/tests/cases/gofmt/failure/test.toml @@ -0,0 +1,20 @@ +[expected] +args = "run --full gofmt" +exit = 1 +stderr = """ +[gofmt] +diff /main.go.orig /main.go +--- /main.go.orig ++++ /main.go +@@ -1,5 +1,5 @@ + package main + +-func add(x int,y int) int { +-return x+y ++func add(x int, y int) int { ++ return x + y + } + +flint: 1 check failed (gofmt) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/hadolint/clean/files/Dockerfile b/tests/cases/hadolint/clean/files/Dockerfile new file mode 100644 index 0000000..ff56c76 --- /dev/null +++ b/tests/cases/hadolint/clean/files/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:22.04 +COPY app /app +CMD ["/app"] diff --git a/tests/cases/hadolint/clean/files/mise.toml b/tests/cases/hadolint/clean/files/mise.toml new file mode 100644 index 0000000..bc333fe --- /dev/null +++ b/tests/cases/hadolint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +hadolint = "latest" diff --git a/tests/cases/hadolint/clean/test.toml b/tests/cases/hadolint/clean/test.toml new file mode 100644 index 0000000..9d5d1e2 --- /dev/null +++ b/tests/cases/hadolint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full hadolint" +exit = 0 diff --git a/tests/cases/hadolint/failure/files/Dockerfile b/tests/cases/hadolint/failure/files/Dockerfile new file mode 100644 index 0000000..53c3ea0 --- /dev/null +++ b/tests/cases/hadolint/failure/files/Dockerfile @@ -0,0 +1,2 @@ +FROM ubuntu:latest +RUN apt-get install -y curl diff --git a/tests/cases/hadolint/failure/files/mise.toml b/tests/cases/hadolint/failure/files/mise.toml new file mode 100644 index 0000000..bc333fe --- /dev/null +++ b/tests/cases/hadolint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +hadolint = "latest" diff --git a/tests/cases/hadolint/failure/test.toml b/tests/cases/hadolint/failure/test.toml new file mode 100644 index 0000000..07768f1 --- /dev/null +++ b/tests/cases/hadolint/failure/test.toml @@ -0,0 +1,12 @@ +[expected] +args = "run --full hadolint" +exit = 1 +stderr = """ +[hadolint] +/Dockerfile:1 DL3007 warning: Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag +/Dockerfile:2 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install ` use `apt-get install =` +/Dockerfile:2 DL3015 info: Avoid additional packages by specifying `--no-install-recommends` + +flint: 1 check failed (hadolint) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/license-header/clean/files/Main.java b/tests/cases/license-header/clean/files/Main.java new file mode 100644 index 0000000..695b38a --- /dev/null +++ b/tests/cases/license-header/clean/files/Main.java @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +public class Main { + public static void main(String[] args) { + System.out.println("hello"); + } +} diff --git a/tests/cases/license-header/clean/files/flint.toml b/tests/cases/license-header/clean/files/flint.toml new file mode 100644 index 0000000..de980e0 --- /dev/null +++ b/tests/cases/license-header/clean/files/flint.toml @@ -0,0 +1,3 @@ +[checks.license-header] +text = "SPDX-License-Identifier: Apache-2.0" +patterns = ["*.java"] diff --git a/tests/cases/license-header/clean/test.toml b/tests/cases/license-header/clean/test.toml new file mode 100644 index 0000000..0861d7f --- /dev/null +++ b/tests/cases/license-header/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full license-header" +exit = 0 diff --git a/tests/cases/license-header/failure/files/Main.java b/tests/cases/license-header/failure/files/Main.java new file mode 100644 index 0000000..b8e3fe9 --- /dev/null +++ b/tests/cases/license-header/failure/files/Main.java @@ -0,0 +1,5 @@ +public class Main { + public static void main(String[] args) { + System.out.println("hello"); + } +} diff --git a/tests/cases/license-header/failure/files/flint.toml b/tests/cases/license-header/failure/files/flint.toml new file mode 100644 index 0000000..de980e0 --- /dev/null +++ b/tests/cases/license-header/failure/files/flint.toml @@ -0,0 +1,3 @@ +[checks.license-header] +text = "SPDX-License-Identifier: Apache-2.0" +patterns = ["*.java"] diff --git a/tests/cases/license-header/failure/test.toml b/tests/cases/license-header/failure/test.toml new file mode 100644 index 0000000..8e58999 --- /dev/null +++ b/tests/cases/license-header/failure/test.toml @@ -0,0 +1,10 @@ +[expected] +args = "run --full license-header" +exit = 1 +stderr = """ +[license-header] +Main.java: missing license header + +flint: 1 check failed (license-header) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/markdownlint-cli2/auto-fix/files/README.md b/tests/cases/markdownlint-cli2/auto-fix/files/README.md new file mode 100644 index 0000000..56c12ab --- /dev/null +++ b/tests/cases/markdownlint-cli2/auto-fix/files/README.md @@ -0,0 +1,5 @@ +# Title + +Text with trailing spaces + +## Section diff --git a/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml b/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml new file mode 100644 index 0000000..f8b2754 --- /dev/null +++ b/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/auto-fix/test.toml b/tests/cases/markdownlint-cli2/auto-fix/test.toml new file mode 100644 index 0000000..29a5293 --- /dev/null +++ b/tests/cases/markdownlint-cli2/auto-fix/test.toml @@ -0,0 +1,15 @@ +[expected] +args = "run --full --fix markdownlint-cli2" +exit = 1 +stderr = """ +flint: fixed: markdownlint-cli2 — commit before pushing +""" + +[expected.files] +"README.md" = """ +# Title + +Text with trailing spaces + +## Section +""" \ No newline at end of file diff --git a/tests/cases/markdownlint-cli2/clean/files/README.md b/tests/cases/markdownlint-cli2/clean/files/README.md new file mode 100644 index 0000000..f8b741f --- /dev/null +++ b/tests/cases/markdownlint-cli2/clean/files/README.md @@ -0,0 +1,7 @@ +# Title + +Some text here. + +## Section + +More text. diff --git a/tests/cases/markdownlint-cli2/clean/files/mise.toml b/tests/cases/markdownlint-cli2/clean/files/mise.toml new file mode 100644 index 0000000..f8b2754 --- /dev/null +++ b/tests/cases/markdownlint-cli2/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/clean/test.toml b/tests/cases/markdownlint-cli2/clean/test.toml new file mode 100644 index 0000000..2bc16c2 --- /dev/null +++ b/tests/cases/markdownlint-cli2/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full markdownlint-cli2" +exit = 0 diff --git a/tests/cases/markdownlint-cli2/failure/files/README.md b/tests/cases/markdownlint-cli2/failure/files/README.md new file mode 100644 index 0000000..56c12ab --- /dev/null +++ b/tests/cases/markdownlint-cli2/failure/files/README.md @@ -0,0 +1,5 @@ +# Title + +Text with trailing spaces + +## Section diff --git a/tests/cases/markdownlint-cli2/failure/files/mise.toml b/tests/cases/markdownlint-cli2/failure/files/mise.toml new file mode 100644 index 0000000..f8b2754 --- /dev/null +++ b/tests/cases/markdownlint-cli2/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/failure/test.toml b/tests/cases/markdownlint-cli2/failure/test.toml new file mode 100644 index 0000000..0556d20 --- /dev/null +++ b/tests/cases/markdownlint-cli2/failure/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full markdownlint-cli2" +exit = 1 +stderr = """ +[markdownlint-cli2] +markdownlint-cli2 v0.17.2 (markdownlint v0.37.4) +Finding: /README.md +Linting: 1 file(s) +Summary: 1 error(s) +README.md:3:26 MD009/no-trailing-spaces Trailing spaces [Expected: 0 or 2; Actual: 3] + +flint: 1 check failed (markdownlint-cli2) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/prettier/auto-fix/files/config.yml b/tests/cases/prettier/auto-fix/files/config.yml new file mode 100644 index 0000000..d2d0e9b --- /dev/null +++ b/tests/cases/prettier/auto-fix/files/config.yml @@ -0,0 +1,3 @@ +name: "test" +value: 42 +items: ["a","b"] diff --git a/tests/cases/prettier/auto-fix/files/mise.toml b/tests/cases/prettier/auto-fix/files/mise.toml new file mode 100644 index 0000000..789d69a --- /dev/null +++ b/tests/cases/prettier/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +prettier = "latest" diff --git a/tests/cases/prettier/auto-fix/test.toml b/tests/cases/prettier/auto-fix/test.toml new file mode 100644 index 0000000..04dfb48 --- /dev/null +++ b/tests/cases/prettier/auto-fix/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full --fix prettier" +exit = 1 +stderr = """ +flint: fixed: prettier — commit before pushing +""" + +[expected.files] +"config.yml" = """ +name: "test" +value: 42 +items: ["a", "b"] +""" \ No newline at end of file diff --git a/tests/cases/prettier/clean/files/config.yml b/tests/cases/prettier/clean/files/config.yml new file mode 100644 index 0000000..adde7d6 --- /dev/null +++ b/tests/cases/prettier/clean/files/config.yml @@ -0,0 +1,2 @@ +name: test +value: 42 diff --git a/tests/cases/prettier/clean/files/mise.toml b/tests/cases/prettier/clean/files/mise.toml new file mode 100644 index 0000000..789d69a --- /dev/null +++ b/tests/cases/prettier/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +prettier = "latest" diff --git a/tests/cases/prettier/clean/test.toml b/tests/cases/prettier/clean/test.toml new file mode 100644 index 0000000..ea50f65 --- /dev/null +++ b/tests/cases/prettier/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full prettier" +exit = 0 diff --git a/tests/cases/prettier/failure/files/config.yml b/tests/cases/prettier/failure/files/config.yml new file mode 100644 index 0000000..d2d0e9b --- /dev/null +++ b/tests/cases/prettier/failure/files/config.yml @@ -0,0 +1,3 @@ +name: "test" +value: 42 +items: ["a","b"] diff --git a/tests/cases/prettier/failure/files/mise.toml b/tests/cases/prettier/failure/files/mise.toml new file mode 100644 index 0000000..789d69a --- /dev/null +++ b/tests/cases/prettier/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +prettier = "latest" diff --git a/tests/cases/prettier/failure/test.toml b/tests/cases/prettier/failure/test.toml new file mode 100644 index 0000000..5a822d4 --- /dev/null +++ b/tests/cases/prettier/failure/test.toml @@ -0,0 +1,12 @@ +[expected] +args = "run --full prettier" +exit = 1 +stderr = """ +[prettier] +Checking formatting... +[warn] config.yml +[warn] Code style issues found in the above file. Run Prettier with --write to fix. + +flint: 1 check failed (prettier) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/ruff-format/auto-fix/files/main.py b/tests/cases/ruff-format/auto-fix/files/main.py new file mode 100644 index 0000000..d7826e7 --- /dev/null +++ b/tests/cases/ruff-format/auto-fix/files/main.py @@ -0,0 +1,3 @@ +x=1 +y = 2 +z=x+y diff --git a/tests/cases/ruff-format/auto-fix/files/mise.toml b/tests/cases/ruff-format/auto-fix/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff-format/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff-format/auto-fix/test.toml b/tests/cases/ruff-format/auto-fix/test.toml new file mode 100644 index 0000000..bb7ba2e --- /dev/null +++ b/tests/cases/ruff-format/auto-fix/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full --fix ruff-format" +exit = 1 +stderr = """ +flint: fixed: ruff-format — commit before pushing +""" + +[expected.files] +"main.py" = """ +x = 1 +y = 2 +z = x + y +""" \ No newline at end of file diff --git a/tests/cases/ruff-format/clean/files/main.py b/tests/cases/ruff-format/clean/files/main.py new file mode 100644 index 0000000..2b73c17 --- /dev/null +++ b/tests/cases/ruff-format/clean/files/main.py @@ -0,0 +1,3 @@ +x = 1 +y = 2 +z = x + y diff --git a/tests/cases/ruff-format/clean/files/mise.toml b/tests/cases/ruff-format/clean/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff-format/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff-format/clean/test.toml b/tests/cases/ruff-format/clean/test.toml new file mode 100644 index 0000000..2207f9d --- /dev/null +++ b/tests/cases/ruff-format/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full ruff-format" +exit = 0 diff --git a/tests/cases/ruff-format/failure/files/main.py b/tests/cases/ruff-format/failure/files/main.py new file mode 100644 index 0000000..d7826e7 --- /dev/null +++ b/tests/cases/ruff-format/failure/files/main.py @@ -0,0 +1,3 @@ +x=1 +y = 2 +z=x+y diff --git a/tests/cases/ruff-format/failure/files/mise.toml b/tests/cases/ruff-format/failure/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff-format/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff-format/failure/test.toml b/tests/cases/ruff-format/failure/test.toml new file mode 100644 index 0000000..8307174 --- /dev/null +++ b/tests/cases/ruff-format/failure/test.toml @@ -0,0 +1,11 @@ +[expected] +args = "run --full ruff-format" +exit = 1 +stderr = """ +[ruff-format] +Would reformat: main.py +1 file would be reformatted + +flint: 1 check failed (ruff-format) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/ruff/clean/files/main.py b/tests/cases/ruff/clean/files/main.py new file mode 100644 index 0000000..1e21eeb --- /dev/null +++ b/tests/cases/ruff/clean/files/main.py @@ -0,0 +1,2 @@ +def greet(name: str) -> str: + return f"Hello, {name}!" diff --git a/tests/cases/ruff/clean/files/mise.toml b/tests/cases/ruff/clean/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff/clean/test.toml b/tests/cases/ruff/clean/test.toml new file mode 100644 index 0000000..1943648 --- /dev/null +++ b/tests/cases/ruff/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full ruff" +exit = 0 diff --git a/tests/cases/ruff/failure/files/main.py b/tests/cases/ruff/failure/files/main.py new file mode 100644 index 0000000..75f2eca --- /dev/null +++ b/tests/cases/ruff/failure/files/main.py @@ -0,0 +1,3 @@ +import os + +print("hello") diff --git a/tests/cases/ruff/failure/files/mise.toml b/tests/cases/ruff/failure/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff/failure/test.toml b/tests/cases/ruff/failure/test.toml new file mode 100644 index 0000000..f25d2ae --- /dev/null +++ b/tests/cases/ruff/failure/test.toml @@ -0,0 +1,21 @@ +[expected] +args = "run --full ruff" +exit = 1 +stderr = """ +[ruff] +F401 [*] `os` imported but unused + --> main.py:1:8 + | +1 | import os + | ^^ +2 | +3 | print("hello") + | +help: Remove unused import: `os` + +Found 1 error. +[*] 1 fixable with the `--fix` option. + +flint: 1 check failed (ruff) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file diff --git a/tests/cases/shfmt/auto-fix/files/mise.toml b/tests/cases/shfmt/auto-fix/files/mise.toml new file mode 100644 index 0000000..682bf99 --- /dev/null +++ b/tests/cases/shfmt/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shfmt = "latest" diff --git a/tests/cases/shfmt/auto-fix/files/script.sh b/tests/cases/shfmt/auto-fix/files/script.sh new file mode 100644 index 0000000..e66e97f --- /dev/null +++ b/tests/cases/shfmt/auto-fix/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/shfmt/auto-fix/test.toml b/tests/cases/shfmt/auto-fix/test.toml new file mode 100644 index 0000000..46d2686 --- /dev/null +++ b/tests/cases/shfmt/auto-fix/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full --fix shfmt" +exit = 1 +stderr = """ +flint: fixed: shfmt — commit before pushing +""" + +[expected.files] +"script.sh" = """ +#!/bin/sh +if true; then + echo "hello" +fi +""" \ No newline at end of file diff --git a/tests/cases/shfmt/clean/files/mise.toml b/tests/cases/shfmt/clean/files/mise.toml new file mode 100644 index 0000000..682bf99 --- /dev/null +++ b/tests/cases/shfmt/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shfmt = "latest" diff --git a/tests/cases/shfmt/clean/files/script.sh b/tests/cases/shfmt/clean/files/script.sh new file mode 100644 index 0000000..ba88b94 --- /dev/null +++ b/tests/cases/shfmt/clean/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/shfmt/clean/test.toml b/tests/cases/shfmt/clean/test.toml new file mode 100644 index 0000000..73fe4ce --- /dev/null +++ b/tests/cases/shfmt/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full shfmt" +exit = 0 diff --git a/tests/cases/shfmt/failure/files/mise.toml b/tests/cases/shfmt/failure/files/mise.toml new file mode 100644 index 0000000..682bf99 --- /dev/null +++ b/tests/cases/shfmt/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shfmt = "latest" diff --git a/tests/cases/shfmt/failure/files/script.sh b/tests/cases/shfmt/failure/files/script.sh new file mode 100644 index 0000000..e66e97f --- /dev/null +++ b/tests/cases/shfmt/failure/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/shfmt/failure/test.toml b/tests/cases/shfmt/failure/test.toml new file mode 100644 index 0000000..03af7d6 --- /dev/null +++ b/tests/cases/shfmt/failure/test.toml @@ -0,0 +1,18 @@ +[expected] +args = "run --full shfmt" +exit = 1 +stderr = """ +[shfmt] +diff /script.sh.orig /script.sh +--- /script.sh.orig ++++ /script.sh +@@ -1,4 +1,4 @@ + #!/bin/sh + if true; then +- echo "hello" ++ echo "hello" + fi + +flint: 1 check failed (shfmt) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +""" \ No newline at end of file From c12f962639ebe37caf3bcb7f6d3d2b0380ee3d99 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sun, 5 Apr 2026 09:16:42 +0000 Subject: [PATCH 065/141] chore: remove redundant exclude for renovate-tracked-deps.json --- .github/config/flint.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/config/flint.toml b/.github/config/flint.toml index 2e60889..cbc67a2 100644 --- a/.github/config/flint.toml +++ b/.github/config/flint.toml @@ -1,6 +1,6 @@ [settings] base_branch = "main" -exclude = "CHANGELOG\\.md|\\.github/renovate-tracked-deps\\.json" +exclude = "CHANGELOG\\.md" exclude_paths = ["tests/cases/"] [checks.lychee] From a26d8cb4bc97548d62d1c841778c896f9d36bdb5 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sun, 5 Apr 2026 09:58:33 +0000 Subject: [PATCH 066/141] test: e2e coverage for batch-2 linters and snapshot format fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add e2e cases for cargo-clippy (clean/failure/auto-fix), cargo-fmt (clean), golangci-lint (clean/failure), google-java-format (clean/failure/auto-fix), ktlint (clean/failure/auto-fix), and dotnet-format (clean/failure/auto-fix) using real binaries — no fake_bins needed. Fix write_test_toml to use TOML literal strings (''') for stderr/stdout so backslashes in tool output (e.g. dotnet format's \s patterns) don't produce invalid TOML. Add --log-level=error to ktlint commands to suppress non-deterministic JVM timestamps in WARN output. Regenerate all existing snapshots to the new ''' format (no semantic change). Also fixes stale README: dotnet-format slow column was yes but .slow() was removed in c2afb1c. --- README.md | 2 +- src/registry.rs | 22 +++++++++++-------- tests/cases/actionlint/failure/test.toml | 4 ++-- tests/cases/biome-format/auto-fix/test.toml | 5 ++--- tests/cases/biome-format/failure/test.toml | 5 ++--- tests/cases/biome/failure/test.toml | 5 ++--- .../cargo-clippy/auto-fix/files/Cargo.toml | 4 ++++ .../cargo-clippy/auto-fix/files/mise.toml | 2 ++ .../cargo-clippy/auto-fix/files/src/lib.rs | 3 +++ tests/cases/cargo-clippy/auto-fix/test.toml | 13 +++++++++++ .../cases/cargo-clippy/clean/files/Cargo.toml | 4 ++++ .../cases/cargo-clippy/clean/files/mise.toml | 2 ++ .../cases/cargo-clippy/clean/files/src/lib.rs | 3 +++ tests/cases/cargo-clippy/clean/test.toml | 3 +++ .../cargo-clippy/failure/files/Cargo.toml | 4 ++++ .../cargo-clippy/failure/files/mise.toml | 2 ++ .../cargo-clippy/failure/files/src/lib.rs | 3 +++ tests/cases/cargo-clippy/failure/test.toml | 19 ++++++++++++++++ tests/cases/cargo-fmt/auto-fix/test.toml | 4 ++-- tests/cases/cargo-fmt/clean/files/Cargo.toml | 4 ++++ tests/cases/cargo-fmt/clean/files/mise.toml | 2 ++ tests/cases/cargo-fmt/clean/files/src/lib.rs | 3 +++ tests/cases/cargo-fmt/clean/test.toml | 3 +++ tests/cases/cargo-fmt/failure/test.toml | 4 ++-- tests/cases/codespell/failure/test.toml | 4 ++-- .../dotnet-format/auto-fix/files/App.csproj | 5 +++++ .../dotnet-format/auto-fix/files/Program.cs | 7 ++++++ .../dotnet-format/auto-fix/files/mise.toml | 2 ++ tests/cases/dotnet-format/auto-fix/test.toml | 21 ++++++++++++++++++ .../dotnet-format/clean/files/App.csproj | 5 +++++ .../dotnet-format/clean/files/Program.cs | 7 ++++++ .../cases/dotnet-format/clean/files/mise.toml | 2 ++ tests/cases/dotnet-format/clean/test.toml | 8 +++++++ .../dotnet-format/failure/files/App.csproj | 5 +++++ .../dotnet-format/failure/files/Program.cs | 7 ++++++ .../dotnet-format/failure/files/mise.toml | 2 ++ tests/cases/dotnet-format/failure/test.toml | 17 ++++++++++++++ .../editorconfig-checker/failure/test.toml | 4 ++-- .../general/auto-fix-and-review/test.toml | 4 ++-- .../general/auto-review-two-linters/test.toml | 7 +++--- .../general/auto-review-unfixable/test.toml | 4 ++-- tests/cases/general/list/test.toml | 4 ++-- tests/cases/gofmt/auto-fix/test.toml | 4 ++-- tests/cases/gofmt/failure/test.toml | 4 ++-- tests/cases/golangci-lint/clean/files/go.mod | 3 +++ tests/cases/golangci-lint/clean/files/main.go | 7 ++++++ .../cases/golangci-lint/clean/files/mise.toml | 2 ++ tests/cases/golangci-lint/clean/test.toml | 3 +++ .../cases/golangci-lint/failure/files/go.mod | 3 +++ .../cases/golangci-lint/failure/files/main.go | 7 ++++++ .../golangci-lint/failure/files/mise.toml | 2 ++ tests/cases/golangci-lint/failure/test.toml | 14 ++++++++++++ .../auto-fix/files/Hello.java | 5 +++++ .../auto-fix/files/mise.toml | 2 ++ .../google-java-format/auto-fix/test.toml | 15 +++++++++++++ .../google-java-format/clean/files/Hello.java | 5 +++++ .../google-java-format/clean/files/mise.toml | 2 ++ .../cases/google-java-format/clean/test.toml | 3 +++ .../failure/files/Hello.java | 5 +++++ .../failure/files/mise.toml | 2 ++ .../google-java-format/failure/test.toml | 10 +++++++++ tests/cases/hadolint/failure/test.toml | 4 ++-- tests/cases/ktlint/auto-fix/files/Hello.kt | 3 +++ tests/cases/ktlint/auto-fix/files/mise.toml | 2 ++ tests/cases/ktlint/auto-fix/test.toml | 13 +++++++++++ tests/cases/ktlint/clean/files/Hello.kt | 3 +++ tests/cases/ktlint/clean/files/mise.toml | 2 ++ tests/cases/ktlint/clean/test.toml | 3 +++ tests/cases/ktlint/failure/files/Hello.kt | 3 +++ tests/cases/ktlint/failure/files/mise.toml | 2 ++ tests/cases/ktlint/failure/test.toml | 13 +++++++++++ tests/cases/license-header/failure/test.toml | 4 ++-- tests/cases/lychee/broken-link/test.toml | 4 ++-- .../markdownlint-cli2/auto-fix/test.toml | 4 ++-- .../cases/markdownlint-cli2/failure/test.toml | 4 ++-- tests/cases/prettier/auto-fix/test.toml | 4 ++-- tests/cases/prettier/failure/test.toml | 4 ++-- .../cases/renovate-deps/fix-create/test.toml | 4 ++-- .../cases/renovate-deps/fix-update/test.toml | 4 ++-- .../cases/renovate-deps/out-of-date/test.toml | 4 ++-- tests/cases/ruff-format/auto-fix/test.toml | 4 ++-- tests/cases/ruff-format/failure/test.toml | 4 ++-- tests/cases/ruff/failure/test.toml | 4 ++-- tests/cases/shellcheck/failure/test.toml | 4 ++-- tests/cases/shfmt/auto-fix/test.toml | 4 ++-- tests/cases/shfmt/failure/test.toml | 4 ++-- tests/e2e.rs | 4 ++-- 87 files changed, 373 insertions(+), 77 deletions(-) create mode 100644 tests/cases/cargo-clippy/auto-fix/files/Cargo.toml create mode 100644 tests/cases/cargo-clippy/auto-fix/files/mise.toml create mode 100644 tests/cases/cargo-clippy/auto-fix/files/src/lib.rs create mode 100644 tests/cases/cargo-clippy/auto-fix/test.toml create mode 100644 tests/cases/cargo-clippy/clean/files/Cargo.toml create mode 100644 tests/cases/cargo-clippy/clean/files/mise.toml create mode 100644 tests/cases/cargo-clippy/clean/files/src/lib.rs create mode 100644 tests/cases/cargo-clippy/clean/test.toml create mode 100644 tests/cases/cargo-clippy/failure/files/Cargo.toml create mode 100644 tests/cases/cargo-clippy/failure/files/mise.toml create mode 100644 tests/cases/cargo-clippy/failure/files/src/lib.rs create mode 100644 tests/cases/cargo-clippy/failure/test.toml create mode 100644 tests/cases/cargo-fmt/clean/files/Cargo.toml create mode 100644 tests/cases/cargo-fmt/clean/files/mise.toml create mode 100644 tests/cases/cargo-fmt/clean/files/src/lib.rs create mode 100644 tests/cases/cargo-fmt/clean/test.toml create mode 100644 tests/cases/dotnet-format/auto-fix/files/App.csproj create mode 100644 tests/cases/dotnet-format/auto-fix/files/Program.cs create mode 100644 tests/cases/dotnet-format/auto-fix/files/mise.toml create mode 100644 tests/cases/dotnet-format/auto-fix/test.toml create mode 100644 tests/cases/dotnet-format/clean/files/App.csproj create mode 100644 tests/cases/dotnet-format/clean/files/Program.cs create mode 100644 tests/cases/dotnet-format/clean/files/mise.toml create mode 100644 tests/cases/dotnet-format/clean/test.toml create mode 100644 tests/cases/dotnet-format/failure/files/App.csproj create mode 100644 tests/cases/dotnet-format/failure/files/Program.cs create mode 100644 tests/cases/dotnet-format/failure/files/mise.toml create mode 100644 tests/cases/dotnet-format/failure/test.toml create mode 100644 tests/cases/golangci-lint/clean/files/go.mod create mode 100644 tests/cases/golangci-lint/clean/files/main.go create mode 100644 tests/cases/golangci-lint/clean/files/mise.toml create mode 100644 tests/cases/golangci-lint/clean/test.toml create mode 100644 tests/cases/golangci-lint/failure/files/go.mod create mode 100644 tests/cases/golangci-lint/failure/files/main.go create mode 100644 tests/cases/golangci-lint/failure/files/mise.toml create mode 100644 tests/cases/golangci-lint/failure/test.toml create mode 100644 tests/cases/google-java-format/auto-fix/files/Hello.java create mode 100644 tests/cases/google-java-format/auto-fix/files/mise.toml create mode 100644 tests/cases/google-java-format/auto-fix/test.toml create mode 100644 tests/cases/google-java-format/clean/files/Hello.java create mode 100644 tests/cases/google-java-format/clean/files/mise.toml create mode 100644 tests/cases/google-java-format/clean/test.toml create mode 100644 tests/cases/google-java-format/failure/files/Hello.java create mode 100644 tests/cases/google-java-format/failure/files/mise.toml create mode 100644 tests/cases/google-java-format/failure/test.toml create mode 100644 tests/cases/ktlint/auto-fix/files/Hello.kt create mode 100644 tests/cases/ktlint/auto-fix/files/mise.toml create mode 100644 tests/cases/ktlint/auto-fix/test.toml create mode 100644 tests/cases/ktlint/clean/files/Hello.kt create mode 100644 tests/cases/ktlint/clean/files/mise.toml create mode 100644 tests/cases/ktlint/clean/test.toml create mode 100644 tests/cases/ktlint/failure/files/Hello.kt create mode 100644 tests/cases/ktlint/failure/files/mise.toml create mode 100644 tests/cases/ktlint/failure/test.toml diff --git a/README.md b/README.md index f0d9c78..4d94dc7 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ being linted and cannot be redirected via a flag. | `gofmt` | `gofmt` | `*.go` | yes | — | file | — | | `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | | `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | -| `dotnet-format` | `dotnet` | `*.cs` | yes | yes | project | — | +| `dotnet-format` | `dotnet` | `*.cs` | yes | — | project | — | | `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | | `renovate-deps` | `renovate` | (all files) | yes | yes | special | — | | `license-header` | (built-in) | (all files) | no | — | special | — | diff --git a/src/registry.rs b/src/registry.rs index d96bf1a..94b07f5 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -321,15 +321,19 @@ pub fn builtin() -> Vec { .fix("google-java-format -i {FILES}") .mise_tool("github:google/google-java-format") .formatter(), - Check::files("ktlint", "ktlint {FILES}", &["*.kt", "*.kts"]) - .fix("ktlint --format {FILES}") - .mise_tool("github:pinterest/ktlint") - .bin(if cfg!(windows) { - "ktlint.bat" - } else { - "ktlint" - }) - .formatter(), + Check::files( + "ktlint", + "ktlint --log-level=error {FILES}", + &["*.kt", "*.kts"], + ) + .fix("ktlint --format --log-level=error {FILES}") + .mise_tool("github:pinterest/ktlint") + .bin(if cfg!(windows) { + "ktlint.bat" + } else { + "ktlint" + }) + .formatter(), Check::project( "dotnet-format", "dotnet format --verify-no-changes", diff --git a/tests/cases/actionlint/failure/test.toml b/tests/cases/actionlint/failure/test.toml index c8ecde8..ea40b4e 100644 --- a/tests/cases/actionlint/failure/test.toml +++ b/tests/cases/actionlint/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full actionlint" exit = 1 -stderr = """ +stderr = ''' [actionlint] .github/workflows/ci.yml:9:11: input "invalid_input" is not defined in action "actions/checkout@v4". available inputs are "clean", "fetch-depth", "fetch-tags", "filter", "github-server-url", "lfs", "path", "persist-credentials", "ref", "repository", "set-safe-directory", "show-progress", "sparse-checkout", "sparse-checkout-cone-mode", "ssh-key", "ssh-known-hosts", "ssh-strict", "ssh-user", "submodules", "token" [action] | @@ -10,4 +10,4 @@ stderr = """ flint: 1 check failed (actionlint) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/biome-format/auto-fix/test.toml b/tests/cases/biome-format/auto-fix/test.toml index 7da013c..78a4f43 100644 --- a/tests/cases/biome-format/auto-fix/test.toml +++ b/tests/cases/biome-format/auto-fix/test.toml @@ -1,15 +1,14 @@ [expected] args = "run --full --fix biome-format" exit = 1 -stderr = """ +stderr = ''' flint: fixed: biome-format — commit before pushing -""" +''' [expected.files] "data.json" = """ { "foo": "bar", "baz": 1 } """ - [fake_bins] biome = ''' #!/bin/sh diff --git a/tests/cases/biome-format/failure/test.toml b/tests/cases/biome-format/failure/test.toml index 99ca2d1..421ab78 100644 --- a/tests/cases/biome-format/failure/test.toml +++ b/tests/cases/biome-format/failure/test.toml @@ -1,14 +1,13 @@ [expected] args = "run --full biome-format" exit = 1 -stderr = """ +stderr = ''' [biome-format] data.json: Formatter would have printed different content flint: 1 check failed (biome-format) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" - +''' [fake_bins] biome = ''' #!/bin/sh diff --git a/tests/cases/biome/failure/test.toml b/tests/cases/biome/failure/test.toml index a860e64..0a216e1 100644 --- a/tests/cases/biome/failure/test.toml +++ b/tests/cases/biome/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full biome" exit = 1 -stderr = """ +stderr = ''' [biome] main.js:1:1 lint/suspicious/noDebugger @@ -10,8 +10,7 @@ main.js:1:1 lint/suspicious/noDebugger flint: 1 check failed (biome) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" - +''' [fake_bins] biome = ''' #!/bin/sh diff --git a/tests/cases/cargo-clippy/auto-fix/files/Cargo.toml b/tests/cases/cargo-clippy/auto-fix/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-clippy/auto-fix/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-clippy/auto-fix/files/mise.toml b/tests/cases/cargo-clippy/auto-fix/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-clippy/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-clippy/auto-fix/files/src/lib.rs b/tests/cases/cargo-clippy/auto-fix/files/src/lib.rs new file mode 100644 index 0000000..920280b --- /dev/null +++ b/tests/cases/cargo-clippy/auto-fix/files/src/lib.rs @@ -0,0 +1,3 @@ +pub fn add(a: i32, b: i32) -> i32 { + return a + b; +} diff --git a/tests/cases/cargo-clippy/auto-fix/test.toml b/tests/cases/cargo-clippy/auto-fix/test.toml new file mode 100644 index 0000000..f6a0b07 --- /dev/null +++ b/tests/cases/cargo-clippy/auto-fix/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full --fix cargo-clippy" +exit = 1 +stderr = ''' +flint: fixed: cargo-clippy — commit before pushing +''' + +[expected.files] +"src/lib.rs" = """ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} +""" \ No newline at end of file diff --git a/tests/cases/cargo-clippy/clean/files/Cargo.toml b/tests/cases/cargo-clippy/clean/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-clippy/clean/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-clippy/clean/files/mise.toml b/tests/cases/cargo-clippy/clean/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-clippy/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-clippy/clean/files/src/lib.rs b/tests/cases/cargo-clippy/clean/files/src/lib.rs new file mode 100644 index 0000000..b4a2a9e --- /dev/null +++ b/tests/cases/cargo-clippy/clean/files/src/lib.rs @@ -0,0 +1,3 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} diff --git a/tests/cases/cargo-clippy/clean/test.toml b/tests/cases/cargo-clippy/clean/test.toml new file mode 100644 index 0000000..4c41c93 --- /dev/null +++ b/tests/cases/cargo-clippy/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full cargo-clippy" +exit = 0 diff --git a/tests/cases/cargo-clippy/failure/files/Cargo.toml b/tests/cases/cargo-clippy/failure/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-clippy/failure/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-clippy/failure/files/mise.toml b/tests/cases/cargo-clippy/failure/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-clippy/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-clippy/failure/files/src/lib.rs b/tests/cases/cargo-clippy/failure/files/src/lib.rs new file mode 100644 index 0000000..785a01f --- /dev/null +++ b/tests/cases/cargo-clippy/failure/files/src/lib.rs @@ -0,0 +1,3 @@ +pub fn tautology(x: i32) -> bool { + x == x +} diff --git a/tests/cases/cargo-clippy/failure/test.toml b/tests/cases/cargo-clippy/failure/test.toml new file mode 100644 index 0000000..52cfbb7 --- /dev/null +++ b/tests/cases/cargo-clippy/failure/test.toml @@ -0,0 +1,19 @@ +[expected] +args = "run --full cargo-clippy" +exit = 1 +stderr = ''' +[cargo-clippy] +error: equal expressions as operands to `==` + --> src/lib.rs:2:5 + | +2 | x == x + | ^^^^^^ + | + = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#eq_op + = note: `#[deny(clippy::eq_op)]` on by default + +error: could not compile `test` (lib) due to 1 previous error + +flint: 1 check failed (cargo-clippy) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/cargo-fmt/auto-fix/test.toml b/tests/cases/cargo-fmt/auto-fix/test.toml index 85fd6d4..2369660 100644 --- a/tests/cases/cargo-fmt/auto-fix/test.toml +++ b/tests/cases/cargo-fmt/auto-fix/test.toml @@ -1,9 +1,9 @@ [expected] args = "run --full --fix cargo-fmt" exit = 1 -stderr = """ +stderr = ''' flint: fixed: cargo-fmt — commit before pushing -""" +''' [expected.files] "src/lib.rs" = """ diff --git a/tests/cases/cargo-fmt/clean/files/Cargo.toml b/tests/cases/cargo-fmt/clean/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-fmt/clean/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-fmt/clean/files/mise.toml b/tests/cases/cargo-fmt/clean/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-fmt/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-fmt/clean/files/src/lib.rs b/tests/cases/cargo-fmt/clean/files/src/lib.rs new file mode 100644 index 0000000..b4a2a9e --- /dev/null +++ b/tests/cases/cargo-fmt/clean/files/src/lib.rs @@ -0,0 +1,3 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} diff --git a/tests/cases/cargo-fmt/clean/test.toml b/tests/cases/cargo-fmt/clean/test.toml new file mode 100644 index 0000000..a92a3c2 --- /dev/null +++ b/tests/cases/cargo-fmt/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full cargo-fmt" +exit = 0 diff --git a/tests/cases/cargo-fmt/failure/test.toml b/tests/cases/cargo-fmt/failure/test.toml index 4b167d6..6455e5d 100644 --- a/tests/cases/cargo-fmt/failure/test.toml +++ b/tests/cases/cargo-fmt/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full cargo-fmt" exit = 1 -stderr = """ +stderr = ''' [cargo-fmt] Diff in /src/lib.rs:1: -pub struct Foo { pub a: u32, pub b: u32 } @@ -13,4 +13,4 @@ Diff in /src/lib.rs:1: flint: 1 check failed (cargo-fmt) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/codespell/failure/test.toml b/tests/cases/codespell/failure/test.toml index bb63857..7be19bf 100644 --- a/tests/cases/codespell/failure/test.toml +++ b/tests/cases/codespell/failure/test.toml @@ -1,11 +1,11 @@ [expected] args = "run --full codespell" exit = 1 -stderr = """ +stderr = ''' [codespell] /README.md:3: teh ==> the /README.md:3: teh ==> the flint: 1 check failed (codespell) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/dotnet-format/auto-fix/files/App.csproj b/tests/cases/dotnet-format/auto-fix/files/App.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/files/App.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/tests/cases/dotnet-format/auto-fix/files/Program.cs b/tests/cases/dotnet-format/auto-fix/files/Program.cs new file mode 100644 index 0000000..fe1caf2 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/files/Program.cs @@ -0,0 +1,7 @@ +class Program +{ +static void Main() +{ +System.Console.WriteLine("Hello"); +} +} diff --git a/tests/cases/dotnet-format/auto-fix/files/mise.toml b/tests/cases/dotnet-format/auto-fix/files/mise.toml new file mode 100644 index 0000000..e95ecd7 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "latest" diff --git a/tests/cases/dotnet-format/auto-fix/test.toml b/tests/cases/dotnet-format/auto-fix/test.toml new file mode 100644 index 0000000..1114a92 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/test.toml @@ -0,0 +1,21 @@ +[expected] +args = "run --full --fix dotnet-format" +exit = 1 +stderr = ''' +flint: fixed: dotnet-format — commit before pushing +''' + +[expected.files] +"Program.cs" = """ +class Program +{ + static void Main() + { + System.Console.WriteLine("Hello"); + } +} +""" + +[env] +DOTNET_CLI_HOME = "/tmp/dotnet-home" +DOTNET_NOLOGO = "1" diff --git a/tests/cases/dotnet-format/clean/files/App.csproj b/tests/cases/dotnet-format/clean/files/App.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/tests/cases/dotnet-format/clean/files/App.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/tests/cases/dotnet-format/clean/files/Program.cs b/tests/cases/dotnet-format/clean/files/Program.cs new file mode 100644 index 0000000..e913fd1 --- /dev/null +++ b/tests/cases/dotnet-format/clean/files/Program.cs @@ -0,0 +1,7 @@ +class Program +{ + static void Main() + { + System.Console.WriteLine("Hello"); + } +} diff --git a/tests/cases/dotnet-format/clean/files/mise.toml b/tests/cases/dotnet-format/clean/files/mise.toml new file mode 100644 index 0000000..e95ecd7 --- /dev/null +++ b/tests/cases/dotnet-format/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "latest" diff --git a/tests/cases/dotnet-format/clean/test.toml b/tests/cases/dotnet-format/clean/test.toml new file mode 100644 index 0000000..430b04d --- /dev/null +++ b/tests/cases/dotnet-format/clean/test.toml @@ -0,0 +1,8 @@ +[expected] +args = "run --full dotnet-format" +exit = 0 + + +[env] +DOTNET_CLI_HOME = "/tmp/dotnet-home" +DOTNET_NOLOGO = "1" diff --git a/tests/cases/dotnet-format/failure/files/App.csproj b/tests/cases/dotnet-format/failure/files/App.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/tests/cases/dotnet-format/failure/files/App.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/tests/cases/dotnet-format/failure/files/Program.cs b/tests/cases/dotnet-format/failure/files/Program.cs new file mode 100644 index 0000000..fe1caf2 --- /dev/null +++ b/tests/cases/dotnet-format/failure/files/Program.cs @@ -0,0 +1,7 @@ +class Program +{ +static void Main() +{ +System.Console.WriteLine("Hello"); +} +} diff --git a/tests/cases/dotnet-format/failure/files/mise.toml b/tests/cases/dotnet-format/failure/files/mise.toml new file mode 100644 index 0000000..e95ecd7 --- /dev/null +++ b/tests/cases/dotnet-format/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "latest" diff --git a/tests/cases/dotnet-format/failure/test.toml b/tests/cases/dotnet-format/failure/test.toml new file mode 100644 index 0000000..c1961bb --- /dev/null +++ b/tests/cases/dotnet-format/failure/test.toml @@ -0,0 +1,17 @@ +[expected] +args = "run --full dotnet-format" +exit = 1 +stderr = ''' +[dotnet-format] +/Program.cs(3,1): error WHITESPACE: Fix whitespace formatting. Insert '\s\s\s\s'. [/App.csproj] +/Program.cs(4,1): error WHITESPACE: Fix whitespace formatting. Insert '\s\s\s\s'. [/App.csproj] +/Program.cs(5,1): error WHITESPACE: Fix whitespace formatting. Insert '\s\s\s\s\s\s\s\s'. [/App.csproj] +/Program.cs(6,1): error WHITESPACE: Fix whitespace formatting. Insert '\s\s\s\s'. [/App.csproj] + +flint: 1 check failed (dotnet-format) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' + +[env] +DOTNET_CLI_HOME = "/tmp/dotnet-home" +DOTNET_NOLOGO = "1" diff --git a/tests/cases/editorconfig-checker/failure/test.toml b/tests/cases/editorconfig-checker/failure/test.toml index 3088007..e7ce1be 100644 --- a/tests/cases/editorconfig-checker/failure/test.toml +++ b/tests/cases/editorconfig-checker/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full editorconfig-checker" exit = 1 -stderr = """ +stderr = ''' [editorconfig-checker] hello.txt: 2: Trailing whitespace @@ -10,4 +10,4 @@ hello.txt: flint: 1 check failed (editorconfig-checker) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/general/auto-fix-and-review/test.toml b/tests/cases/general/auto-fix-and-review/test.toml index 0969d79..4397d34 100644 --- a/tests/cases/general/auto-fix-and-review/test.toml +++ b/tests/cases/general/auto-fix-and-review/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full --fix cargo-fmt shellcheck" exit = 1 -stderr = """ +stderr = ''' [shellcheck] In /bad.sh line 2: @@ -14,7 +14,7 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: fixed: cargo-fmt — commit before pushing | review: shellcheck -""" +''' [expected.files] "src/lib.rs" = """ diff --git a/tests/cases/general/auto-review-two-linters/test.toml b/tests/cases/general/auto-review-two-linters/test.toml index 86789de..b7f166e 100644 --- a/tests/cases/general/auto-review-two-linters/test.toml +++ b/tests/cases/general/auto-review-two-linters/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full --fix shellcheck actionlint" exit = 1 -stderr = """ +stderr = ''' [actionlint] .github/workflows/ci.yml:6:23: undefined variable "foo". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression] | @@ -19,11 +19,10 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: review: actionlint, shellcheck -""" - +''' [fake_bins] actionlint = ''' #!/bin/sh printf '.github/workflows/ci.yml:6:23: undefined variable "foo". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression]\n |\n6 | - run: echo ${{ foo.bar }}\n | ^~~~~~~\n' exit 1 -''' \ No newline at end of file +''' diff --git a/tests/cases/general/auto-review-unfixable/test.toml b/tests/cases/general/auto-review-unfixable/test.toml index 078054a..664c077 100644 --- a/tests/cases/general/auto-review-unfixable/test.toml +++ b/tests/cases/general/auto-review-unfixable/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full --fix shellcheck" exit = 1 -stderr = """ +stderr = ''' [shellcheck] In /bad.sh line 2: @@ -14,4 +14,4 @@ echo "$1" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... flint: review: shellcheck -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index 033814c..8fbef06 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -1,7 +1,7 @@ [expected] args = "linters" exit = 0 -stdout = """ +stdout = ''' NAME BINARY STATUS SPEED PATTERNS ------------------------------------------------------------------------- shellcheck shellcheck active fast *.sh *.bash *.bats @@ -26,7 +26,7 @@ dotnet-format dotnet missing fast *.cs lychee lychee active fast renovate-deps renovate active slow license-header license-header active fast -""" +''' [fake_bins] actionlint = ''' #!/bin/sh diff --git a/tests/cases/gofmt/auto-fix/test.toml b/tests/cases/gofmt/auto-fix/test.toml index a358624..3cf634f 100644 --- a/tests/cases/gofmt/auto-fix/test.toml +++ b/tests/cases/gofmt/auto-fix/test.toml @@ -1,9 +1,9 @@ [expected] args = "run --full --fix gofmt" exit = 1 -stderr = """ +stderr = ''' flint: fixed: gofmt — commit before pushing -""" +''' [expected.files] "main.go" = """ diff --git a/tests/cases/gofmt/failure/test.toml b/tests/cases/gofmt/failure/test.toml index 82f1ff5..02b61dc 100644 --- a/tests/cases/gofmt/failure/test.toml +++ b/tests/cases/gofmt/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full gofmt" exit = 1 -stderr = """ +stderr = ''' [gofmt] diff /main.go.orig /main.go --- /main.go.orig @@ -17,4 +17,4 @@ diff /main.go.orig /main.go flint: 1 check failed (gofmt) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/golangci-lint/clean/files/go.mod b/tests/cases/golangci-lint/clean/files/go.mod new file mode 100644 index 0000000..30c69d8 --- /dev/null +++ b/tests/cases/golangci-lint/clean/files/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.21 diff --git a/tests/cases/golangci-lint/clean/files/main.go b/tests/cases/golangci-lint/clean/files/main.go new file mode 100644 index 0000000..99fd805 --- /dev/null +++ b/tests/cases/golangci-lint/clean/files/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello") +} diff --git a/tests/cases/golangci-lint/clean/files/mise.toml b/tests/cases/golangci-lint/clean/files/mise.toml new file mode 100644 index 0000000..748a0bc --- /dev/null +++ b/tests/cases/golangci-lint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +golangci-lint = "latest" diff --git a/tests/cases/golangci-lint/clean/test.toml b/tests/cases/golangci-lint/clean/test.toml new file mode 100644 index 0000000..1e0d63c --- /dev/null +++ b/tests/cases/golangci-lint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full golangci-lint" +exit = 0 diff --git a/tests/cases/golangci-lint/failure/files/go.mod b/tests/cases/golangci-lint/failure/files/go.mod new file mode 100644 index 0000000..30c69d8 --- /dev/null +++ b/tests/cases/golangci-lint/failure/files/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.21 diff --git a/tests/cases/golangci-lint/failure/files/main.go b/tests/cases/golangci-lint/failure/files/main.go new file mode 100644 index 0000000..e06b39e --- /dev/null +++ b/tests/cases/golangci-lint/failure/files/main.go @@ -0,0 +1,7 @@ +package main + +import "os" + +func main() { + os.Remove("/tmp/test") +} diff --git a/tests/cases/golangci-lint/failure/files/mise.toml b/tests/cases/golangci-lint/failure/files/mise.toml new file mode 100644 index 0000000..748a0bc --- /dev/null +++ b/tests/cases/golangci-lint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +golangci-lint = "latest" diff --git a/tests/cases/golangci-lint/failure/test.toml b/tests/cases/golangci-lint/failure/test.toml new file mode 100644 index 0000000..294fb2b --- /dev/null +++ b/tests/cases/golangci-lint/failure/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full golangci-lint" +exit = 1 +stderr = ''' +[golangci-lint] +main.go:6:11: Error return value of `os.Remove` is not checked (errcheck) + os.Remove("/tmp/test") + ^ +1 issues: +* errcheck: 1 + +flint: 1 check failed (golangci-lint) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/google-java-format/auto-fix/files/Hello.java b/tests/cases/google-java-format/auto-fix/files/Hello.java new file mode 100644 index 0000000..a605554 --- /dev/null +++ b/tests/cases/google-java-format/auto-fix/files/Hello.java @@ -0,0 +1,5 @@ +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} diff --git a/tests/cases/google-java-format/auto-fix/files/mise.toml b/tests/cases/google-java-format/auto-fix/files/mise.toml new file mode 100644 index 0000000..eb0a01b --- /dev/null +++ b/tests/cases/google-java-format/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/auto-fix/test.toml b/tests/cases/google-java-format/auto-fix/test.toml new file mode 100644 index 0000000..259eea5 --- /dev/null +++ b/tests/cases/google-java-format/auto-fix/test.toml @@ -0,0 +1,15 @@ +[expected] +args = "run --full --fix google-java-format" +exit = 1 +stderr = ''' +flint: fixed: google-java-format — commit before pushing +''' + +[expected.files] +"Hello.java" = """ +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +""" \ No newline at end of file diff --git a/tests/cases/google-java-format/clean/files/Hello.java b/tests/cases/google-java-format/clean/files/Hello.java new file mode 100644 index 0000000..386b0a9 --- /dev/null +++ b/tests/cases/google-java-format/clean/files/Hello.java @@ -0,0 +1,5 @@ +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} diff --git a/tests/cases/google-java-format/clean/files/mise.toml b/tests/cases/google-java-format/clean/files/mise.toml new file mode 100644 index 0000000..eb0a01b --- /dev/null +++ b/tests/cases/google-java-format/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/clean/test.toml b/tests/cases/google-java-format/clean/test.toml new file mode 100644 index 0000000..c836985 --- /dev/null +++ b/tests/cases/google-java-format/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full google-java-format" +exit = 0 diff --git a/tests/cases/google-java-format/failure/files/Hello.java b/tests/cases/google-java-format/failure/files/Hello.java new file mode 100644 index 0000000..a605554 --- /dev/null +++ b/tests/cases/google-java-format/failure/files/Hello.java @@ -0,0 +1,5 @@ +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} diff --git a/tests/cases/google-java-format/failure/files/mise.toml b/tests/cases/google-java-format/failure/files/mise.toml new file mode 100644 index 0000000..eb0a01b --- /dev/null +++ b/tests/cases/google-java-format/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/failure/test.toml b/tests/cases/google-java-format/failure/test.toml new file mode 100644 index 0000000..2340e93 --- /dev/null +++ b/tests/cases/google-java-format/failure/test.toml @@ -0,0 +1,10 @@ +[expected] +args = "run --full google-java-format" +exit = 1 +stderr = ''' +[google-java-format] +/Hello.java + +flint: 1 check failed (google-java-format) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/hadolint/failure/test.toml b/tests/cases/hadolint/failure/test.toml index 07768f1..67d89b2 100644 --- a/tests/cases/hadolint/failure/test.toml +++ b/tests/cases/hadolint/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full hadolint" exit = 1 -stderr = """ +stderr = ''' [hadolint] /Dockerfile:1 DL3007 warning: Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag /Dockerfile:2 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install ` use `apt-get install =` @@ -9,4 +9,4 @@ stderr = """ flint: 1 check failed (hadolint) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/ktlint/auto-fix/files/Hello.kt b/tests/cases/ktlint/auto-fix/files/Hello.kt new file mode 100644 index 0000000..04a54e9 --- /dev/null +++ b/tests/cases/ktlint/auto-fix/files/Hello.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello") +} diff --git a/tests/cases/ktlint/auto-fix/files/mise.toml b/tests/cases/ktlint/auto-fix/files/mise.toml new file mode 100644 index 0000000..9a1910e --- /dev/null +++ b/tests/cases/ktlint/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:pinterest/ktlint" = "latest" diff --git a/tests/cases/ktlint/auto-fix/test.toml b/tests/cases/ktlint/auto-fix/test.toml new file mode 100644 index 0000000..0c3d580 --- /dev/null +++ b/tests/cases/ktlint/auto-fix/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full --fix ktlint" +exit = 1 +stderr = ''' +flint: fixed: ktlint — commit before pushing +''' + +[expected.files] +"Hello.kt" = """ +fun main() { + println("Hello") +} +""" \ No newline at end of file diff --git a/tests/cases/ktlint/clean/files/Hello.kt b/tests/cases/ktlint/clean/files/Hello.kt new file mode 100644 index 0000000..71c286e --- /dev/null +++ b/tests/cases/ktlint/clean/files/Hello.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello") +} diff --git a/tests/cases/ktlint/clean/files/mise.toml b/tests/cases/ktlint/clean/files/mise.toml new file mode 100644 index 0000000..9a1910e --- /dev/null +++ b/tests/cases/ktlint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:pinterest/ktlint" = "latest" diff --git a/tests/cases/ktlint/clean/test.toml b/tests/cases/ktlint/clean/test.toml new file mode 100644 index 0000000..86797e4 --- /dev/null +++ b/tests/cases/ktlint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full ktlint" +exit = 0 diff --git a/tests/cases/ktlint/failure/files/Hello.kt b/tests/cases/ktlint/failure/files/Hello.kt new file mode 100644 index 0000000..04a54e9 --- /dev/null +++ b/tests/cases/ktlint/failure/files/Hello.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello") +} diff --git a/tests/cases/ktlint/failure/files/mise.toml b/tests/cases/ktlint/failure/files/mise.toml new file mode 100644 index 0000000..9a1910e --- /dev/null +++ b/tests/cases/ktlint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:pinterest/ktlint" = "latest" diff --git a/tests/cases/ktlint/failure/test.toml b/tests/cases/ktlint/failure/test.toml new file mode 100644 index 0000000..f5bcdb5 --- /dev/null +++ b/tests/cases/ktlint/failure/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full ktlint" +exit = 1 +stderr = ''' +[ktlint] +/Hello.kt:2:1: Unexpected indentation (2) (should be 4) (standard:indent) + +Summary error count (descending) by rule: + standard:indent: 1 + +flint: 1 check failed (ktlint) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/license-header/failure/test.toml b/tests/cases/license-header/failure/test.toml index 8e58999..81d2d90 100644 --- a/tests/cases/license-header/failure/test.toml +++ b/tests/cases/license-header/failure/test.toml @@ -1,10 +1,10 @@ [expected] args = "run --full license-header" exit = 1 -stderr = """ +stderr = ''' [license-header] Main.java: missing license header flint: 1 check failed (license-header) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/lychee/broken-link/test.toml b/tests/cases/lychee/broken-link/test.toml index 41cb96c..2591ea2 100644 --- a/tests/cases/lychee/broken-link/test.toml +++ b/tests/cases/lychee/broken-link/test.toml @@ -1,14 +1,14 @@ [expected] args = "run --full lychee" exit = 1 -stderr = """ +stderr = ''' [lychee] ==> Checking all links in all files [404] https://example.com/does-not-exist flint: 1 check failed (lychee) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" +''' [env] LYCHEE_SKIP_GITHUB_REMAPS = "true" diff --git a/tests/cases/markdownlint-cli2/auto-fix/test.toml b/tests/cases/markdownlint-cli2/auto-fix/test.toml index 29a5293..9438367 100644 --- a/tests/cases/markdownlint-cli2/auto-fix/test.toml +++ b/tests/cases/markdownlint-cli2/auto-fix/test.toml @@ -1,9 +1,9 @@ [expected] args = "run --full --fix markdownlint-cli2" exit = 1 -stderr = """ +stderr = ''' flint: fixed: markdownlint-cli2 — commit before pushing -""" +''' [expected.files] "README.md" = """ diff --git a/tests/cases/markdownlint-cli2/failure/test.toml b/tests/cases/markdownlint-cli2/failure/test.toml index 0556d20..11adb99 100644 --- a/tests/cases/markdownlint-cli2/failure/test.toml +++ b/tests/cases/markdownlint-cli2/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full markdownlint-cli2" exit = 1 -stderr = """ +stderr = ''' [markdownlint-cli2] markdownlint-cli2 v0.17.2 (markdownlint v0.37.4) Finding: /README.md @@ -11,4 +11,4 @@ README.md:3:26 MD009/no-trailing-spaces Trailing spaces [Expected: 0 or 2; Actua flint: 1 check failed (markdownlint-cli2) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/prettier/auto-fix/test.toml b/tests/cases/prettier/auto-fix/test.toml index 04dfb48..3690de9 100644 --- a/tests/cases/prettier/auto-fix/test.toml +++ b/tests/cases/prettier/auto-fix/test.toml @@ -1,9 +1,9 @@ [expected] args = "run --full --fix prettier" exit = 1 -stderr = """ +stderr = ''' flint: fixed: prettier — commit before pushing -""" +''' [expected.files] "config.yml" = """ diff --git a/tests/cases/prettier/failure/test.toml b/tests/cases/prettier/failure/test.toml index 5a822d4..4caf9ed 100644 --- a/tests/cases/prettier/failure/test.toml +++ b/tests/cases/prettier/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full prettier" exit = 1 -stderr = """ +stderr = ''' [prettier] Checking formatting... [warn] config.yml @@ -9,4 +9,4 @@ Checking formatting... flint: 1 check failed (prettier) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/renovate-deps/fix-create/test.toml b/tests/cases/renovate-deps/fix-create/test.toml index 16fa24b..6c340f0 100644 --- a/tests/cases/renovate-deps/fix-create/test.toml +++ b/tests/cases/renovate-deps/fix-create/test.toml @@ -1,9 +1,9 @@ [expected] args = "run --full --fix renovate-deps" exit = 1 -stderr = """ +stderr = ''' flint: fixed: renovate-deps — commit before pushing -""" +''' [expected.files] ".github/renovate-tracked-deps.json" = """ diff --git a/tests/cases/renovate-deps/fix-update/test.toml b/tests/cases/renovate-deps/fix-update/test.toml index 16fa24b..6c340f0 100644 --- a/tests/cases/renovate-deps/fix-update/test.toml +++ b/tests/cases/renovate-deps/fix-update/test.toml @@ -1,9 +1,9 @@ [expected] args = "run --full --fix renovate-deps" exit = 1 -stderr = """ +stderr = ''' flint: fixed: renovate-deps — commit before pushing -""" +''' [expected.files] ".github/renovate-tracked-deps.json" = """ diff --git a/tests/cases/renovate-deps/out-of-date/test.toml b/tests/cases/renovate-deps/out-of-date/test.toml index 0e49656..a6d89bd 100644 --- a/tests/cases/renovate-deps/out-of-date/test.toml +++ b/tests/cases/renovate-deps/out-of-date/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full renovate-deps" exit = 1 -stderr = """ +stderr = ''' [renovate-deps] --- .github/renovate-tracked-deps.json +++ generated @@ -20,7 +20,7 @@ Run `flint run --fix renovate-deps` to update. flint: 1 check failed (renovate-deps) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" +''' [expected.files] ".github/renovate-tracked-deps.json" = """ diff --git a/tests/cases/ruff-format/auto-fix/test.toml b/tests/cases/ruff-format/auto-fix/test.toml index bb7ba2e..3ce0b88 100644 --- a/tests/cases/ruff-format/auto-fix/test.toml +++ b/tests/cases/ruff-format/auto-fix/test.toml @@ -1,9 +1,9 @@ [expected] args = "run --full --fix ruff-format" exit = 1 -stderr = """ +stderr = ''' flint: fixed: ruff-format — commit before pushing -""" +''' [expected.files] "main.py" = """ diff --git a/tests/cases/ruff-format/failure/test.toml b/tests/cases/ruff-format/failure/test.toml index 8307174..6988eef 100644 --- a/tests/cases/ruff-format/failure/test.toml +++ b/tests/cases/ruff-format/failure/test.toml @@ -1,11 +1,11 @@ [expected] args = "run --full ruff-format" exit = 1 -stderr = """ +stderr = ''' [ruff-format] Would reformat: main.py 1 file would be reformatted flint: 1 check failed (ruff-format) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/ruff/failure/test.toml b/tests/cases/ruff/failure/test.toml index f25d2ae..b7f3a52 100644 --- a/tests/cases/ruff/failure/test.toml +++ b/tests/cases/ruff/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full ruff" exit = 1 -stderr = """ +stderr = ''' [ruff] F401 [*] `os` imported but unused --> main.py:1:8 @@ -18,4 +18,4 @@ Found 1 error. flint: 1 check failed (ruff) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/shellcheck/failure/test.toml b/tests/cases/shellcheck/failure/test.toml index 64d581d..71e11cd 100644 --- a/tests/cases/shellcheck/failure/test.toml +++ b/tests/cases/shellcheck/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full shellcheck" exit = 1 -stderr = """ +stderr = ''' [shellcheck] In /bad.sh line 2: @@ -16,4 +16,4 @@ For more information: flint: 1 check failed (shellcheck) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/cases/shfmt/auto-fix/test.toml b/tests/cases/shfmt/auto-fix/test.toml index 46d2686..feb46d6 100644 --- a/tests/cases/shfmt/auto-fix/test.toml +++ b/tests/cases/shfmt/auto-fix/test.toml @@ -1,9 +1,9 @@ [expected] args = "run --full --fix shfmt" exit = 1 -stderr = """ +stderr = ''' flint: fixed: shfmt — commit before pushing -""" +''' [expected.files] "script.sh" = """ diff --git a/tests/cases/shfmt/failure/test.toml b/tests/cases/shfmt/failure/test.toml index 03af7d6..89700a0 100644 --- a/tests/cases/shfmt/failure/test.toml +++ b/tests/cases/shfmt/failure/test.toml @@ -1,7 +1,7 @@ [expected] args = "run --full shfmt" exit = 1 -stderr = """ +stderr = ''' [shfmt] diff /script.sh.orig /script.sh --- /script.sh.orig @@ -15,4 +15,4 @@ diff /script.sh.orig /script.sh flint: 1 check failed (shfmt) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -""" \ No newline at end of file +''' \ No newline at end of file diff --git a/tests/e2e.rs b/tests/e2e.rs index 1e9a14e..51dbd60 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -279,10 +279,10 @@ fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdo out += &format!("args = \"{}\"\n", toml_escape(args_str)); out += &format!("exit = {exit}\n"); if !stderr.is_empty() { - out += &format!("stderr = \"\"\"\n{stderr}\"\"\""); + out += &format!("stderr = '''\n{stderr}'''"); } if !stdout.is_empty() { - out += &format!("stdout = \"\"\"\n{stdout}\"\"\""); + out += &format!("stdout = '''\n{stdout}'''"); } if let Some(files) = existing_files { out += "\n\n[expected.files]\n"; From c85c02a140a7b24ff5df4c3fe14b80944986949e Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Sun, 5 Apr 2026 14:52:25 +0000 Subject: [PATCH 067/141] feat: scope files-linters to changed files, add full_cmd for all-files mode - cargo-fmt: switch from `cargo fmt` to `rustfmt {CARGO_EDITION_FLAG} --check {FILES}` so only changed .rs files are checked; full mode uses `cargo fmt -- --check` (handles edition via Cargo and avoids long arg lists) - dotnet-format: switch to files scope with `--include {RELFILES}` for changed files; full mode uses `dotnet format --verify-no-changes` without --include - prettier, ktlint: add full_cmd so --full uses a single project-wide invocation (`{ROOT}`) instead of a long file list - New template placeholders: `{RELFILES}` (paths relative to project root), `{CARGO_EDITION_FLAG}` (reads edition from Cargo.toml), `{ROOT}` (absolute project root, used in full_cmd) - New `full_cmd(check, fix)` builder: project-wide commands used when file_list.full == true (explicit --full or no merge base) - Add Notes column to README linter table; call out cargo-clippy limitation (lints all .rs files, not just changed) and golangci-lint's --new-from-rev --- .github/agents/knowledge/linters.md | 21 ++++++++- README.md | 54 +++++++++++---------- src/files.rs | 10 +++- src/registry.rs | 73 ++++++++++++++++++++++++----- src/runner.rs | 57 +++++++++++++++++++++- tests/cases/general/list/test.toml | 4 +- 6 files changed, 176 insertions(+), 43 deletions(-) diff --git a/.github/agents/knowledge/linters.md b/.github/agents/knowledge/linters.md index 2844472..ae139fb 100644 --- a/.github/agents/knowledge/linters.md +++ b/.github/agents/knowledge/linters.md @@ -8,10 +8,16 @@ builder pattern: Check::file("mytool", "mytool --check {FILE}", &["*.ext"]) .fix("mytool --fix {FILE}"), -// Files scope — invoked once with all matched files +// Files scope — invoked once with all matched files (absolute paths) Check::files("mytool", "mytool {FILES}", &["*.ext"]) .fix("mytool --fix {FILES}"), +// Files scope — invoked once with all matched files (relative to project root) +// Use {RELFILES} when the tool requires paths relative to the project root +// (e.g. dotnet format --include). +Check::files("mytool", "mytool --include {RELFILES}", &["*.ext"]) + .fix("mytool --fix --include {RELFILES}"), + // Project scope — invoked once, skipped if no *.ext changed Check::project("mytool", "mytool run", &["*.ext"]), ``` @@ -54,10 +60,21 @@ Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) `.linter_config` that injects the directory rather than the full file path (not yet implemented) - The tool is project-scoped and its config must live at the project root to - function (e.g. `cargo-fmt` reads `rustfmt.toml` via Cargo, not a direct flag) + function (no explicit `--config` flag exists) Look up the tool's `--help` or man page for the config flag name and expected argument type before adding `.linter_config`. For checks that need custom logic (not a simple command template), add a module under `src/linters/` and use `CheckKind::Special`. + +## Changed-files scoping + +Most linters use `file` or `files` scope, so they naturally receive only changed +files as arguments. `golangci-lint` uses `project` scope but scopes internally via +`--new-from-rev={MERGE_BASE}`. + +**`cargo-clippy` cannot scope to changed files.** Cargo has no git-aware flag +equivalent to `--new-from-rev`. It still skips entirely when no `*.rs` files +changed, but when it does run it checks the whole project. Workspace support +(`-p --no-deps` per changed package) would be a future improvement. diff --git a/README.md b/README.md index 4d94dc7..5fc5863 100644 --- a/README.md +++ b/README.md @@ -222,30 +222,30 @@ being linted and cannot be redirected via a flag. -| Name | Binary | Patterns | Fix | Slow | Scope | Config file | -| ---------------------- | -------------------- | -------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | — | file | — | -| `markdownlint-cli2` | `markdownlint-cli2` | `*.md` | yes | — | file | `.markdownlint.json` | -| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | — | files | `.prettierrc` | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | — | file | `actionlint.yml` | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | — | file | `.hadolint.yaml` | -| `codespell` | `codespell` | `*` | yes | — | files | `.codespellrc` | -| `editorconfig-checker` | `ec` | `*` | no | — | files | `.editorconfig-checker.json` | -| `golangci-lint` | `golangci-lint` | `*.go` | no | — | project | `.golangci.yml` | -| `ruff` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | -| `ruff-format` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | -| `cargo-fmt` | `cargo-fmt` | `*.rs` | yes | — | project | — | -| `gofmt` | `gofmt` | `*.go` | yes | — | file | — | -| `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | -| `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | -| `dotnet-format` | `dotnet` | `*.cs` | yes | — | project | — | -| `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | -| `renovate-deps` | `renovate` | (all files) | yes | yes | special | — | -| `license-header` | (built-in) | (all files) | no | — | special | — | +| Name | Binary | Patterns | Fix | Slow | Scope | Config file | Notes | +| ---------------------- | -------------------- | -------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | --------------------------------------------- | +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | — | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | — | file | — | — | +| `markdownlint-cli2` | `markdownlint-cli2` | `*.md` | yes | — | file | `.markdownlint.json` | — | +| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | — | files | `.prettierrc` | — | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | — | file | `actionlint.yml` | — | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | — | file | `.hadolint.yaml` | — | +| `codespell` | `codespell` | `*` | yes | — | files | `.codespellrc` | — | +| `editorconfig-checker` | `ec` | `*` | no | — | files | `.editorconfig-checker.json` | — | +| `golangci-lint` | `golangci-lint` | `*.go` | no | — | project | `.golangci.yml` | uses --new-from-rev to lint only changed code | +| `ruff` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | — | +| `ruff-format` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | — | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | lints all .rs files, not just changed | +| `cargo-fmt` | `rustfmt` | `*.rs` | yes | — | files | — | — | +| `gofmt` | `gofmt` | `*.go` | yes | — | file | — | — | +| `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | — | +| `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | — | +| `dotnet-format` | `dotnet` | `*.cs` | yes | — | files | — | — | +| `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | — | +| `renovate-deps` | `renovate` | (all files) | yes | yes | special | — | — | +| `license-header` | (built-in) | (all files) | no | — | special | — | — | @@ -256,9 +256,11 @@ config injection for `biome` and `biome-format` is not yet implemented. **Scopes:** - `file` — invoked once per matched file -- `files` — invoked once with all matched files as args +- `files` — invoked once with all matched files as args; only changed files are passed - `project` — invoked once with no file args; for checks with patterns set - (e.g. `cargo-clippy`), skipped entirely if no matching files changed + (e.g. `cargo-clippy`), skipped entirely if no matching files changed, but runs on + the whole project when it does run. `golangci-lint` is the exception — it uses + `--new-from-rev` to scope analysis to changed code even within the project run. **Slow checks** (Slow = yes) are skipped by `--fast-only`. Use `--fast-only` for local/pre-push feedback and the full set in CI. diff --git a/src/files.rs b/src/files.rs index ce02118..cb4de56 100644 --- a/src/files.rs +++ b/src/files.rs @@ -13,6 +13,9 @@ pub struct FileList { pub files: Vec, /// The merge base ref, used by project-scoped checks (e.g. golangci-lint). pub merge_base: Option, + /// True when the file list contains all project files (explicit --full or no merge base). + /// Used by checks with a `full_cmd` to switch to a project-wide command. + pub full: bool, } pub fn changed( @@ -39,7 +42,11 @@ pub fn changed( return all_files(project_root, exclude_re.as_ref(), exclude_paths); }; - Ok(FileList { files, merge_base }) + Ok(FileList { + files, + merge_base, + full: false, + }) } fn compile_exclude_re(cfg: &Config) -> Option { @@ -118,6 +125,7 @@ fn all_files( Ok(FileList { files: filter_names(project_root, exclude_re, exclude_paths, names), merge_base: None, + full: true, }) } diff --git a/src/registry.rs b/src/registry.rs index 94b07f5..ae42882 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -24,6 +24,12 @@ pub enum CheckKind { Template { check_cmd: &'static str, fix_cmd: &'static str, + /// When set and `file_list.full == true`, used instead of `check_cmd` as a + /// project-wide check command (no `{FILES}` substitution). Useful for tools like + /// `cargo fmt` that handle all-files scanning better than a long file list. + full_cmd: &'static str, + /// When set and `file_list.full == true` in fix mode, used instead of `fix_cmd`. + full_fix_cmd: &'static str, scope: Scope, }, Special(SpecialKind), @@ -60,6 +66,8 @@ pub struct Check { /// Always considered active regardless of mise.toml (used for config-activated checks). pub activate_unconditionally: bool, pub kind: CheckKind, + /// Optional note shown in the README linter table. + pub note: Option<&'static str>, } impl Check { @@ -127,8 +135,11 @@ impl Check { kind: CheckKind::Template { check_cmd, fix_cmd: "", + full_cmd: "", + full_fix_cmd: "", scope, }, + note: None, } } @@ -147,6 +158,7 @@ impl Check { defers_to_formatters: false, activate_unconditionally: false, kind: CheckKind::Special(kind), + note: None, } } @@ -177,6 +189,23 @@ impl Check { self } + /// Set project-wide commands used instead of `check_cmd`/`fix_cmd` when + /// `file_list.full == true` (explicit `--full` or no merge base). Commands + /// run with no file arguments — useful for tools that discover files internally + /// (e.g. `cargo fmt`). Also handles edition detection for Rust tools. + pub fn full_cmd(mut self, check: &'static str, fix: &'static str) -> Self { + if let CheckKind::Template { + full_cmd: ref mut c, + full_fix_cmd: ref mut f, + .. + } = self.kind + { + *c = check; + *f = fix; + } + self + } + /// Restrict activation to a semver range of the declared tool version. #[allow(dead_code)] pub fn version_req(mut self, range: &'static str) -> Self { @@ -208,6 +237,12 @@ impl Check { self } + /// Add a note shown in the README linter table. + pub fn note(mut self, note: &'static str) -> Self { + self.note = Some(note); + self + } + /// Inject a config file from config_dir into the linter command. /// If `config_dir/file` exists at runtime, `flag ` is inserted /// right after the binary name. Has no effect when the file is absent. @@ -249,6 +284,7 @@ pub fn builtin() -> Vec { &["*.md", "*.yml", "*.yaml"], ) .fix("prettier --write {FILES}") + .full_cmd("prettier --check {ROOT}", "prettier --write {ROOT}") .linter_config(".prettierrc", "--config") .formatter(), Check::file( @@ -279,7 +315,8 @@ pub fn builtin() -> Vec { "golangci-lint run --new-from-rev={MERGE_BASE}", &["*.go"], ) - .linter_config(".golangci.yml", "--config"), + .linter_config(".golangci.yml", "--config") + .note("uses --new-from-rev to lint only changed code"), Check::file("ruff", "ruff check {FILE}", &["*.py"]) .fix("ruff check --fix {FILE}") .linter_config("ruff.toml", "--config"), @@ -304,11 +341,18 @@ pub fn builtin() -> Vec { .formatter(), Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") - .mise_tool("rust"), - Check::project("cargo-fmt", "cargo fmt -- --check", &["*.rs"]) - .fix("cargo fmt") .mise_tool("rust") - .formatter(), + .note("lints all .rs files, not just changed"), + Check::files( + "cargo-fmt", + "rustfmt {CARGO_EDITION_FLAG} --check {FILES}", + &["*.rs"], + ) + .fix("rustfmt {CARGO_EDITION_FLAG} {FILES}") + .full_cmd("cargo fmt -- --check", "cargo fmt") + .bin("rustfmt") + .mise_tool("rust") + .formatter(), Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) .fix("gofmt -w {FILE}") .mise_tool("go") @@ -327,6 +371,10 @@ pub fn builtin() -> Vec { &["*.kt", "*.kts"], ) .fix("ktlint --format --log-level=error {FILES}") + .full_cmd( + "ktlint --log-level=error {ROOT}", + "ktlint --format --log-level=error {ROOT}", + ) .mise_tool("github:pinterest/ktlint") .bin(if cfg!(windows) { "ktlint.bat" @@ -334,12 +382,13 @@ pub fn builtin() -> Vec { "ktlint" }) .formatter(), - Check::project( + Check::files( "dotnet-format", - "dotnet format --verify-no-changes", + "dotnet format --verify-no-changes --include {RELFILES}", &["*.cs"], ) - .fix("dotnet format") + .fix("dotnet format --include {RELFILES}") + .full_cmd("dotnet format --verify-no-changes", "dotnet format") .bin("dotnet") .mise_tool("dotnet") .formatter(), @@ -584,8 +633,9 @@ mod tests { "Slow", "Scope", "Config file", + "Notes", ]; - let rows: Vec<[String; 7]> = registry.iter().map(table_row).collect(); + let rows: Vec<[String; 8]> = registry.iter().map(table_row).collect(); // Compute column widths. let mut widths = headers.map(|h| h.len()); @@ -621,7 +671,7 @@ mod tests { lines.join("\n") } - fn table_row(check: &Check) -> [String; 7] { + fn table_row(check: &Check) -> [String; 8] { let name = format!("`{}`", check.name); let binary = if check.uses_binary() { format!("`{}`", check.bin_name) @@ -653,7 +703,8 @@ mod tests { _ => "—".to_string(), }, }; - [name, binary, patterns, fix, slow, scope, config_file] + let notes = check.note.unwrap_or("—").to_string(); + [name, binary, patterns, fix, slow, scope, config_file, notes] } /// Smoke test: every check whose tool key resolves in this repo's expanded diff --git a/src/runner.rs b/src/runner.rs index a7c72ee..27115fe 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -201,6 +201,8 @@ fn build_invocations( let CheckKind::Template { check_cmd, fix_cmd, + full_cmd, + full_fix_cmd, scope, } = &check.kind else { @@ -257,17 +259,66 @@ fn build_invocations( if matched.is_empty() { return vec![]; } + // When all project files are in scope and a full_cmd is set, use it as a + // project-wide command instead of passing a (potentially huge) file list. + if file_list.full { + let effective = if fix && !full_fix_cmd.is_empty() { + Some(*full_fix_cmd) + } else if !fix && !full_cmd.is_empty() { + Some(*full_cmd) + } else { + None + }; + if let Some(cmd) = effective { + let cmd = cmd.replace("{ROOT}", "e_path(project_root)); + return vec![inject_config(shell_words(cmd), &config_args)]; + } + } + let edition_flag = resolve_cargo_edition_flag(project_root); let files_arg: String = matched .iter() .map(|f| quote_path(f)) .collect::>() .join(" "); - let cmd = cmd_template.replace("{FILES}", &files_arg); + let rel_files_arg: String = matched + .iter() + .map(|f| quote_path(f.strip_prefix(project_root).unwrap_or(f))) + .collect::>() + .join(" "); + let cmd = cmd_template + .replace("{CARGO_EDITION_FLAG}", &edition_flag) + .replace("{FILES}", &files_arg) + .replace("{RELFILES}", &rel_files_arg); vec![inject_config(shell_words(cmd), &config_args)] } } } +/// Returns `--edition ` if a Rust edition is declared in the project's +/// `Cargo.toml`, or an empty string if not found. Used to substitute +/// `{CARGO_EDITION_FLAG}` in rustfmt command templates. +fn resolve_cargo_edition_flag(project_root: &Path) -> String { + let Ok(content) = std::fs::read_to_string(project_root.join("Cargo.toml")) else { + return String::new(); + }; + let Ok(doc) = content.parse::() else { + return String::new(); + }; + let edition = doc + .get("package") + .and_then(|p| p.get("edition")) + .and_then(|e| e.as_str()) + .or_else(|| { + doc.get("workspace") + .and_then(|w| w.get("package")) + .and_then(|p| p.get("edition")) + .and_then(|e| e.as_str()) + }); + edition + .map(|e| format!("--edition {e}")) + .unwrap_or_default() +} + /// Returns `[flag, abs-path]` if `check.linter_config` is set and the file exists /// in `config_dir`, otherwise an empty slice. fn resolve_linter_config(check: &Check, config_dir: &Path) -> Vec { @@ -534,8 +585,11 @@ mod tests { kind: CheckKind::Template { check_cmd: "run-it", fix_cmd: "", + full_cmd: "", + full_fix_cmd: "", scope: Scope::Project, }, + note: None, } } @@ -546,6 +600,7 @@ mod tests { .map(|s| PathBuf::from(format!("/repo/{s}"))) .collect(), merge_base: Some("abc123".to_string()), + full: false, } } diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index 8fbef06..34348af 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -18,7 +18,7 @@ ruff-format ruff active fast *.py biome biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx biome-format biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx cargo-clippy cargo-clippy active fast *.rs -cargo-fmt cargo-fmt active fast *.rs +cargo-fmt rustfmt active fast *.rs gofmt gofmt missing fast *.go google-java-format google-java-format missing fast *.java ktlint ktlint missing fast *.kt *.kts @@ -37,7 +37,7 @@ biome = ''' cargo-clippy = ''' #!/bin/sh ''' -cargo-fmt = ''' +rustfmt = ''' #!/bin/sh ''' codespell = ''' From 2ababf44f431eec342bdbf5e6169599e3ba6ebf9 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 09:26:54 +0000 Subject: [PATCH 068/141] feat: add release workflow, bump version to 0.20.0-alpha.1, v2 migration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Release workflow builds Linux/macOS binaries on tag push - Cargo.toml version: 0.1.0 → 0.20.0-alpha.1 - MIGRATION.md: new v2 migration section (v1 bash tasks → flint binary) - Drop redundant base_branch and check_all_local from flint.toml --- .github/config/flint.toml | 4 -- .github/workflows/release.yml | 90 +++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- MIGRATION.md | 80 +++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/config/flint.toml b/.github/config/flint.toml index cbc67a2..401a260 100644 --- a/.github/config/flint.toml +++ b/.github/config/flint.toml @@ -1,10 +1,6 @@ [settings] -base_branch = "main" exclude = "CHANGELOG\\.md" exclude_paths = ["tests/cases/"] -[checks.lychee] -check_all_local = true - [checks.renovate-deps] exclude_managers = ["github-actions", "github-runners", "cargo"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8d1358f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +--- +name: Release + +on: + push: + tags: + - "v*" + +permissions: {} + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04 + use_cross: true + - target: x86_64-apple-darwin + runner: macos-15-intel + - target: aarch64-apple-darwin + runner: macos-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: ${{ matrix.target }} + + - name: Install cross + if: matrix.use_cross + run: cargo install cross --locked + + - name: Add target + if: "!matrix.use_cross" + run: rustup target add ${{ matrix.target }} + + - name: Build + run: | + if [ "${{ matrix.use_cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} + else + cargo build --release --target ${{ matrix.target }} + fi + + - name: Package + run: | + cd target/${{ matrix.target }}/release + tar czf flint-${{ matrix.target }}.tar.gz flint + mv flint-${{ matrix.target }}.tar.gz "$GITHUB_WORKSPACE/" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: flint-${{ matrix.target }} + path: flint-${{ matrix.target }}.tar.gz + + release: + name: Publish release + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + merge-multiple: true + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + extra_flags=() + if echo "$TAG" | grep -qE '\-(alpha|beta|rc)'; then + extra_flags+=("--prerelease") + fi + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --generate-notes \ + "${extra_flags[@]}" \ + flint-*.tar.gz diff --git a/Cargo.lock b/Cargo.lock index 8d984a3..899d5c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,7 +184,7 @@ dependencies = [ [[package]] name = "flint" -version = "0.1.0" +version = "0.20.0-alpha.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index c4009d6..db4f49e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "flint" -version = "0.1.0" +version = "0.20.0-alpha.1" edition = "2024" description = "mise-native lint orchestrator" license = "Apache-2.0" diff --git a/MIGRATION.md b/MIGRATION.md index 5bd5d3b..d692ef1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,85 @@ # Migration Guide +## Migrating from flint v1 (bash tasks) to flint v2 (binary) + +flint v2 replaces the HTTP remote tasks with a single `flint` binary that +discovers linters from your `mise.toml` and runs them against changed files. + +### 1. Remove the v1 task entries from `mise.toml` + +Remove all task entries that reference remote flint task scripts: + +```toml +# Remove these: +[tasks."lint:links"] +file = "https://raw.githubusercontent.com/grafana/flint/..." +[tasks."lint:renovate-deps"] +file = "https://raw.githubusercontent.com/grafana/flint/..." +``` + +Also remove any hand-rolled style lint scripts that delegate to individual +linters (shfmt, prettier, markdownlint, actionlint, codespell, +editorconfig-checker) — flint v2 handles all of these automatically based on +what is declared in `[tools]`. + +### 2. Add `flint` as a tool + +```toml +[tools] +"ubi:grafana/flint" = "0.20.0-alpha.1" +``` + +### 3. Replace linting tasks with `flint run` + +```toml +[tasks.lint] +run = "flint run" + +[tasks."lint:fix"] +run = "flint run --fix" +``` + +For CI, pass `--short` for compact output suited to AI-assisted review: + +```toml +[tasks.ci] +run = "flint run --short" +``` + +### 4. Add a pre-commit task + +flint v2 provides a fast auto-fix pass intended for git hooks: + +```toml +[tasks."lint:pre-commit"] +description = "Fast auto-fix lint (skips slow checks) — for pre-commit/pre-push hooks" +run = "flint run --fix --fast-only" + +[tasks."setup:pre-commit-hook"] +description = "Install git pre-commit hook" +run = "mise generate git-pre-commit --write --task=lint:pre-commit" +``` + +Then run `mise run setup:pre-commit-hook` once to install the hook. + +### 5. Switch `markdownlint-cli` to `markdownlint-cli2` + +flint v2 only supports `markdownlint-cli2`. See the +[section below](#replacing-markdownlint-cli-with-markdownlint-cli2) for +details — config files are compatible, no changes required there. + +```toml +# Before: +"npm:markdownlint-cli" = "0.48.0" +# After: +"npm:markdownlint-cli2" = "0.17.2" +``` + +### 6. Verify active linters + +Run `flint linters` to confirm flint detects all the tools declared in your +`mise.toml`. Any tool listed as `missing` is not declared and will be skipped. + ## Replacing `markdownlint-cli` with `markdownlint-cli2` `markdownlint-cli2` is the actively maintained successor to `markdownlint-cli`. From 0ecf7d4915207e39e5790660e11f38d72502c6fa Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 09:44:54 +0000 Subject: [PATCH 069/141] docs: add renovate-deps env var migration step --- MIGRATION.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index d692ef1..9a59259 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -75,7 +75,19 @@ details — config files are compatible, no changes required there. "npm:markdownlint-cli2" = "0.17.2" ``` -### 6. Verify active linters +### 6. Move renovate-deps config to `flint.toml` + +If you previously used the `RENOVATE_TRACKED_DEPS_EXCLUDE` env var to exclude +managers, move that to a `flint.toml` at your project root instead: + +```toml +[checks.renovate-deps] +exclude_managers = ["github-actions", "github-runners", "cargo"] +``` + +Remove `RENOVATE_TRACKED_DEPS_EXCLUDE` from `[env]` in `mise.toml`. + +### 7. Verify active linters Run `flint linters` to confirm flint detects all the tools declared in your `mise.toml`. Any tool listed as `missing` is not declared and will be skipped. From a63758dedd7dd26a0b525703096ce18db17931e7 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 12:42:43 +0000 Subject: [PATCH 070/141] feat: add flint init command for mise.toml setup Adds `flint init` which detects file types via `git ls-files`, prompts for a profile (lang/default/comprehensive), and syncs the [tools] section of mise.toml: adding missing linters, removing known-but-unneeded ones, and upgrading entries with missing or incorrect components (e.g. adds `clippy,rustfmt` components to an existing bare `rust = "1.x"` entry). Supports `--profile` and `--yes`/`-y` for non-interactive use. Introduces a `Category` enum (Lang/Default/Slow) replacing the separate `slow: bool` field, and adds `mise_install_key` / `mise_install_components` fields to `Check` for init-specific tool metadata. --- Cargo.lock | 1 + Cargo.toml | 1 + src/init.rs | 465 +++++++++++++++++++++++++++++ src/main.rs | 27 +- src/registry.rs | 123 ++++++-- src/runner.rs | 6 +- tests/cases/general/list/test.toml | 6 +- 7 files changed, 598 insertions(+), 31 deletions(-) create mode 100644 src/init.rs diff --git a/Cargo.lock b/Cargo.lock index 899d5c9..ba509b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "toml_edit", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index db4f49e..2eaa6ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ semver = "1" regex = "1" serde_json = "1" similar = "2" +toml_edit = "0.22" [dev-dependencies] tempfile = "3" diff --git a/src/init.rs b/src/init.rs new file mode 100644 index 0000000..90d08bb --- /dev/null +++ b/src/init.rs @@ -0,0 +1,465 @@ +use anyhow::{Context, Result}; +use std::collections::{HashMap, HashSet}; +use std::io::{self, BufRead, Write}; +use std::path::Path; +use std::process::Command; + +use crate::registry::{Category, Check, builtin}; + +/// Linter profile — controls which linters are included. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Profile { + /// Language-specific linters only (shellcheck, ruff, cargo-clippy, …). + Lang, + /// Lang + fast general linters (+ prettier, codespell, editorconfig-checker, …). + Default, + /// Default + slow linters (+ lychee, renovate-deps). + Comprehensive, +} + +/// Desired tools for a profile: maps each mise tool key to its optional components string. +type DesiredTools = HashMap>; + +pub fn run(project_root: &Path, profile_arg: Option, yes: bool) -> Result<()> { + println!( + "Tip: flint init detects languages from tracked files (`git ls-files`). \ +Add and stage your source files before running init so the detection is accurate." + ); + println!(); + + let registry = builtin(); + + // Detect which file patterns have matches in the repo. + let present_patterns = detect_present_patterns(project_root, ®istry)?; + + // Choose profile interactively if not supplied via flag. + let profile = match profile_arg { + Some(p) => p, + None => prompt_profile()?, + }; + + // Compute the map of mise tool keys → optional components this profile requires. + let desired = compute_desired_tools(®istry, &present_patterns, profile); + + // Read existing mise.toml (may not exist yet). + let mise_path = project_root.join("mise.toml"); + let current_content = std::fs::read_to_string(&mise_path).unwrap_or_default(); + let current_tool_keys = parse_tool_keys(¤t_content); + + // All flint-known tool keys — used to constrain what we remove. + let known_keys: HashSet<&str> = registry.iter().filter_map(install_key).collect(); + + let mut to_add: Vec<(String, Option<&'static str>)> = desired + .iter() + .filter(|(k, _)| !current_tool_keys.contains(k.as_str())) + .map(|(k, c)| (k.clone(), *c)) + .collect(); + to_add.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut to_remove: Vec = current_tool_keys + .iter() + .filter(|k| known_keys.contains(k.as_str()) && !desired.contains_key(k.as_str())) + .cloned() + .collect(); + to_remove.sort(); + + // Tools already present that need components added (e.g. `rust = "1.x"` → inline table). + let mut to_upgrade: Vec<(String, &'static str)> = desired + .iter() + .filter_map(|(k, components)| components.map(|c| (k.clone(), c))) + .filter(|(k, _)| current_tool_keys.contains(k.as_str())) + .filter(|(k, c)| entry_components_differ(¤t_content, k, c)) + .collect(); + to_upgrade.sort_by(|a, b| a.0.cmp(&b.0)); + + if to_add.is_empty() && to_remove.is_empty() && to_upgrade.is_empty() { + println!("mise.toml [tools] is already up to date for the selected profile."); + return Ok(()); + } + + // Show planned changes. + println!(); + if !to_add.is_empty() { + println!("Adding to [tools]:"); + for (key, components) in &to_add { + println!(" + {} = {}", key, format_toml_value(components)); + } + } + if !to_upgrade.is_empty() { + println!("Updating [tools] (adding components):"); + for (key, components) in &to_upgrade { + println!(" ~ {key}: add components = \"{components}\""); + } + } + if !to_remove.is_empty() { + println!("Removing from [tools]:"); + for key in &to_remove { + println!(" - {}", key); + } + } + println!(); + + if !yes && !confirm("Apply changes to mise.toml?")? { + println!("Aborted."); + return Ok(()); + } + + apply_changes( + &mise_path, + ¤t_content, + &to_add, + &to_remove, + &to_upgrade, + )?; + println!("Done. Run `mise install` to install the new tools."); + Ok(()) +} + +/// Returns the canonical mise.toml tool key to write when installing this check +/// via `flint init`, or `None` if no mise entry is needed (built-in or +/// unconditionally active checks). +/// +/// Preference order: `mise_install_key` → `mise_tool_name` → `bin_name`. +pub fn install_key(check: &Check) -> Option<&'static str> { + if !check.uses_binary() || check.activate_unconditionally { + return None; + } + Some( + check + .mise_install_key + .or(check.mise_tool_name) + .unwrap_or(check.bin_name), + ) +} + +/// Compute the map of `tool_key → optional_components` needed for `profile` +/// given the detected file patterns present in the repo. +fn compute_desired_tools( + registry: &[Check], + present_patterns: &HashSet, + profile: Profile, +) -> DesiredTools { + let mut desired = DesiredTools::new(); + for check in registry { + let key = match install_key(check) { + Some(k) => k, + None => continue, + }; + if !files_present(check, present_patterns) { + continue; + } + let included = match profile { + Profile::Lang => check.category == Category::Lang, + Profile::Default => check.category != Category::Slow, + Profile::Comprehensive => true, + }; + if included { + desired.insert(key.to_string(), check.mise_install_components); + } + } + desired +} + +/// Returns `true` if the repo contains at least one file matching any of the +/// check's patterns. Checks with no patterns (project-scope specials like +/// lychee) are always considered present. +fn files_present(check: &Check, present_patterns: &HashSet) -> bool { + check.patterns.is_empty() + || check + .patterns + .iter() + .any(|p| *p == "*" || present_patterns.contains(*p)) +} + +/// Runs `git ls-files -- ` for every unique pattern in the registry +/// and returns the set of patterns that produced at least one result. +fn detect_present_patterns(project_root: &Path, registry: &[Check]) -> Result> { + let all_patterns: HashSet<&str> = registry + .iter() + .flat_map(|c| c.patterns.iter().copied()) + .filter(|p| *p != "*") + .collect(); + + let mut present = HashSet::new(); + for pattern in all_patterns { + let out = Command::new("git") + .args(["ls-files", "--", pattern]) + .current_dir(project_root) + .output() + .context("git ls-files")?; + if !out.stdout.is_empty() { + present.insert(pattern.to_string()); + } + } + Ok(present) +} + +/// Returns the set of keys currently declared in `[tools]`. +fn parse_tool_keys(content: &str) -> HashSet { + let value: toml::Value = match toml::from_str(content) { + Ok(v) => v, + Err(_) => return HashSet::new(), + }; + value + .get("tools") + .and_then(|v| v.as_table()) + .map(|t| t.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Returns `true` if the `[tools]` entry for `key` exists and its `components` +/// field is absent or differs from `required`. Used to detect entries that need +/// upgrading (missing components) or correcting (wrong components). +fn entry_components_differ(content: &str, key: &str, required: &str) -> bool { + let doc: toml_edit::DocumentMut = match content.parse() { + Ok(d) => d, + Err(_) => return false, + }; + let tools = match doc.get("tools").and_then(|t| t.as_table()) { + Some(t) => t, + None => return false, + }; + match tools.get(key) { + Some(item) => match item.as_value() { + Some(toml_edit::Value::InlineTable(tbl)) => { + tbl.get("components").and_then(|v| v.as_str()) != Some(required) + } + Some(toml_edit::Value::String(_)) => true, + _ => false, + }, + None => false, + } +} + +/// Format the display string for a tool entry (used in the planned-changes output). +fn format_toml_value(components: &Option<&'static str>) -> String { + match components { + Some(c) => format!(r#"{{ version = "latest", components = "{c}" }}"#), + None => r#""latest""#.to_string(), + } +} + +fn prompt_profile() -> Result { + println!("Select a profile:"); + println!(" 1) lang — language linters only (shellcheck, ruff, cargo-clippy, …)"); + println!( + " 2) default — lang + fast general linters (+ prettier, codespell, ec, lychee, …)" + ); + println!(" 3) comprehensive — default + slow linters (+ renovate-deps)"); + println!(); + print!("Profile [1-3, default: 2]: "); + io::stdout().flush()?; + + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + Ok(match line.trim() { + "1" => Profile::Lang, + "3" => Profile::Comprehensive, + _ => Profile::Default, + }) +} + +fn confirm(prompt: &str) -> Result { + print!("{prompt} [y/N]: "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let answer = line.trim().to_ascii_lowercase(); + Ok(answer == "y" || answer == "yes") +} + +fn apply_changes( + path: &Path, + current_content: &str, + to_add: &[(String, Option<&'static str>)], + to_remove: &[String], + to_upgrade: &[(String, &'static str)], +) -> Result<()> { + let mut doc: toml_edit::DocumentMut = current_content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + + // Ensure [tools] table exists. + if !doc.contains_key("tools") { + doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new())); + } + let tools = doc["tools"] + .as_table_mut() + .context("[tools] is not a table")?; + + for key in to_remove { + tools.remove(key.as_str()); + } + + for (key, components) in to_add { + match components { + Some(comps) => { + let mut tbl = toml_edit::InlineTable::new(); + tbl.insert("version", toml_edit::Value::from("latest")); + tbl.insert("components", toml_edit::Value::from(*comps)); + tools.insert( + key.as_str(), + toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), + ); + } + None => { + tools.insert(key.as_str(), toml_edit::value("latest")); + } + } + } + + // Upgrade existing entries: preserve the current version, add components. + for (key, components) in to_upgrade { + let existing_version = tools + .get(key.as_str()) + .and_then(|item| item.as_value()) + .and_then(|v| match v { + toml_edit::Value::String(s) => Some(s.value().to_string()), + toml_edit::Value::InlineTable(tbl) => tbl + .get("version") + .and_then(|v| v.as_str()) + .map(str::to_string), + _ => None, + }) + .unwrap_or_else(|| "latest".to_string()); + + let mut tbl = toml_edit::InlineTable::new(); + tbl.insert("version", toml_edit::Value::from(existing_version.as_str())); + tbl.insert("components", toml_edit::Value::from(*components)); + tools.insert( + key.as_str(), + toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), + ); + } + + std::fs::write(path, doc.to_string())?; + Ok(()) +} + +// --- Tests --- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_registry_checks_have_install_key_or_none() { + // Every check that uses a binary and isn't unconditional must have a resolvable key. + for check in builtin() { + if check.uses_binary() && !check.activate_unconditionally { + let key = install_key(&check); + assert!( + key.is_some(), + "check '{}' is missing an install key", + check.name + ); + } + } + } + + #[test] + fn entry_components_differ_string_value() { + let content = "[tools]\nrust = \"1.80.0\"\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_without_components() { + let content = "[tools]\nrust = { version = \"1.80.0\" }\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_wrong_components() { + let content = "[tools]\nrust = { version = \"1.80.0\", components = \"clippy\" }\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_correct_components() { + let content = "[tools]\nrust = { version = \"1.80.0\", components = \"clippy,rustfmt\" }\n"; + assert!(!entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn apply_changes_upgrade_preserves_version() { + let content = "[tools]\nrust = \"1.80.0\"\n"; + let tmp = tempfile::NamedTempFile::new().unwrap(); + apply_changes( + tmp.path(), + content, + &[], + &[], + &[("rust".to_string(), "clippy,rustfmt")], + ) + .unwrap(); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(result.contains("version = \"1.80.0\""), "version preserved"); + assert!( + result.contains("components = \"clippy,rustfmt\""), + "components added" + ); + } + + #[test] + fn parse_tool_keys_reads_simple_toml() { + let content = r#" +[tools] +shellcheck = "v0.11.0" +"npm:prettier" = "3.8.1" +rust = { version = "1.0", components = "clippy" } +"#; + let keys = parse_tool_keys(content); + assert!(keys.contains("shellcheck")); + assert!(keys.contains("npm:prettier")); + assert!(keys.contains("rust")); + assert!(!keys.contains("nonexistent")); + } + + #[test] + fn compute_desired_tools_lang_profile() { + let registry = builtin(); + let mut present = HashSet::new(); + present.insert("*.sh".to_string()); + present.insert("*.bash".to_string()); + let tools = compute_desired_tools(®istry, &present, Profile::Lang); + assert!(tools.contains_key("shellcheck")); + assert!(tools.contains_key("shfmt")); + // codespell is not lang-only + assert!(!tools.contains_key("pipx:codespell")); + } + + #[test] + fn rust_install_entry_has_components() { + let registry = builtin(); + let mut present = HashSet::new(); + present.insert("*.rs".to_string()); + let tools = compute_desired_tools(®istry, &present, Profile::Lang); + // Both cargo-clippy and cargo-fmt share the "rust" key with components set. + assert_eq!( + tools.get("rust"), + Some(&Some("clippy,rustfmt")), + "rust tool entry should carry components" + ); + } + + #[test] + fn compute_desired_tools_default_excludes_slow() { + let registry = builtin(); + let present: HashSet = HashSet::new(); + let tools = compute_desired_tools(®istry, &present, Profile::Default); + // renovate-deps is slow — should be absent + assert!(!tools.contains_key("npm:renovate")); + // lychee is fast — should be present (empty patterns → always present) + assert!(tools.contains_key("lychee")); + } + + #[test] + fn compute_desired_tools_comprehensive_includes_slow() { + let registry = builtin(); + let present: HashSet = HashSet::new(); + let tools = compute_desired_tools(®istry, &present, Profile::Comprehensive); + assert!(tools.contains_key("lychee")); + assert!(tools.contains_key("npm:renovate")); + } +} diff --git a/src/main.rs b/src/main.rs index de3ec7a..2eb91ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod files; +mod init; mod linters; mod registry; mod runner; @@ -24,6 +25,8 @@ enum SubCommand { Run(RunArgs), /// List available linters and their status. Linters(LintersArgs), + /// Set up linters in mise.toml for this project. + Init(InitArgs), /// Display the flint version. Version, } @@ -35,6 +38,17 @@ struct LintersArgs { json: bool, } +#[derive(Args, Debug)] +struct InitArgs { + /// Profile to configure: lang, default, or comprehensive. + #[arg(long, value_enum)] + profile: Option, + + /// Apply changes without prompting for confirmation. + #[arg(long, short = 'y')] + yes: bool, +} + #[derive(Args, Debug)] struct RunArgs { /// Fix what's fixable, report what still needs review. @@ -99,6 +113,9 @@ async fn main() -> Result<()> { print_linters(®istry, &mise_tools); } } + SubCommand::Init(args) => { + init::run(&project_root, args.profile, args.yes)?; + } SubCommand::Run(args) => { run(args, &project_root, &config_dir, ®istry).await?; } @@ -141,7 +158,7 @@ async fn run( let active: Vec<®istry::Check> = checks .into_iter() .filter(|c| registry::check_active(c, &mise_tools)) - .filter(|c| explicit || !args.fast_only || !c.slow) + .filter(|c| explicit || !args.fast_only || c.category != registry::Category::Slow) .collect(); if args.verbose { @@ -326,7 +343,7 @@ pub fn linter_json(check: ®istry::Check) -> serde_json::Value { "binary": if check.uses_binary() { check.bin_name } else { "(built-in)" }, "patterns": patterns, "fix": check.has_fix(), - "slow": check.slow, + "slow": check.category == registry::Category::Slow, "scope": scope, "config_file": config_file, }) @@ -374,7 +391,11 @@ fn print_linters(registry: &[registry::Check], mise_tools: &HashMap` into the command right after the binary name. pub linter_config: Option<(&'static str, &'static str)>, @@ -65,6 +77,15 @@ pub struct Check { pub defers_to_formatters: bool, /// Always considered active regardless of mise.toml (used for config-activated checks). pub activate_unconditionally: bool, + /// Canonical mise tool key to write when setting up a new project (e.g. `npm:prettier`). + /// When `None`, falls back to `mise_tool_name` then `bin_name`. + /// Distinct from `mise_tool_name` (lookup key) because existing repos may declare the + /// same tool under a bare name (e.g. `ruff = "latest"`) which must still be detected. + pub mise_install_key: Option<&'static str>, + /// Optional mise toolchain components to request when installing via `flint init` + /// (e.g. `"clippy,rustfmt"` for the `rust` toolchain). Produces an inline-table + /// entry: `rust = { version = "latest", components = "clippy,rustfmt" }`. + pub mise_install_components: Option<&'static str>, pub kind: CheckKind, /// Optional note shown in the README linter table. pub note: Option<&'static str>, @@ -127,11 +148,13 @@ impl Check { version_range: None, patterns, excludes_if_active: &[], - slow: false, linter_config: None, is_formatter: false, defers_to_formatters: false, activate_unconditionally: false, + category: Category::Default, + mise_install_key: None, + mise_install_components: None, kind: CheckKind::Template { check_cmd, fix_cmd: "", @@ -152,11 +175,13 @@ impl Check { version_range: None, patterns: &[], excludes_if_active: &[], - slow: false, linter_config: None, is_formatter: false, defers_to_formatters: false, activate_unconditionally: false, + category: Category::Default, + mise_install_key: None, + mise_install_components: None, kind: CheckKind::Special(kind), note: None, } @@ -213,9 +238,9 @@ impl Check { self } - /// Mark as slow — skipped when `--fast-only` is passed. + /// Mark as slow — skipped when `--fast-only` is passed; `comprehensive` init profile only. pub fn slow(mut self) -> Self { - self.slow = true; + self.category = Category::Slow; self } @@ -243,6 +268,29 @@ impl Check { self } + /// Mark as a language-specific linter — included in all init profiles. + pub fn lang(mut self) -> Self { + self.category = Category::Lang; + self + } + + /// Set the canonical mise tool key used when installing this linter via `flint init` + /// (e.g. `npm:prettier`, `pipx:ruff`). Distinct from `mise_tool_name`, which is the + /// lookup key used to detect the tool in an existing mise.toml — existing repos may + /// declare the tool under a bare name (`ruff = "latest"`) which is still detectable + /// via the alias map but must not be overwritten with the prefixed key. + pub fn install_key(mut self, key: &'static str) -> Self { + self.mise_install_key = Some(key); + self + } + + /// Set toolchain components required when installing via `flint init` + /// (e.g. `"clippy,rustfmt"` for the `rust` toolchain). + pub fn install_components(mut self, components: &'static str) -> Self { + self.mise_install_components = Some(components); + self + } + /// Inject a config file from config_dir into the linter command. /// If `config_dir/file` exists at runtime, `flag ` is inserted /// right after the binary name. Has no effect when the file is absent. @@ -271,13 +319,16 @@ pub fn builtin() -> Vec { "shellcheck {FILE}", &["*.sh", "*.bash", "*.bats"], ) - .linter_config(".shellcheckrc", "--rcfile"), + .linter_config(".shellcheckrc", "--rcfile") + .lang(), Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) .fix("shfmt -w {FILE}") - .formatter(), + .formatter() + .lang(), Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) .fix("markdownlint-cli2 --fix {FILE}") - .linter_config(".markdownlint.json", "--config"), + .linter_config(".markdownlint.json", "--config") + .install_key("npm:markdownlint-cli2"), Check::files( "prettier", "prettier --check {FILES}", @@ -286,22 +337,26 @@ pub fn builtin() -> Vec { .fix("prettier --write {FILES}") .full_cmd("prettier --check {ROOT}", "prettier --write {ROOT}") .linter_config(".prettierrc", "--config") - .formatter(), + .formatter() + .install_key("npm:prettier"), Check::file( "actionlint", "actionlint {FILE}", &[".github/workflows/*.yml", ".github/workflows/*.yaml"], ) - .linter_config("actionlint.yml", "-config-file"), + .linter_config("actionlint.yml", "-config-file") + .lang(), Check::file( "hadolint", "hadolint {FILE}", &["Dockerfile", "Dockerfile.*", "*.dockerfile"], ) - .linter_config(".hadolint.yaml", "--config"), + .linter_config(".hadolint.yaml", "--config") + .lang(), Check::files("codespell", "codespell {FILES}", &["*"]) .fix("codespell --write-changes {FILES}") - .linter_config(".codespellrc", "--config"), + .linter_config(".codespellrc", "--config") + .install_key("pipx:codespell"), // Defer to formatters that enforce line length — those are the ones // that conflict with ec's max_line_length editorconfig check. // Note: ec's -config flag controls ec's own JSON config, not .editorconfig itself. @@ -316,21 +371,28 @@ pub fn builtin() -> Vec { &["*.go"], ) .linter_config(".golangci.yml", "--config") + .lang() .note("uses --new-from-rev to lint only changed code"), Check::file("ruff", "ruff check {FILE}", &["*.py"]) .fix("ruff check --fix {FILE}") - .linter_config("ruff.toml", "--config"), + .linter_config("ruff.toml", "--config") + .install_key("pipx:ruff") + .lang(), Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]) .bin("ruff") .fix("ruff format {FILE}") .linter_config("ruff.toml", "--config") - .formatter(), + .formatter() + .install_key("pipx:ruff") + .lang(), Check::file( "biome", "biome check {FILE}", &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], ) - .fix("biome check --fix {FILE}"), + .fix("biome check --fix {FILE}") + .install_key("npm:@biomejs/biome") + .lang(), Check::file( "biome-format", "biome format {FILE}", @@ -338,10 +400,14 @@ pub fn builtin() -> Vec { ) .bin("biome") .fix("biome format --write {FILE}") - .formatter(), + .formatter() + .install_key("npm:@biomejs/biome") + .lang(), Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") .mise_tool("rust") + .install_components("clippy,rustfmt") + .lang() .note("lints all .rs files, not just changed"), Check::files( "cargo-fmt", @@ -352,11 +418,14 @@ pub fn builtin() -> Vec { .full_cmd("cargo fmt -- --check", "cargo fmt") .bin("rustfmt") .mise_tool("rust") - .formatter(), + .install_components("clippy,rustfmt") + .formatter() + .lang(), Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) .fix("gofmt -w {FILE}") .mise_tool("go") - .formatter(), + .formatter() + .lang(), Check::files( "google-java-format", "google-java-format --dry-run --set-exit-if-changed {FILES}", @@ -364,7 +433,8 @@ pub fn builtin() -> Vec { ) .fix("google-java-format -i {FILES}") .mise_tool("github:google/google-java-format") - .formatter(), + .formatter() + .lang(), Check::files( "ktlint", "ktlint --log-level=error {FILES}", @@ -381,7 +451,8 @@ pub fn builtin() -> Vec { } else { "ktlint" }) - .formatter(), + .formatter() + .lang(), Check::files( "dotnet-format", "dotnet format --verify-no-changes --include {RELFILES}", @@ -391,7 +462,8 @@ pub fn builtin() -> Vec { .full_cmd("dotnet format --verify-no-changes", "dotnet format") .bin("dotnet") .mise_tool("dotnet") - .formatter(), + .formatter() + .lang(), Check::special("lychee", "lychee", SpecialKind::Links), Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) .mise_tool("npm:renovate") @@ -684,7 +756,12 @@ mod tests { format!("`{}`", check.patterns.join(" ")) }; let fix = if check.has_fix() { "yes" } else { "no" }.to_string(); - let slow = if check.slow { "yes" } else { "—" }.to_string(); + let slow = if check.category == Category::Slow { + "yes" + } else { + "—" + } + .to_string(); let scope = match &check.kind { CheckKind::Template { scope, .. } => match scope { Scope::File => "file", diff --git a/src/runner.rs b/src/runner.rs index 27115fe..57d00a9 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -513,7 +513,7 @@ fn shell_words(cmd: String) -> Vec { mod tests { use super::*; use crate::files::FileList; - use crate::registry::{Check, CheckKind, Scope}; + use crate::registry::{Category, Check, CheckKind, Scope}; use std::path::PathBuf; #[test] @@ -577,11 +577,13 @@ mod tests { version_range: None, patterns, excludes_if_active: &[], - slow: false, linter_config: None, is_formatter: false, defers_to_formatters: false, activate_unconditionally: false, + category: Category::Default, + mise_install_key: None, + mise_install_components: None, kind: CheckKind::Template { check_cmd: "run-it", fix_cmd: "", diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index 34348af..e7bacc6 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -37,9 +37,6 @@ biome = ''' cargo-clippy = ''' #!/bin/sh ''' -rustfmt = ''' -#!/bin/sh -''' codespell = ''' #!/bin/sh ''' @@ -61,6 +58,9 @@ renovate = ''' ruff = ''' #!/bin/sh ''' +rustfmt = ''' +#!/bin/sh +''' shellcheck = ''' #!/bin/sh ''' From 025398fbe2d43146a1e387b6077a215ec7b4a960 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 12:59:56 +0000 Subject: [PATCH 071/141] feat: interactive init selection, renovate detection by config file flint init now presents a numbered checklist of recommended changes (add/remove/upgrade) that the user can toggle before applying. --yes skips the interactive step as before. renovate-deps is no longer recommended unconditionally: it is gated on the presence of a renovate config file (renovate.json, renovate.json5, .github/renovate.json{5}, .renovaterc{,.json,.json5}) in the repo. A new .patterns() builder on Check enables init-detection patterns on Special checks without affecting runtime file matching. Also fixes trailing whitespace emitted by `flint linters` for checks with no patterns. --- README.md | 48 ++++---- src/init.rs | 180 +++++++++++++++++++++++------ src/main.rs | 33 ++++-- src/registry.rs | 18 ++- tests/cases/general/list/test.toml | 6 +- 5 files changed, 211 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 5fc5863..9f82d83 100644 --- a/README.md +++ b/README.md @@ -222,30 +222,30 @@ being linted and cannot be redirected via a flag. -| Name | Binary | Patterns | Fix | Slow | Scope | Config file | Notes | -| ---------------------- | -------------------- | -------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | --------------------------------------------- | -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | — | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | — | file | — | — | -| `markdownlint-cli2` | `markdownlint-cli2` | `*.md` | yes | — | file | `.markdownlint.json` | — | -| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | — | files | `.prettierrc` | — | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | — | file | `actionlint.yml` | — | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | — | file | `.hadolint.yaml` | — | -| `codespell` | `codespell` | `*` | yes | — | files | `.codespellrc` | — | -| `editorconfig-checker` | `ec` | `*` | no | — | files | `.editorconfig-checker.json` | — | -| `golangci-lint` | `golangci-lint` | `*.go` | no | — | project | `.golangci.yml` | uses --new-from-rev to lint only changed code | -| `ruff` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | — | -| `ruff-format` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | — | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | lints all .rs files, not just changed | -| `cargo-fmt` | `rustfmt` | `*.rs` | yes | — | files | — | — | -| `gofmt` | `gofmt` | `*.go` | yes | — | file | — | — | -| `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | — | -| `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | — | -| `dotnet-format` | `dotnet` | `*.cs` | yes | — | files | — | — | -| `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | — | -| `renovate-deps` | `renovate` | (all files) | yes | yes | special | — | — | -| `license-header` | (built-in) | (all files) | no | — | special | — | — | +| Name | Binary | Patterns | Fix | Slow | Scope | Config file | Notes | +| ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | --------------------------------------------- | +| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | — | +| `shfmt` | `shfmt` | `*.sh *.bash` | yes | — | file | — | — | +| `markdownlint-cli2` | `markdownlint-cli2` | `*.md` | yes | — | file | `.markdownlint.json` | — | +| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | — | files | `.prettierrc` | — | +| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | — | file | `actionlint.yml` | — | +| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | — | file | `.hadolint.yaml` | — | +| `codespell` | `codespell` | `*` | yes | — | files | `.codespellrc` | — | +| `editorconfig-checker` | `ec` | `*` | no | — | files | `.editorconfig-checker.json` | — | +| `golangci-lint` | `golangci-lint` | `*.go` | no | — | project | `.golangci.yml` | uses --new-from-rev to lint only changed code | +| `ruff` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | — | +| `ruff-format` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | — | +| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | +| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | +| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | lints all .rs files, not just changed | +| `cargo-fmt` | `rustfmt` | `*.rs` | yes | — | files | — | — | +| `gofmt` | `gofmt` | `*.go` | yes | — | file | — | — | +| `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | — | +| `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | — | +| `dotnet-format` | `dotnet` | `*.cs` | yes | — | files | — | — | +| `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | — | +| `renovate-deps` | `renovate` | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | yes | yes | special | — | — | +| `license-header` | (built-in) | (all files) | no | — | special | — | — | diff --git a/src/init.rs b/src/init.rs index 90d08bb..918c528 100644 --- a/src/init.rs +++ b/src/init.rs @@ -20,6 +20,39 @@ pub enum Profile { /// Desired tools for a profile: maps each mise tool key to its optional components string. type DesiredTools = HashMap>; +enum ChangeKind { + Add { + key: String, + components: Option<&'static str>, + }, + Remove { + key: String, + }, + Upgrade { + key: String, + components: &'static str, + }, +} + +struct ChangeItem { + selected: bool, + kind: ChangeKind, +} + +impl ChangeItem { + fn label(&self) -> String { + match &self.kind { + ChangeKind::Add { key, components } => { + format!("[+] {} = {}", key, format_toml_value(components)) + } + ChangeKind::Remove { key } => format!("[-] {}", key), + ChangeKind::Upgrade { key, components } => { + format!("[~] {} (add components: {})", key, components) + } + } + } +} + pub fn run(project_root: &Path, profile_arg: Option, yes: bool) -> Result<()> { println!( "Tip: flint init detects languages from tracked files (`git ls-files`). \ @@ -72,44 +105,80 @@ Add and stage your source files before running init so the detection is accurate .collect(); to_upgrade.sort_by(|a, b| a.0.cmp(&b.0)); - if to_add.is_empty() && to_remove.is_empty() && to_upgrade.is_empty() { - println!("mise.toml [tools] is already up to date for the selected profile."); - return Ok(()); + // Build unified change list. + let mut items: Vec = Vec::new(); + for (key, components) in &to_add { + items.push(ChangeItem { + selected: true, + kind: ChangeKind::Add { + key: key.clone(), + components: *components, + }, + }); } - - // Show planned changes. - println!(); - if !to_add.is_empty() { - println!("Adding to [tools]:"); - for (key, components) in &to_add { - println!(" + {} = {}", key, format_toml_value(components)); - } + for key in &to_remove { + items.push(ChangeItem { + selected: true, + kind: ChangeKind::Remove { key: key.clone() }, + }); } - if !to_upgrade.is_empty() { - println!("Updating [tools] (adding components):"); - for (key, components) in &to_upgrade { - println!(" ~ {key}: add components = \"{components}\""); - } + for (key, components) in &to_upgrade { + items.push(ChangeItem { + selected: true, + kind: ChangeKind::Upgrade { + key: key.clone(), + components, + }, + }); } - if !to_remove.is_empty() { - println!("Removing from [tools]:"); - for key in &to_remove { - println!(" - {}", key); - } + + if items.is_empty() { + println!("mise.toml [tools] is already up to date for the selected profile."); + return Ok(()); } - println!(); - if !yes && !confirm("Apply changes to mise.toml?")? { + // Interactive selection (skipped with --yes). + if !yes && !interactive_select(&mut items)? { println!("Aborted."); return Ok(()); } + let final_add: Vec<(String, Option<&'static str>)> = items + .iter() + .filter(|i| i.selected) + .filter_map(|i| match &i.kind { + ChangeKind::Add { key, components } => Some((key.clone(), *components)), + _ => None, + }) + .collect(); + let final_remove: Vec = items + .iter() + .filter(|i| i.selected) + .filter_map(|i| match &i.kind { + ChangeKind::Remove { key } => Some(key.clone()), + _ => None, + }) + .collect(); + let final_upgrade: Vec<(String, &'static str)> = items + .iter() + .filter(|i| i.selected) + .filter_map(|i| match &i.kind { + ChangeKind::Upgrade { key, components } => Some((key.clone(), *components)), + _ => None, + }) + .collect(); + + if final_add.is_empty() && final_remove.is_empty() && final_upgrade.is_empty() { + println!("No changes selected."); + return Ok(()); + } + apply_changes( &mise_path, ¤t_content, - &to_add, - &to_remove, - &to_upgrade, + &final_add, + &final_remove, + &final_upgrade, )?; println!("Done. Run `mise install` to install the new tools."); Ok(()) @@ -239,6 +308,43 @@ fn format_toml_value(components: &Option<&'static str>) -> String { } } +/// Present a numbered, toggleable list of planned changes. Returns `true` if +/// the user confirms (Enter), `false` if they abort (`q`). +fn interactive_select(items: &mut [ChangeItem]) -> Result { + loop { + print_items(items); + print!("\nToggle by number (space-separated), Enter to apply, q to abort: "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let trimmed = line.trim(); + + if trimmed.eq_ignore_ascii_case("q") { + return Ok(false); + } + if trimmed.is_empty() { + return Ok(true); + } + for token in trimmed.split_whitespace() { + if let Ok(n) = token.parse::() + && n >= 1 + && n <= items.len() + { + items[n - 1].selected = !items[n - 1].selected; + } + } + } +} + +fn print_items(items: &[ChangeItem]) { + println!("\nRecommended changes:"); + println!(); + for (i, item) in items.iter().enumerate() { + let check = if item.selected { "āœ“" } else { " " }; + println!(" {:>2}. {} {}", i + 1, check, item.label()); + } +} + fn prompt_profile() -> Result { println!("Select a profile:"); println!(" 1) lang — language linters only (shellcheck, ruff, cargo-clippy, …)"); @@ -259,15 +365,6 @@ fn prompt_profile() -> Result { }) } -fn confirm(prompt: &str) -> Result { - print!("{prompt} [y/N]: "); - io::stdout().flush()?; - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - let answer = line.trim().to_ascii_lowercase(); - Ok(answer == "y" || answer == "yes") -} - fn apply_changes( path: &Path, current_content: &str, @@ -457,9 +554,20 @@ rust = { version = "1.0", components = "clippy" } #[test] fn compute_desired_tools_comprehensive_includes_slow() { let registry = builtin(); - let present: HashSet = HashSet::new(); + // Must include renovate config pattern so renovate-deps is considered present. + let mut present: HashSet = HashSet::new(); + present.insert(".github/renovate.json5".to_string()); let tools = compute_desired_tools(®istry, &present, Profile::Comprehensive); assert!(tools.contains_key("lychee")); assert!(tools.contains_key("npm:renovate")); } + + #[test] + fn renovate_deps_absent_without_renovate_config() { + let registry = builtin(); + // No renovate config file in present patterns → renovate-deps should be excluded. + let present: HashSet = HashSet::new(); + let tools = compute_desired_tools(®istry, &present, Profile::Comprehensive); + assert!(!tools.contains_key("npm:renovate")); + } } diff --git a/src/main.rs b/src/main.rs index 2eb91ec..b80daf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -396,15 +396,28 @@ fn print_linters(registry: &[registry::Check], mise_tools: &HashMap Self { + self.patterns = patterns; + self + } + /// Mark as a language-specific linter — included in all init profiles. pub fn lang(mut self) -> Self { self.category = Category::Lang; @@ -467,7 +474,16 @@ pub fn builtin() -> Vec { Check::special("lychee", "lychee", SpecialKind::Links), Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) .mise_tool("npm:renovate") - .slow(), + .slow() + .patterns(&[ + "renovate.json", + "renovate.json5", + ".github/renovate.json", + ".github/renovate.json5", + ".renovaterc", + ".renovaterc.json", + ".renovaterc.json5", + ]), Check::special( "license-header", "license-header", diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index e7bacc6..9d62f93 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -23,9 +23,9 @@ gofmt gofmt missing fast *.go google-java-format google-java-format missing fast *.java ktlint ktlint missing fast *.kt *.kts dotnet-format dotnet missing fast *.cs -lychee lychee active fast -renovate-deps renovate active slow -license-header license-header active fast +lychee lychee active fast +renovate-deps renovate active slow renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5 +license-header license-header active fast ''' [fake_bins] actionlint = ''' From 624454b12854f6076a2e2149d4512a6c4c1f521a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 13:14:34 +0000 Subject: [PATCH 072/141] feat: split Lang into Lang/Style, interactive category selection in init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces Category::Style for supplementary language checks (shellcheck, shfmt, actionlint, hadolint) — checks for scripting/config files that users may want to omit when the primary language is e.g. Rust or Python. Category::Lang now covers only primary programming language linters and their formatters. flint init now shows a two-step interactive flow: 1. Category selection (lang āœ“, style āœ“, general āœ“, slow āœ— by default) 2. Per-tool change list (add/remove/upgrade, individually toggleable) --profile maps to a category set and skips step 1; --yes skips both. --- src/init.rs | 182 ++++++++++++++++++++++++++++++++++-------------- src/registry.rs | 27 ++++--- 2 files changed, 148 insertions(+), 61 deletions(-) diff --git a/src/init.rs b/src/init.rs index 918c528..756a9f7 100644 --- a/src/init.rs +++ b/src/init.rs @@ -6,20 +6,36 @@ use std::process::Command; use crate::registry::{Category, Check, builtin}; -/// Linter profile — controls which linters are included. +/// Linter profile — shorthand for `--profile` CLI flag; maps to a category set. #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] pub enum Profile { - /// Language-specific linters only (shellcheck, ruff, cargo-clippy, …). + /// Primary language linters only (ruff, cargo-clippy, golangci-lint, …). Lang, - /// Lang + fast general linters (+ prettier, codespell, editorconfig-checker, …). + /// Lang + supplementary checks + fast general tools (shellcheck, prettier, codespell, …). Default, - /// Default + slow linters (+ lychee, renovate-deps). + /// Default + slow linters (renovate-deps). Comprehensive, } +fn profile_to_categories(profile: Profile) -> HashSet { + match profile { + Profile::Lang => [Category::Lang].into(), + Profile::Default => [Category::Lang, Category::Style, Category::Default].into(), + Profile::Comprehensive => [ + Category::Lang, + Category::Style, + Category::Default, + Category::Slow, + ] + .into(), + } +} + /// Desired tools for a profile: maps each mise tool key to its optional components string. type DesiredTools = HashMap>; +// --- Change list (step 2) --- + enum ChangeKind { Add { key: String, @@ -53,6 +69,39 @@ impl ChangeItem { } } +// --- Category selection (step 1) --- + +struct CategoryItem { + selected: bool, + category: Category, + label: &'static str, +} + +fn default_category_items() -> Vec { + vec![ + CategoryItem { + selected: true, + category: Category::Lang, + label: "lang — primary language linters (ruff, cargo-clippy, golangci-lint, …)", + }, + CategoryItem { + selected: true, + category: Category::Style, + label: "style — supplementary checks (shellcheck, actionlint, hadolint, …)", + }, + CategoryItem { + selected: true, + category: Category::Default, + label: "general — general tools (codespell, ec, lychee, …)", + }, + CategoryItem { + selected: false, + category: Category::Slow, + label: "slow — slow linters (renovate-deps)", + }, + ] +} + pub fn run(project_root: &Path, profile_arg: Option, yes: bool) -> Result<()> { println!( "Tip: flint init detects languages from tracked files (`git ls-files`). \ @@ -65,14 +114,29 @@ Add and stage your source files before running init so the detection is accurate // Detect which file patterns have matches in the repo. let present_patterns = detect_present_patterns(project_root, ®istry)?; - // Choose profile interactively if not supplied via flag. - let profile = match profile_arg { - Some(p) => p, - None => prompt_profile()?, + // Determine the active category set. + // --profile maps directly; otherwise ask interactively (skipped with --yes, uses default). + let categories: HashSet = if let Some(profile) = profile_arg { + profile_to_categories(profile) + } else if yes { + // --yes without --profile: use the default category set (lang + style + general). + profile_to_categories(Profile::Default) + } else { + let mut cat_items = default_category_items(); + if !select_categories(&mut cat_items)? { + println!("Aborted."); + return Ok(()); + } + println!(); + cat_items + .iter() + .filter(|i| i.selected) + .map(|i| i.category) + .collect() }; - // Compute the map of mise tool keys → optional components this profile requires. - let desired = compute_desired_tools(®istry, &present_patterns, profile); + // Compute the map of mise tool keys → optional components for the selected categories. + let desired = compute_desired_tools(®istry, &present_patterns, &categories); // Read existing mise.toml (may not exist yet). let mise_path = project_root.join("mise.toml"); @@ -133,11 +197,11 @@ Add and stage your source files before running init so the detection is accurate } if items.is_empty() { - println!("mise.toml [tools] is already up to date for the selected profile."); + println!("mise.toml [tools] is already up to date for the selected categories."); return Ok(()); } - // Interactive selection (skipped with --yes). + // Interactive item selection (skipped with --yes). if !yes && !interactive_select(&mut items)? { println!("Aborted."); return Ok(()); @@ -201,12 +265,12 @@ pub fn install_key(check: &Check) -> Option<&'static str> { ) } -/// Compute the map of `tool_key → optional_components` needed for `profile` -/// given the detected file patterns present in the repo. +/// Compute the map of `tool_key → optional_components` for the given category set, +/// filtered to file patterns present in the repo. fn compute_desired_tools( registry: &[Check], present_patterns: &HashSet, - profile: Profile, + categories: &HashSet, ) -> DesiredTools { let mut desired = DesiredTools::new(); for check in registry { @@ -217,12 +281,7 @@ fn compute_desired_tools( if !files_present(check, present_patterns) { continue; } - let included = match profile { - Profile::Lang => check.category == Category::Lang, - Profile::Default => check.category != Category::Slow, - Profile::Comprehensive => true, - }; - if included { + if categories.contains(&check.category) { desired.insert(key.to_string(), check.mise_install_components); } } @@ -308,8 +367,40 @@ fn format_toml_value(components: &Option<&'static str>) -> String { } } -/// Present a numbered, toggleable list of planned changes. Returns `true` if -/// the user confirms (Enter), `false` if they abort (`q`). +/// Step 1: interactive category selection. Returns `true` to continue, `false` to abort. +fn select_categories(items: &mut [CategoryItem]) -> Result { + loop { + println!("Select categories:"); + println!(); + for (i, item) in items.iter().enumerate() { + let check = if item.selected { "āœ“" } else { " " }; + println!(" {:>2}. {} {}", i + 1, check, item.label); + } + print!("\nToggle by number (space-separated), Enter to continue, q to abort: "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let trimmed = line.trim(); + + if trimmed.eq_ignore_ascii_case("q") { + return Ok(false); + } + if trimmed.is_empty() { + return Ok(true); + } + for token in trimmed.split_whitespace() { + if let Ok(n) = token.parse::() + && n >= 1 + && n <= items.len() + { + items[n - 1].selected = !items[n - 1].selected; + } + } + println!(); + } +} + +/// Step 2: interactive change-list selection. Returns `true` to apply, `false` to abort. fn interactive_select(items: &mut [ChangeItem]) -> Result { loop { print_items(items); @@ -345,26 +436,6 @@ fn print_items(items: &[ChangeItem]) { } } -fn prompt_profile() -> Result { - println!("Select a profile:"); - println!(" 1) lang — language linters only (shellcheck, ruff, cargo-clippy, …)"); - println!( - " 2) default — lang + fast general linters (+ prettier, codespell, ec, lychee, …)" - ); - println!(" 3) comprehensive — default + slow linters (+ renovate-deps)"); - println!(); - print!("Profile [1-3, default: 2]: "); - io::stdout().flush()?; - - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - Ok(match line.trim() { - "1" => Profile::Lang, - "3" => Profile::Comprehensive, - _ => Profile::Default, - }) -} - fn apply_changes( path: &Path, current_content: &str, @@ -519,10 +590,15 @@ rust = { version = "1.0", components = "clippy" } let mut present = HashSet::new(); present.insert("*.sh".to_string()); present.insert("*.bash".to_string()); - let tools = compute_desired_tools(®istry, &present, Profile::Lang); - assert!(tools.contains_key("shellcheck")); - assert!(tools.contains_key("shfmt")); - // codespell is not lang-only + present.insert("*.rs".to_string()); + let categories = profile_to_categories(Profile::Lang); + let tools = compute_desired_tools(®istry, &present, &categories); + // Shell checks are supplementary (Style), not included in the lang profile. + assert!(!tools.contains_key("shellcheck")); + assert!(!tools.contains_key("shfmt")); + // Primary language linters are included. + assert!(tools.contains_key("rust")); + // General tools are not lang-only. assert!(!tools.contains_key("pipx:codespell")); } @@ -531,7 +607,8 @@ rust = { version = "1.0", components = "clippy" } let registry = builtin(); let mut present = HashSet::new(); present.insert("*.rs".to_string()); - let tools = compute_desired_tools(®istry, &present, Profile::Lang); + let categories = profile_to_categories(Profile::Lang); + let tools = compute_desired_tools(®istry, &present, &categories); // Both cargo-clippy and cargo-fmt share the "rust" key with components set. assert_eq!( tools.get("rust"), @@ -544,7 +621,8 @@ rust = { version = "1.0", components = "clippy" } fn compute_desired_tools_default_excludes_slow() { let registry = builtin(); let present: HashSet = HashSet::new(); - let tools = compute_desired_tools(®istry, &present, Profile::Default); + let categories = profile_to_categories(Profile::Default); + let tools = compute_desired_tools(®istry, &present, &categories); // renovate-deps is slow — should be absent assert!(!tools.contains_key("npm:renovate")); // lychee is fast — should be present (empty patterns → always present) @@ -557,7 +635,8 @@ rust = { version = "1.0", components = "clippy" } // Must include renovate config pattern so renovate-deps is considered present. let mut present: HashSet = HashSet::new(); present.insert(".github/renovate.json5".to_string()); - let tools = compute_desired_tools(®istry, &present, Profile::Comprehensive); + let categories = profile_to_categories(Profile::Comprehensive); + let tools = compute_desired_tools(®istry, &present, &categories); assert!(tools.contains_key("lychee")); assert!(tools.contains_key("npm:renovate")); } @@ -567,7 +646,8 @@ rust = { version = "1.0", components = "clippy" } let registry = builtin(); // No renovate config file in present patterns → renovate-deps should be excluded. let present: HashSet = HashSet::new(); - let tools = compute_desired_tools(®istry, &present, Profile::Comprehensive); + let categories = profile_to_categories(Profile::Comprehensive); + let tools = compute_desired_tools(®istry, &present, &categories); assert!(!tools.contains_key("npm:renovate")); } } diff --git a/src/registry.rs b/src/registry.rs index cfb2955..25e3141 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -13,15 +13,16 @@ pub enum Scope { } /// Which init profile (and `--fast-only` behaviour) a check belongs to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum Category { - /// Language/toolchain-specific linter — fast; included in all init profiles. + /// Primary programming language linter/formatter (Rust, Python, Go, …) — all init profiles. Lang, - /// General fast linter — included in `default` and `comprehensive` init profiles. + /// Supplementary language check (shell, Docker, CI/CD) — `default` + `comprehensive` only. + Style, + /// General fast tool (not language-specific) — `default` and `comprehensive` init profiles. #[default] Default, - /// Slow linter — included only in the `comprehensive` init profile; - /// skipped when `--fast-only` is passed. + /// Slow tool — `comprehensive` init profile only; skipped when `--fast-only` is passed. Slow, } @@ -275,12 +276,18 @@ impl Check { self } - /// Mark as a language-specific linter — included in all init profiles. + /// Mark as a primary language analysis check — included in all init profiles. pub fn lang(mut self) -> Self { self.category = Category::Lang; self } + /// Mark as a language-specific style/formatter check — included in all init profiles. + pub fn style(mut self) -> Self { + self.category = Category::Style; + self + } + /// Set the canonical mise tool key used when installing this linter via `flint init` /// (e.g. `npm:prettier`, `pipx:ruff`). Distinct from `mise_tool_name`, which is the /// lookup key used to detect the tool in an existing mise.toml — existing repos may @@ -327,11 +334,11 @@ pub fn builtin() -> Vec { &["*.sh", "*.bash", "*.bats"], ) .linter_config(".shellcheckrc", "--rcfile") - .lang(), + .style(), Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) .fix("shfmt -w {FILE}") .formatter() - .lang(), + .style(), Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) .fix("markdownlint-cli2 --fix {FILE}") .linter_config(".markdownlint.json", "--config") @@ -352,14 +359,14 @@ pub fn builtin() -> Vec { &[".github/workflows/*.yml", ".github/workflows/*.yaml"], ) .linter_config("actionlint.yml", "-config-file") - .lang(), + .style(), Check::file( "hadolint", "hadolint {FILE}", &["Dockerfile", "Dockerfile.*", "*.dockerfile"], ) .linter_config(".hadolint.yaml", "--config") - .lang(), + .style(), Check::files("codespell", "codespell {FILES}", &["*"]) .fix("codespell --write-changes {FILES}") .linter_config(".codespellrc", "--config") From c2a56711bce4ca59b2948f0755e8304cfaf532de Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 14:28:50 +0000 Subject: [PATCH 073/141] feat: redesign flint init with arrow-nav and unified linter table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Category selection now uses arrow navigation (↑↓ + space to toggle) - Replace two-step context table + change list with a single interactive linter table: all applicable linters shown with SEL/NAME/BINARY/SPEED/ PATTERNS/ACTION columns, pre-selected based on chosen categories - ACTION column is live: add/keep/upgrade/remove/— updates as you toggle - Checks sharing an install key (cargo-clippy+cargo-fmt, biome+biome-fmt) are grouped together and toggled as one unit - Only suggest removing installed tools whose category was in scope (fixes renovate showing as "remove" when slow category not selected) - Add crossterm dependency for raw-mode terminal input --- Cargo.lock | 182 +++++++++++++++++++-- Cargo.toml | 1 + src/init.rs | 458 +++++++++++++++++++++++++++++++--------------------- 3 files changed, 447 insertions(+), 194 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba509b3..abd8543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -146,6 +146,31 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -159,7 +184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -188,6 +213,7 @@ version = "0.20.0-alpha.1" dependencies = [ "anyhow", "clap", + "crossterm", "figment", "regex", "semver", @@ -288,6 +314,12 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -322,8 +354,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -475,6 +508,19 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -484,8 +530,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -552,6 +598,27 @@ dependencies = [ "serde", ] +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -581,7 +648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -610,8 +677,8 @@ dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -628,7 +695,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -774,12 +841,43 @@ dependencies = [ "semver", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -789,6 +887,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 2eaa6ee..c137be6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/grafana/flint" [dependencies] anyhow = "1" +crossterm = "0.28" clap = { version = "4", features = ["derive", "env"] } figment = { version = "0.10", features = ["toml", "env"] } serde = { version = "1", features = ["derive"] } diff --git a/src/init.rs b/src/init.rs index 756a9f7..af146bd 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,9 +1,16 @@ use anyhow::{Context, Result}; use std::collections::{HashMap, HashSet}; -use std::io::{self, BufRead, Write}; +use std::io::{self, Write}; use std::path::Path; use std::process::Command; +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyModifiers}, + execute, + terminal::{self, ClearType}, +}; + use crate::registry::{Category, Check, builtin}; /// Linter profile — shorthand for `--profile` CLI flag; maps to a category set. @@ -32,39 +39,32 @@ fn profile_to_categories(profile: Profile) -> HashSet { } /// Desired tools for a profile: maps each mise tool key to its optional components string. +#[cfg(test)] type DesiredTools = HashMap>; -// --- Change list (step 2) --- - -enum ChangeKind { - Add { - key: String, - components: Option<&'static str>, - }, - Remove { - key: String, - }, - Upgrade { - key: String, - components: &'static str, - }, -} - -struct ChangeItem { +// One entry per install key — groups all checks sharing that key. +struct LinterGroup<'a> { + key: &'static str, + checks: Vec<&'a Check>, // sorted by name + installed: bool, + needs_upgrade: bool, selected: bool, - kind: ChangeKind, } -impl ChangeItem { - fn label(&self) -> String { - match &self.kind { - ChangeKind::Add { key, components } => { - format!("[+] {} = {}", key, format_toml_value(components)) - } - ChangeKind::Remove { key } => format!("[-] {}", key), - ChangeKind::Upgrade { key, components } => { - format!("[~] {} (add components: {})", key, components) +impl LinterGroup<'_> { + fn action(&self) -> &'static str { + if self.selected { + if !self.installed { + "add" + } else if self.needs_upgrade { + "upgrade" + } else { + "keep" } + } else if self.installed { + "remove" + } else { + "" } } } @@ -110,24 +110,19 @@ Add and stage your source files before running init so the detection is accurate println!(); let registry = builtin(); - - // Detect which file patterns have matches in the repo. let present_patterns = detect_present_patterns(project_root, ®istry)?; - // Determine the active category set. - // --profile maps directly; otherwise ask interactively (skipped with --yes, uses default). - let categories: HashSet = if let Some(profile) = profile_arg { + // Step 1: determine which categories set the initial pre-selection. + let default_categories: HashSet = if let Some(profile) = profile_arg { profile_to_categories(profile) } else if yes { - // --yes without --profile: use the default category set (lang + style + general). profile_to_categories(Profile::Default) } else { let mut cat_items = default_category_items(); - if !select_categories(&mut cat_items)? { + if !select_categories_arrow(&mut cat_items)? { println!("Aborted."); return Ok(()); } - println!(); cat_items .iter() .filter(|i| i.selected) @@ -135,105 +130,57 @@ Add and stage your source files before running init so the detection is accurate .collect() }; - // Compute the map of mise tool keys → optional components for the selected categories. - let desired = compute_desired_tools(®istry, &present_patterns, &categories); - - // Read existing mise.toml (may not exist yet). let mise_path = project_root.join("mise.toml"); let current_content = std::fs::read_to_string(&mise_path).unwrap_or_default(); let current_tool_keys = parse_tool_keys(¤t_content); - - // All flint-known tool keys — used to constrain what we remove. let known_keys: HashSet<&str> = registry.iter().filter_map(install_key).collect(); - let mut to_add: Vec<(String, Option<&'static str>)> = desired - .iter() - .filter(|(k, _)| !current_tool_keys.contains(k.as_str())) - .map(|(k, c)| (k.clone(), *c)) - .collect(); - to_add.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut to_remove: Vec = current_tool_keys - .iter() - .filter(|k| known_keys.contains(k.as_str()) && !desired.contains_key(k.as_str())) - .cloned() - .collect(); - to_remove.sort(); - - // Tools already present that need components added (e.g. `rust = "1.x"` → inline table). - let mut to_upgrade: Vec<(String, &'static str)> = desired - .iter() - .filter_map(|(k, components)| components.map(|c| (k.clone(), c))) - .filter(|(k, _)| current_tool_keys.contains(k.as_str())) - .filter(|(k, c)| entry_components_differ(¤t_content, k, c)) - .collect(); - to_upgrade.sort_by(|a, b| a.0.cmp(&b.0)); - - // Build unified change list. - let mut items: Vec = Vec::new(); - for (key, components) in &to_add { - items.push(ChangeItem { - selected: true, - kind: ChangeKind::Add { - key: key.clone(), - components: *components, - }, - }); - } - for key in &to_remove { - items.push(ChangeItem { - selected: true, - kind: ChangeKind::Remove { key: key.clone() }, - }); - } - for (key, components) in &to_upgrade { - items.push(ChangeItem { - selected: true, - kind: ChangeKind::Upgrade { - key: key.clone(), - components, - }, - }); - } + // Step 2: build one group per install key, covering all checks whose files are + // present in the repo or which are already installed. + let mut groups = build_linter_groups( + ®istry, + &present_patterns, + ¤t_tool_keys, + ¤t_content, + &default_categories, + ); - if items.is_empty() { - println!("mise.toml [tools] is already up to date for the selected categories."); + if groups.is_empty() { + println!("No applicable linters found for this project."); return Ok(()); } - // Interactive item selection (skipped with --yes). - if !yes && !interactive_select(&mut items)? { + // Step 3: interactive linter table (skipped with --yes). + if !yes && !interactive_select_linters(&mut groups)? { println!("Aborted."); return Ok(()); } - let final_add: Vec<(String, Option<&'static str>)> = items - .iter() - .filter(|i| i.selected) - .filter_map(|i| match &i.kind { - ChangeKind::Add { key, components } => Some((key.clone(), *components)), - _ => None, - }) - .collect(); - let final_remove: Vec = items - .iter() - .filter(|i| i.selected) - .filter_map(|i| match &i.kind { - ChangeKind::Remove { key } => Some(key.clone()), - _ => None, - }) - .collect(); - let final_upgrade: Vec<(String, &'static str)> = items - .iter() - .filter(|i| i.selected) - .filter_map(|i| match &i.kind { - ChangeKind::Upgrade { key, components } => Some((key.clone(), *components)), - _ => None, - }) - .collect(); + // Derive changes from final selection state. + let mut final_add: Vec<(String, Option<&'static str>)> = Vec::new(); + let mut final_remove: Vec = Vec::new(); + let mut final_upgrade: Vec<(String, &'static str)> = Vec::new(); + + for group in &groups { + if group.selected { + if !group.installed { + let components = group.checks.iter().find_map(|c| c.mise_install_components); + final_add.push((group.key.to_string(), components)); + } else if group.needs_upgrade { + let components = group + .checks + .iter() + .find_map(|c| c.mise_install_components) + .unwrap(); + final_upgrade.push((group.key.to_string(), components)); + } + } else if group.installed && known_keys.contains(group.key) { + final_remove.push(group.key.to_string()); + } + } if final_add.is_empty() && final_remove.is_empty() && final_upgrade.is_empty() { - println!("No changes selected."); + println!("No changes to apply."); return Ok(()); } @@ -267,6 +214,7 @@ pub fn install_key(check: &Check) -> Option<&'static str> { /// Compute the map of `tool_key → optional_components` for the given category set, /// filtered to file patterns present in the repo. +#[cfg(test)] fn compute_desired_tools( registry: &[Check], present_patterns: &HashSet, @@ -359,81 +307,223 @@ fn entry_components_differ(content: &str, key: &str, required: &str) -> bool { } } -/// Format the display string for a tool entry (used in the planned-changes output). -fn format_toml_value(components: &Option<&'static str>) -> String { - match components { - Some(c) => format!(r#"{{ version = "latest", components = "{c}" }}"#), - None => r#""latest""#.to_string(), +/// Builds one `LinterGroup` per install key, covering all checks whose file patterns +/// are present in the repo or whose key is already installed. +fn build_linter_groups<'a>( + registry: &'a [Check], + present_patterns: &HashSet, + current_tool_keys: &HashSet, + current_content: &str, + default_categories: &HashSet, +) -> Vec> { + let mut by_key: HashMap<&'static str, Vec<&'a Check>> = HashMap::new(); + for check in registry { + let key = match install_key(check) { + Some(k) => k, + None => continue, + }; + if files_present(check, present_patterns) || current_tool_keys.contains(key) { + by_key.entry(key).or_default().push(check); + } } -} -/// Step 1: interactive category selection. Returns `true` to continue, `false` to abort. -fn select_categories(items: &mut [CategoryItem]) -> Result { - loop { - println!("Select categories:"); - println!(); - for (i, item) in items.iter().enumerate() { - let check = if item.selected { "āœ“" } else { " " }; - println!(" {:>2}. {} {}", i + 1, check, item.label); - } - print!("\nToggle by number (space-separated), Enter to continue, q to abort: "); - io::stdout().flush()?; - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - let trimmed = line.trim(); - - if trimmed.eq_ignore_ascii_case("q") { - return Ok(false); - } - if trimmed.is_empty() { - return Ok(true); - } - for token in trimmed.split_whitespace() { - if let Ok(n) = token.parse::() - && n >= 1 - && n <= items.len() - { - items[n - 1].selected = !items[n - 1].selected; + let mut groups: Vec> = by_key + .into_iter() + .map(|(key, mut checks)| { + checks.sort_by_key(|c| c.name); + let installed = current_tool_keys.contains(key); + let needs_upgrade = checks.iter().any(|c| { + c.mise_install_components + .is_some_and(|comp| entry_components_differ(current_content, key, comp)) + }); + // Pre-select if any check in the group is in the default categories and its + // patterns are present, OR if the key is already installed. + let suggested = checks.iter().any(|c| { + default_categories.contains(&c.category) && files_present(c, present_patterns) + }); + LinterGroup { + key, + checks, + installed, + needs_upgrade, + selected: suggested || installed, } - } - println!(); - } + }) + .collect(); + + groups.sort_by_key(|g| g.checks.first().map_or(g.key, |c| c.name)); + groups } -/// Step 2: interactive change-list selection. Returns `true` to apply, `false` to abort. -fn interactive_select(items: &mut [ChangeItem]) -> Result { - loop { - print_items(items); - print!("\nToggle by number (space-separated), Enter to apply, q to abort: "); - io::stdout().flush()?; - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - let trimmed = line.trim(); - - if trimmed.eq_ignore_ascii_case("q") { - return Ok(false); - } - if trimmed.is_empty() { - return Ok(true); - } - for token in trimmed.split_whitespace() { - if let Ok(n) = token.parse::() - && n >= 1 - && n <= items.len() - { - items[n - 1].selected = !items[n - 1].selected; +fn run_arrow_selector( + items: &mut Vec, + print_fn: fn(&mut dyn Write, &[T], usize) -> Result, + toggle_fn: fn(&mut T), +) -> Result { + let mut cursor = 0usize; + terminal::enable_raw_mode()?; + let result = (|| -> Result { + let mut stdout = io::stdout(); + let mut n_lines = print_fn(&mut stdout, items, cursor)?; + loop { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Up if cursor > 0 => cursor -= 1, + KeyCode::Down if cursor + 1 < items.len() => cursor += 1, + KeyCode::Char(' ') => toggle_fn(&mut items[cursor]), + KeyCode::Enter => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(true); + } + KeyCode::Char('q') | KeyCode::Esc => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + _ => continue, + } + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + n_lines = print_fn(&mut stdout, items, cursor)?; } } - } + })(); + let _ = terminal::disable_raw_mode(); + println!(); + result } -fn print_items(items: &[ChangeItem]) { - println!("\nRecommended changes:"); - println!(); +// --- Step 1: category selection --- + +fn select_categories_arrow(items: &mut Vec) -> Result { + run_arrow_selector(items, print_cat_selector, |item| { + item.selected = !item.selected + }) +} + +fn print_cat_selector( + stdout: &mut dyn Write, + items: &[CategoryItem], + cursor: usize, +) -> Result { + let mut lines = 0usize; + write!(stdout, "Select categories:\r\n\r\n")?; + lines += 2; for (i, item) in items.iter().enumerate() { - let check = if item.selected { "āœ“" } else { " " }; - println!(" {:>2}. {} {}", i + 1, check, item.label()); + let arrow = if i == cursor { ">" } else { " " }; + let sel = if item.selected { "āœ“" } else { " " }; + write!(stdout, " {} [{}] {}\r\n", arrow, sel, item.label)?; + lines += 1; + } + write!( + stdout, + "\r\n ↑↓ navigate space toggle enter continue q abort\r\n" + )?; + lines += 2; + stdout.flush()?; + Ok(lines) +} + +// --- Step 2: linter table selection --- + +fn interactive_select_linters(groups: &mut Vec) -> Result { + run_arrow_selector(groups, print_linter_table, |group| { + group.selected = !group.selected + }) +} + +fn print_linter_table( + stdout: &mut dyn Write, + groups: &[LinterGroup], + cursor: usize, +) -> Result { + let name_w = groups + .iter() + .flat_map(|g| &g.checks) + .map(|c| c.name.len()) + .max() + .unwrap_or(4) + .max(4); + let bin_w = groups + .iter() + .flat_map(|g| &g.checks) + .map(|c| c.bin_name.len()) + .max() + .unwrap_or(6) + .max(6); + + let mut lines = 0usize; + write!( + stdout, + " {:<5} {:" } else { " " }; + let speed = if check.category == Category::Slow { + "slow" + } else { + "fast" + }; + let patterns = check.patterns.join(" "); + write!( + stdout, + " {} {} {: Date: Mon, 6 Apr 2026 14:54:32 +0000 Subject: [PATCH 074/141] feat: allow independent selection of cargo-clippy and cargo-fmt in init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each check in a group now has its own selection state. Navigation in the linter table moves by row (flat index across all checks) rather than by group, so cargo-fmt and cargo-clippy can be toggled independently. Components written to mise.toml are derived from whichever checks are selected (e.g. only cargo-fmt selected → components = "rustfmt"). --- src/init.rs | 222 +++++++++++++++++++++++++++++++++++++----------- src/registry.rs | 16 ++-- 2 files changed, 178 insertions(+), 60 deletions(-) diff --git a/src/init.rs b/src/init.rs index af146bd..ae02097 100644 --- a/src/init.rs +++ b/src/init.rs @@ -40,23 +40,43 @@ fn profile_to_categories(profile: Profile) -> HashSet { /// Desired tools for a profile: maps each mise tool key to its optional components string. #[cfg(test)] -type DesiredTools = HashMap>; +type DesiredTools = HashMap>; // One entry per install key — groups all checks sharing that key. struct LinterGroup<'a> { key: &'static str, - checks: Vec<&'a Check>, // sorted by name + checks: Vec<&'a Check>, // sorted by name + check_selected: Vec, // parallel to checks installed: bool, - needs_upgrade: bool, - selected: bool, + current_components: Option, } impl LinterGroup<'_> { + fn any_selected(&self) -> bool { + self.check_selected.iter().any(|&s| s) + } + + /// Components string to write for the currently selected checks, e.g. `"clippy,rustfmt"`. + /// Returns `None` when no selected check carries a component requirement. + fn selected_components(&self) -> Option { + let comps: Vec<&'static str> = self + .checks + .iter() + .zip(&self.check_selected) + .filter_map(|(c, &sel)| if sel { c.mise_install_components } else { None }) + .collect(); + if comps.is_empty() { + None + } else { + Some(comps.join(",")) + } + } + fn action(&self) -> &'static str { - if self.selected { + if self.any_selected() { if !self.installed { "add" - } else if self.needs_upgrade { + } else if self.selected_components() != self.current_components { "upgrade" } else { "keep" @@ -157,22 +177,24 @@ Add and stage your source files before running init so the detection is accurate } // Derive changes from final selection state. - let mut final_add: Vec<(String, Option<&'static str>)> = Vec::new(); + let mut final_add: Vec<(String, Option)> = Vec::new(); let mut final_remove: Vec = Vec::new(); - let mut final_upgrade: Vec<(String, &'static str)> = Vec::new(); + let mut final_upgrade: Vec<(String, String)> = Vec::new(); for group in &groups { - if group.selected { + if group.any_selected() { if !group.installed { - let components = group.checks.iter().find_map(|c| c.mise_install_components); - final_add.push((group.key.to_string(), components)); - } else if group.needs_upgrade { - let components = group - .checks - .iter() - .find_map(|c| c.mise_install_components) - .unwrap(); - final_upgrade.push((group.key.to_string(), components)); + final_add.push((group.key.to_string(), group.selected_components())); + } else { + let target = group.selected_components(); + if target != group.current_components { + // Upgrade: components changed (added, removed, or reordered). + // If the target has no components (e.g. all component-bearing checks + // deselected), treat as a plain-version install via add+remove. + if let Some(comps) = target { + final_upgrade.push((group.key.to_string(), comps)); + } + } } } else if group.installed && known_keys.contains(group.key) { final_remove.push(group.key.to_string()); @@ -220,7 +242,8 @@ fn compute_desired_tools( present_patterns: &HashSet, categories: &HashSet, ) -> DesiredTools { - let mut desired = DesiredTools::new(); + // Collect per-key component lists so multiple checks sharing a key are merged. + let mut by_key: HashMap> = HashMap::new(); for check in registry { let key = match install_key(check) { Some(k) => k, @@ -230,10 +253,25 @@ fn compute_desired_tools( continue; } if categories.contains(&check.category) { - desired.insert(key.to_string(), check.mise_install_components); + let entry = by_key.entry(key.to_string()).or_default(); + if let Some(comp) = check.mise_install_components { + if !entry.contains(&comp) { + entry.push(comp); + } + } } } - desired + by_key + .into_iter() + .map(|(k, comps)| { + let merged = if comps.is_empty() { + None + } else { + Some(comps.join(",")) + }; + (k, merged) + }) + .collect() } /// Returns `true` if the repo contains at least one file matching any of the @@ -286,6 +324,7 @@ fn parse_tool_keys(content: &str) -> HashSet { /// Returns `true` if the `[tools]` entry for `key` exists and its `components` /// field is absent or differs from `required`. Used to detect entries that need /// upgrading (missing components) or correcting (wrong components). +#[cfg(test)] fn entry_components_differ(content: &str, key: &str, required: &str) -> bool { let doc: toml_edit::DocumentMut = match content.parse() { Ok(d) => d, @@ -307,6 +346,17 @@ fn entry_components_differ(content: &str, key: &str, required: &str) -> bool { } } +/// Returns the `components` string currently set for `key` in the `[tools]` section, +/// or `None` if the key is absent, is a plain string entry, or has no `components` field. +fn get_entry_components(content: &str, key: &str) -> Option { + let doc: toml_edit::DocumentMut = content.parse().ok()?; + let tools = doc.get("tools")?.as_table()?; + match tools.get(key)?.as_value()? { + toml_edit::Value::InlineTable(tbl) => tbl.get("components")?.as_str().map(str::to_string), + _ => None, + } +} + /// Builds one `LinterGroup` per install key, covering all checks whose file patterns /// are present in the repo or whose key is already installed. fn build_linter_groups<'a>( @@ -332,21 +382,27 @@ fn build_linter_groups<'a>( .map(|(key, mut checks)| { checks.sort_by_key(|c| c.name); let installed = current_tool_keys.contains(key); - let needs_upgrade = checks.iter().any(|c| { - c.mise_install_components - .is_some_and(|comp| entry_components_differ(current_content, key, comp)) - }); - // Pre-select if any check in the group is in the default categories and its - // patterns are present, OR if the key is already installed. - let suggested = checks.iter().any(|c| { - default_categories.contains(&c.category) && files_present(c, present_patterns) - }); + let current_components = if installed { + get_entry_components(current_content, key) + } else { + None + }; + // Pre-select each check individually: select if its category is in the + // default set and its patterns are present, OR if the key is already installed. + let check_selected: Vec = checks + .iter() + .map(|c| { + let suggested = default_categories.contains(&c.category) + && files_present(c, present_patterns); + suggested || installed + }) + .collect(); LinterGroup { key, checks, + check_selected, installed, - needs_upgrade, - selected: suggested || installed, + current_components, } }) .collect(); @@ -356,7 +412,7 @@ fn build_linter_groups<'a>( } fn run_arrow_selector( - items: &mut Vec, + items: &mut [T], print_fn: fn(&mut dyn Write, &[T], usize) -> Result, toggle_fn: fn(&mut T), ) -> Result { @@ -413,7 +469,7 @@ fn run_arrow_selector( // --- Step 1: category selection --- -fn select_categories_arrow(items: &mut Vec) -> Result { +fn select_categories_arrow(items: &mut [CategoryItem]) -> Result { run_arrow_selector(items, print_cat_selector, |item| { item.selected = !item.selected }) @@ -444,10 +500,72 @@ fn print_cat_selector( // --- Step 2: linter table selection --- +/// Maps a flat row index (across all checks in all groups) to `(group_idx, check_idx)`. +fn flat_to_group_check(groups: &[LinterGroup], flat: usize) -> (usize, usize) { + let mut remaining = flat; + for (gi, group) in groups.iter().enumerate() { + if remaining < group.checks.len() { + return (gi, remaining); + } + remaining -= group.checks.len(); + } + (0, 0) +} + fn interactive_select_linters(groups: &mut Vec) -> Result { - run_arrow_selector(groups, print_linter_table, |group| { - group.selected = !group.selected - }) + let total_rows = |gs: &[LinterGroup]| gs.iter().map(|g| g.checks.len()).sum::(); + let mut cursor = 0usize; + terminal::enable_raw_mode()?; + let result = (|| -> Result { + let mut stdout = io::stdout(); + let mut n_lines = print_linter_table(&mut stdout, groups, cursor)?; + loop { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Up if cursor > 0 => cursor -= 1, + KeyCode::Down if cursor + 1 < total_rows(groups) => cursor += 1, + KeyCode::Char(' ') => { + let (gi, ci) = flat_to_group_check(groups, cursor); + groups[gi].check_selected[ci] = !groups[gi].check_selected[ci]; + } + KeyCode::Enter => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(true); + } + KeyCode::Char('q') | KeyCode::Esc => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + _ => continue, + } + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + n_lines = print_linter_table(&mut stdout, groups, cursor)?; + } + } + })(); + let _ = terminal::disable_raw_mode(); + println!(); + result } fn print_linter_table( @@ -489,11 +607,16 @@ fn print_linter_table( )?; lines += 2; - for (gi, group) in groups.iter().enumerate() { + let mut flat_idx = 0usize; + for group in groups.iter() { let action = group.action(); - let sel_mark = if group.selected { "[āœ“]" } else { "[ ]" }; for (ci, check) in group.checks.iter().enumerate() { - let cursor_mark = if gi == cursor && ci == 0 { ">" } else { " " }; + let sel_mark = if group.check_selected[ci] { + "[āœ“]" + } else { + "[ ]" + }; + let cursor_mark = if flat_idx == cursor { ">" } else { " " }; let speed = if check.category == Category::Slow { "slow" } else { @@ -514,6 +637,7 @@ fn print_linter_table( bin_w = bin_w, )?; lines += 1; + flat_idx += 1; } } @@ -529,9 +653,9 @@ fn print_linter_table( fn apply_changes( path: &Path, current_content: &str, - to_add: &[(String, Option<&'static str>)], + to_add: &[(String, Option)], to_remove: &[String], - to_upgrade: &[(String, &'static str)], + to_upgrade: &[(String, String)], ) -> Result<()> { let mut doc: toml_edit::DocumentMut = current_content .parse() @@ -554,7 +678,7 @@ fn apply_changes( Some(comps) => { let mut tbl = toml_edit::InlineTable::new(); tbl.insert("version", toml_edit::Value::from("latest")); - tbl.insert("components", toml_edit::Value::from(*comps)); + tbl.insert("components", toml_edit::Value::from(comps.as_str())); tools.insert( key.as_str(), toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), @@ -566,7 +690,7 @@ fn apply_changes( } } - // Upgrade existing entries: preserve the current version, add components. + // Upgrade existing entries: preserve the current version, update components. for (key, components) in to_upgrade { let existing_version = tools .get(key.as_str()) @@ -583,7 +707,7 @@ fn apply_changes( let mut tbl = toml_edit::InlineTable::new(); tbl.insert("version", toml_edit::Value::from(existing_version.as_str())); - tbl.insert("components", toml_edit::Value::from(*components)); + tbl.insert("components", toml_edit::Value::from(components.as_str())); tools.insert( key.as_str(), toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), @@ -648,7 +772,7 @@ mod tests { content, &[], &[], - &[("rust".to_string(), "clippy,rustfmt")], + &[("rust".to_string(), "clippy,rustfmt".to_string())], ) .unwrap(); let result = std::fs::read_to_string(tmp.path()).unwrap(); @@ -699,11 +823,11 @@ rust = { version = "1.0", components = "clippy" } present.insert("*.rs".to_string()); let categories = profile_to_categories(Profile::Lang); let tools = compute_desired_tools(®istry, &present, &categories); - // Both cargo-clippy and cargo-fmt share the "rust" key with components set. + // Both cargo-clippy and cargo-fmt share the "rust" key; their components are merged. assert_eq!( tools.get("rust"), - Some(&Some("clippy,rustfmt")), - "rust tool entry should carry components" + Some(&Some("clippy,rustfmt".to_string())), + "rust tool entry should carry merged components" ); } diff --git a/src/registry.rs b/src/registry.rs index 25e3141..344d815 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::path::Path; +use crate::linters::renovate_deps::RENOVATE_CONFIG_PATTERNS; + /// How a check is invoked relative to the file list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Scope { @@ -420,7 +422,7 @@ pub fn builtin() -> Vec { Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") .mise_tool("rust") - .install_components("clippy,rustfmt") + .install_components("clippy") .lang() .note("lints all .rs files, not just changed"), Check::files( @@ -432,7 +434,7 @@ pub fn builtin() -> Vec { .full_cmd("cargo fmt -- --check", "cargo fmt") .bin("rustfmt") .mise_tool("rust") - .install_components("clippy,rustfmt") + .install_components("rustfmt") .formatter() .lang(), Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) @@ -482,15 +484,7 @@ pub fn builtin() -> Vec { Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) .mise_tool("npm:renovate") .slow() - .patterns(&[ - "renovate.json", - "renovate.json5", - ".github/renovate.json", - ".github/renovate.json5", - ".renovaterc", - ".renovaterc.json", - ".renovaterc.json5", - ]), + .patterns(RENOVATE_CONFIG_PATTERNS), Check::special( "license-header", "license-header", From 25428e4869bd5ef29b834deff950eb96c3ab1374 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 17:02:14 +0200 Subject: [PATCH 075/141] fix(renovate-deps): track snapshot next to config --- src/files.rs | 4 +- src/linters/renovate_deps.rs | 104 +++++++++++++++--- .../files/.renovaterc.json | 1 + .../files/mise.toml | 2 + .../files/package.json | 1 + .../files/renovate-tracked-deps.json | 8 ++ .../up-to-date-renovaterc-json/test.toml | 9 ++ 7 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 tests/cases/renovate-deps/up-to-date-renovaterc-json/files/.renovaterc.json create mode 100644 tests/cases/renovate-deps/up-to-date-renovaterc-json/files/mise.toml create mode 100644 tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json create mode 100644 tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json create mode 100644 tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml diff --git a/src/files.rs b/src/files.rs index cb4de56..3d89600 100644 --- a/src/files.rs +++ b/src/files.rs @@ -3,10 +3,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::Config; -use crate::linters::renovate_deps::COMMITTED_DISPLAY; +use crate::linters::renovate_deps::COMMITTED_PATHS; /// Files managed by flint itself — always excluded from generic linter checks. -const BUILTIN_EXCLUDES: &[&str] = &[COMMITTED_DISPLAY]; +const BUILTIN_EXCLUDES: &[&str] = COMMITTED_PATHS; #[derive(Clone)] pub struct FileList { diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 2404e9c..2e9f1bd 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -1,15 +1,22 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Stdio; use tokio::process::Command; use crate::config::RenovateDepsConfig; use crate::linters::LinterOutput; -const COMMITTED_DIR: &str = ".github"; const COMMITTED_FILE: &str = "renovate-tracked-deps.json"; -pub(crate) const COMMITTED_DISPLAY: &str = ".github/renovate-tracked-deps.json"; -const RENOVATE_CONFIG_FILE: &str = "renovate.json5"; +pub(crate) const COMMITTED_PATHS: &[&str] = &[COMMITTED_FILE, ".github/renovate-tracked-deps.json"]; +pub(crate) const RENOVATE_CONFIG_PATTERNS: &[&str] = &[ + "renovate.json", + "renovate.json5", + ".github/renovate.json", + ".github/renovate.json5", + ".renovaterc", + ".renovaterc.json", + ".renovaterc.json5", +]; const PACKAGE_FILES_MSG: &str = "packageFiles with updates"; const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-version"]; @@ -28,9 +35,11 @@ async fn run_inner( fix: bool, project_root: &Path, ) -> anyhow::Result { - let log_bytes = run_renovate(project_root).await?; + let config_path = resolve_renovate_config_path(project_root)?; + let committed_path = committed_path_for_config(&config_path); + let committed_display = display_path(project_root, &committed_path); + let log_bytes = run_renovate(project_root, &config_path).await?; let generated = extract_deps(&log_bytes, &cfg.exclude_managers)?; - let committed_path = project_root.join(COMMITTED_DIR).join(COMMITTED_FILE); if !committed_path.exists() { if fix { @@ -42,7 +51,7 @@ async fn run_inner( }); } return Ok(LinterOutput::err(format!( - "ERROR: {COMMITTED_DISPLAY} does not exist.\nRun `flint run --fix renovate-deps` to create it.\n" + "ERROR: {committed_display} does not exist.\nRun `flint run --fix renovate-deps` to create it.\n" ))); } @@ -56,7 +65,7 @@ async fn run_inner( }); } - let diff = unified_diff(&committed, &generated); + let diff = unified_diff(&committed, &generated, &committed_display); if fix { write_snapshot(&committed_path, &generated)?; @@ -80,9 +89,7 @@ async fn run_inner( } /// Runs `renovate --platform=local` and returns the combined stdout+stderr log bytes. -async fn run_renovate(project_root: &Path) -> anyhow::Result> { - let config_path = project_root.join(COMMITTED_DIR).join(RENOVATE_CONFIG_FILE); - +async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result> { // Forward env, setting Renovate-specific vars. let mut env: Vec<(String, String)> = std::env::vars().collect(); // Override logging to get parseable JSON output. @@ -131,6 +138,33 @@ async fn run_renovate(project_root: &Path) -> anyhow::Result> { Ok(combined) } +fn resolve_renovate_config_path(project_root: &Path) -> anyhow::Result { + RENOVATE_CONFIG_PATTERNS + .iter() + .map(|path| project_root.join(path)) + .find(|path| path.exists()) + .ok_or_else(|| { + anyhow::anyhow!( + "no supported Renovate config file found; tried: {}", + RENOVATE_CONFIG_PATTERNS.join(", ") + ) + }) +} + +fn committed_path_for_config(config_path: &Path) -> PathBuf { + config_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(COMMITTED_FILE) +} + +fn display_path(project_root: &Path, path: &Path) -> String { + path.strip_prefix(project_root) + .unwrap_or(path) + .to_string_lossy() + .into_owned() +} + /// Parses Renovate's NDJSON log and returns the dep map. fn extract_deps(log_bytes: &[u8], exclude_managers: &[String]) -> anyhow::Result { let log = std::str::from_utf8(log_bytes)?; @@ -209,13 +243,13 @@ fn write_snapshot(path: &Path, deps: &DepMap) -> anyhow::Result<()> { Ok(()) } -fn unified_diff(old: &DepMap, new: &DepMap) -> String { +fn unified_diff(old: &DepMap, new: &DepMap, committed_display: &str) -> String { let old_text = serde_json::to_string_pretty(old).unwrap_or_default() + "\n"; let new_text = serde_json::to_string_pretty(new).unwrap_or_default() + "\n"; let diff = similar::TextDiff::from_lines(&old_text, &new_text); diff.unified_diff() - .header(COMMITTED_DISPLAY, "generated") + .header(committed_display, "generated") .to_string() } @@ -361,7 +395,7 @@ mod tests { fn unified_diff_contains_added_and_removed_lines() { let old = dep_map(&[("a.json", &[("npm", &["old-dep"])])]); let new = dep_map(&[("a.json", &[("npm", &["new-dep"])])]); - let diff = unified_diff(&old, &new); + let diff = unified_diff(&old, &new, ".github/renovate-tracked-deps.json"); assert!(diff.contains("-"), "should have removals"); assert!(diff.contains("+"), "should have additions"); assert!(diff.contains("old-dep")); @@ -372,7 +406,45 @@ mod tests { fn unified_diff_header_uses_display_path() { let old = dep_map(&[("a.json", &[("npm", &["x"])])]); let new = dep_map(&[("a.json", &[("npm", &["y"])])]); - let diff = unified_diff(&old, &new); - assert!(diff.contains(COMMITTED_DISPLAY)); + let diff = unified_diff(&old, &new, "renovate-tracked-deps.json"); + assert!(diff.contains("renovate-tracked-deps.json")); + } + + #[test] + fn resolves_supported_renovate_config_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".renovaterc.json"); + std::fs::write(&config_path, "{}\n").unwrap(); + + let resolved = resolve_renovate_config_path(dir.path()).unwrap(); + + assert_eq!(resolved, config_path); + } + + #[test] + fn missing_supported_renovate_config_file_returns_error() { + let dir = tempfile::tempdir().unwrap(); + + let err = resolve_renovate_config_path(dir.path()).unwrap_err(); + let msg = err.to_string(); + + assert!(msg.contains("no supported Renovate config file found")); + assert!( + RENOVATE_CONFIG_PATTERNS + .iter() + .all(|path| msg.contains(path)) + ); + } + + #[test] + fn committed_path_uses_same_dir_as_found_config() { + assert_eq!( + committed_path_for_config(Path::new("renovate.json5")), + PathBuf::from("renovate-tracked-deps.json") + ); + assert_eq!( + committed_path_for_config(Path::new(".github/renovate.json5")), + PathBuf::from(".github/renovate-tracked-deps.json") + ); } } diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/.renovaterc.json b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/.renovaterc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/.renovaterc.json @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/mise.toml b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/mise.toml new file mode 100644 index 0000000..09e8396 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "latest" diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json new file mode 100644 index 0000000..b46b339 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json @@ -0,0 +1,8 @@ +{ + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml b/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml new file mode 100644 index 0000000..05e0a36 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml @@ -0,0 +1,9 @@ +[expected] +args = "run --full renovate-deps" +exit = 0 + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' From 21dd30d98e04d52d95b5f8008db4819eeefe2203 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 17:51:36 +0200 Subject: [PATCH 076/141] fix(ci): disable rust-cache save in release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d1358f..294998d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: key: ${{ matrix.target }} + save-if: "false" - name: Install cross if: matrix.use_cross From 6259b261329b1e47115b4f046b9fac354b603858 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 18:02:22 +0200 Subject: [PATCH 077/141] docs(rust): explain missing toolchain components --- src/runner.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/runner.rs b/src/runner.rs index 57d00a9..fea7956 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -377,6 +377,8 @@ async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) - } } + maybe_append_rust_component_note(name, &mut combined_stderr); + LinterOutput { ok: all_ok, stdout: combined_stdout, @@ -384,6 +386,31 @@ async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) - } } +fn maybe_append_rust_component_note(name: &str, stderr: &mut Vec) { + let Some(component) = missing_rust_component(name, stderr) else { + return; + }; + let note = format!( + "NOTE: `{name}` needs the Rust `{component}` component in the active toolchain.\n\ +`mise` may activate an existing Rust toolchain without adding missing components.\n\ +Install it with: `rustup component add {component}`\n" + ); + stderr.extend_from_slice(note.as_bytes()); +} + +fn missing_rust_component(name: &str, stderr: &[u8]) -> Option<&'static str> { + let stderr = String::from_utf8_lossy(stderr); + match name { + "cargo-clippy" if stderr.contains("'cargo-clippy' is not installed for the toolchain") => { + Some("clippy") + } + "cargo-fmt" if stderr.contains("'rustfmt' is not installed for the toolchain") => { + Some("rustfmt") + } + _ => None, + } +} + fn flush_output(stdout: &[u8], stderr: &[u8]) { // All tool output goes to stderr so headers and diagnostics stay on the // same stream — callers (humans and AI alike) see a coherent sequence. @@ -652,4 +679,27 @@ mod tests { ); assert_eq!(inv, vec![vec!["run-it".to_string()]]); } + + #[test] + fn appends_rust_component_note_for_missing_clippy() { + let mut stderr = b"error: 'cargo-clippy' is not installed for the toolchain '1.94.1-x86_64-unknown-linux-gnu'.\n".to_vec(); + + maybe_append_rust_component_note("cargo-clippy", &mut stderr); + + let msg = String::from_utf8(stderr).unwrap(); + assert!(msg.contains("NOTE: `cargo-clippy` needs the Rust `clippy` component")); + assert!(msg.contains("rustup component add clippy")); + } + + #[test] + fn appends_rust_component_note_for_missing_rustfmt() { + let mut stderr = + b"error: 'rustfmt' is not installed for the toolchain '1.94.1-x86_64-unknown-linux-gnu'.\n".to_vec(); + + maybe_append_rust_component_note("cargo-fmt", &mut stderr); + + let msg = String::from_utf8(stderr).unwrap(); + assert!(msg.contains("NOTE: `cargo-fmt` needs the Rust `rustfmt` component")); + assert!(msg.contains("rustup component add rustfmt")); + } } From d427eff110cd44074de9b968b73c4d60e993ab23 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 18:03:47 +0200 Subject: [PATCH 078/141] fix(ci): install rust lint components --- .github/workflows/lint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e67b0c9..4767612 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,6 +28,9 @@ jobs: version: v2026.4.1 sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + - name: Install Rust lint components + run: rustup component add clippy rustfmt + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Lint From 63711e306e47a8ebc9580b1770746c67e1e3143e Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 6 Apr 2026 18:08:17 +0200 Subject: [PATCH 079/141] fix(ci): address rust components and release cache --- .github/workflows/lint.yml | 1 + .github/workflows/release.yml | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4767612..f8c54bb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,6 +28,7 @@ jobs: version: v2026.4.1 sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + # mise may activate an existing Rust toolchain without adding missing components. - name: Install Rust lint components run: rustup component add clippy rustfmt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 294998d..bcb244b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,11 +30,6 @@ jobs: with: persist-credentials: false - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - key: ${{ matrix.target }} - save-if: "false" - - name: Install cross if: matrix.use_cross run: cargo install cross --locked From 6d5f7844ec7c7a33b8196e6d3748d175f2f682aa Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 11:49:06 +0200 Subject: [PATCH 080/141] feat(init): generate env, tasks, flint.toml, and lint workflow (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `flint init` now handles the full project setup beyond just `[tools]`: - prompts for config dir (`.github/config` default), skipped on re-runs when `FLINT_CONFIG_DIR` is already set - writes `[env] FLINT_CONFIG_DIR` to `mise.toml` - adds `lint`, `lint:fix`, `lint:pre-commit` (if slow linters selected), and `setup:pre-commit-hook` tasks — idempotent, skips existing entries - generates `flint.toml` skeleton with commented `exclude`/`exclude_paths` stubs, `base_branch` only if not `main`, and `[checks.renovate-deps]` stub when renovate-deps is selected - generates `.github/workflows/lint.yml` (SHA-pinned, current versions, no rust-cache) - offers to install the git pre-commit hook - `MIGRATION.md`: collapse old steps 3+4 (write tasks manually) into "run `flint init`"; add step for `renovate.json5` preset ## Test plan - [ ] `flint init` on a new repo generates all expected files and tasks - [ ] Re-running `flint init` on an already-configured repo reports "No changes to apply" - [ ] `--yes` skips all prompts, defaults to `.github/config` - [ ] Slow linter selected → `lint:pre-commit` task + `setup:pre-commit-hook` points to it - [ ] No slow linters → no `lint:pre-commit` task + `setup:pre-commit-hook` points to `lint` - [ ] Unit tests pass: `cargo test` --- MIGRATION.md | 74 +++++---- src/init.rs | 459 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 488 insertions(+), 45 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 9a59259..b8fd308 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -29,44 +29,25 @@ what is declared in `[tools]`. "ubi:grafana/flint" = "0.20.0-alpha.1" ``` -### 3. Replace linting tasks with `flint run` +### 3. Run `flint init` -```toml -[tasks.lint] -run = "flint run" - -[tasks."lint:fix"] -run = "flint run --fix" -``` - -For CI, pass `--short` for compact output suited to AI-assisted review: - -```toml -[tasks.ci] -run = "flint run --short" -``` - -### 4. Add a pre-commit task - -flint v2 provides a fast auto-fix pass intended for git hooks: - -```toml -[tasks."lint:pre-commit"] -description = "Fast auto-fix lint (skips slow checks) — for pre-commit/pre-push hooks" -run = "flint run --fix --fast-only" +After installing flint (`mise install`), run `flint init`. It detects your +languages from tracked files and takes care of: -[tasks."setup:pre-commit-hook"] -description = "Install git pre-commit hook" -run = "mise generate git-pre-commit --write --task=lint:pre-commit" -``` +- adding linters to `[tools]` +- adding `[env] FLINT_CONFIG_DIR` pointing to your chosen config dir +- adding `lint`, `lint:fix`, `lint:pre-commit`, and `setup:pre-commit-hook` + tasks to `[tasks]` +- writing a `flint.toml` skeleton in your config dir +- generating `.github/workflows/lint.yml` -Then run `mise run setup:pre-commit-hook` once to install the hook. +Then run `mise install` to install the new tools and +`mise run setup:pre-commit-hook` to install the git hook. -### 5. Switch `markdownlint-cli` to `markdownlint-cli2` +### 4. Switch `markdownlint-cli` to `markdownlint-cli2` -flint v2 only supports `markdownlint-cli2`. See the -[section below](#replacing-markdownlint-cli-with-markdownlint-cli2) for -details — config files are compatible, no changes required there. +flint v2 only supports `markdownlint-cli2`. `flint init` selects it for new +installs, but for an existing repo you need to rename the key manually: ```toml # Before: @@ -75,17 +56,38 @@ details — config files are compatible, no changes required there. "npm:markdownlint-cli2" = "0.17.2" ``` -### 6. Move renovate-deps config to `flint.toml` +Configuration files remain compatible — both tools read `.markdownlint.json` +(and `.markdownlint.yaml`, `.markdownlint.jsonc`). No changes to your config +file are required. + +### 5. Move renovate-deps config to `flint.toml` If you previously used the `RENOVATE_TRACKED_DEPS_EXCLUDE` env var to exclude -managers, move that to a `flint.toml` at your project root instead: +managers, remove it from `[env]` in `mise.toml` and uncomment the +`exclude_managers` line that `flint init` wrote to your `flint.toml`: ```toml [checks.renovate-deps] exclude_managers = ["github-actions", "github-runners", "cargo"] ``` -Remove `RENOVATE_TRACKED_DEPS_EXCLUDE` from `[env]` in `mise.toml`. +### 6. Add the flint renovate preset to `renovate.json5` + +Add `"github>grafana/flint#v"` to the `extends` list in your +`renovate.json5`. This lets renovate keep the flint binary version up to date +automatically: + +```json5 +{ + extends: [ + "config:recommended", + "github>grafana/flint#v0.20.0", + // ... + ], +} +``` + +Replace `v0.20.0` with the version you pinned in `[tools]`. ### 7. Verify active linters diff --git a/src/init.rs b/src/init.rs index ae02097..65219e0 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use std::collections::{HashMap, HashSet}; -use std::io::{self, Write}; +use std::io::{self, BufRead, Write}; use std::path::Path; use std::process::Command; @@ -201,18 +201,45 @@ Add and stage your source files before running init so the detection is accurate } } - if final_add.is_empty() && final_remove.is_empty() && final_upgrade.is_empty() { + let has_slow = has_slow_selected(&groups); + let has_renovate = groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && c.name == "renovate-deps") + }); + + // Prompt for the flint config dir (skipped if already set in mise.toml or --yes). + let existing_config_dir = get_existing_config_dir(¤t_content); + let config_dir_rel = prompt_config_dir(existing_config_dir.as_deref(), yes)?; + + let tools_changed = + !final_add.is_empty() || !final_remove.is_empty() || !final_upgrade.is_empty(); + if tools_changed { + apply_changes( + &mise_path, + ¤t_content, + &final_add, + &final_remove, + &final_upgrade, + )?; + } + + let meta_changed = apply_env_and_tasks(&mise_path, &config_dir_rel, has_slow)?; + + let base_branch = detect_base_branch(project_root); + let config_dir_path = project_root.join(&config_dir_rel); + let toml_generated = generate_flint_toml(&config_dir_path, &base_branch, has_renovate)?; + let workflow_generated = generate_lint_workflow(project_root, &base_branch)?; + + if !tools_changed && !meta_changed && !toml_generated && !workflow_generated { println!("No changes to apply."); return Ok(()); } - apply_changes( - &mise_path, - ¤t_content, - &final_add, - &final_remove, - &final_upgrade, - )?; + let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; + maybe_install_hook(project_root, hook_task, yes)?; + println!("Done. Run `mise install` to install the new tools."); Ok(()) } @@ -718,6 +745,267 @@ fn apply_changes( Ok(()) } +// --- Post-linter-selection setup helpers --- + +/// Returns true if any currently-selected check has `Category::Slow`. +fn has_slow_selected(groups: &[LinterGroup]) -> bool { + groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && c.category == Category::Slow) + }) +} + +/// Reads the default branch for `origin` from git, falling back to `"main"`. +fn detect_base_branch(project_root: &Path) -> String { + Command::new("git") + .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) + .current_dir(project_root) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().strip_prefix("origin/").map(str::to_string)) + .unwrap_or_else(|| "main".to_string()) +} + +/// Reads `FLINT_CONFIG_DIR` from the `[env]` section of a mise.toml string, if present. +fn get_existing_config_dir(content: &str) -> Option { + let doc: toml_edit::DocumentMut = content.parse().ok()?; + doc.get("env")? + .as_table()? + .get("FLINT_CONFIG_DIR")? + .as_str() + .map(str::to_string) +} + +/// Asks where `flint.toml` should live. Skips the prompt when `--yes` or when +/// `FLINT_CONFIG_DIR` is already set in the current mise.toml. +/// +/// Returns a path relative to the project root (e.g. `".github/config"`). +fn prompt_config_dir(existing: Option<&str>, yes: bool) -> Result { + if let Some(dir) = existing { + return Ok(dir.to_string()); + } + if yes { + return Ok(".github/config".to_string()); + } + + const CHOICES: &[&str] = &[".github/config", ".github", ".", "other…"]; + println!("Where should flint.toml live?\n"); + for (i, choice) in CHOICES.iter().enumerate() { + println!(" {}) {}", i + 1, choice); + } + print!("\nChoice [1]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + let input = input.trim(); + + let idx: usize = if input.is_empty() { + 0 + } else { + input.parse::().unwrap_or(1).saturating_sub(1) + }; + + if idx == CHOICES.len() - 1 { + print!("Config dir path: "); + io::stdout().flush()?; + let mut path = String::new(); + io::stdin().lock().read_line(&mut path)?; + Ok(path.trim().to_string()) + } else { + Ok(CHOICES[idx.min(CHOICES.len() - 2)].to_string()) + } +} + +/// Writes a skeleton `flint.toml` in `config_dir`. Creates the directory if needed. +/// Returns `true` if the file was written, `false` if it already existed. +fn generate_flint_toml(config_dir: &Path, base_branch: &str, has_renovate: bool) -> Result { + let toml_path = config_dir.join("flint.toml"); + if toml_path.exists() { + return Ok(false); + } + std::fs::create_dir_all(config_dir)?; + let mut content = String::from("[settings]\n"); + if base_branch != "main" { + content.push_str(&format!("base_branch = \"{base_branch}\"\n")); + } + content.push_str("# exclude = \"CHANGELOG\\\\.md\"\n"); + content.push_str("# exclude_paths = []\n"); + if has_renovate { + content.push_str("\n[checks.renovate-deps]\n"); + content.push_str("# exclude_managers = []\n"); + } + std::fs::write(&toml_path, &content)?; + println!(" wrote {}", toml_path.display()); + Ok(true) +} + +/// Generates `.github/workflows/lint.yml` if it does not already exist. +/// Returns `true` if the file was written. +fn generate_lint_workflow(project_root: &Path, base_branch: &str) -> Result { + let workflows_dir = project_root.join(".github/workflows"); + let workflow_path = workflows_dir.join("lint.yml"); + if workflow_path.exists() { + return Ok(false); + } + std::fs::create_dir_all(&workflows_dir)?; + let content = format!( + r#"name: Lint + +on: + push: + branches: [{base_branch}] + pull_request: + branches: [{base_branch}] + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + version: v2026.4.1 + sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + + - name: Lint + env: + GITHUB_TOKEN: ${{{{ github.token }}}} + GITHUB_HEAD_SHA: ${{{{ github.event.pull_request.head.sha }}}} + run: mise run lint +"# + ); + std::fs::write(&workflow_path, content)?; + println!(" wrote {}", workflow_path.display()); + Ok(true) +} + +/// Adds a `[tasks.]` entry only when it is not already present. +/// Returns `true` if an entry was added. +fn add_task_if_absent( + tasks: &mut toml_edit::Table, + name: &str, + description: &str, + run: &str, +) -> bool { + if tasks.contains_key(name) { + return false; + } + let mut t = toml_edit::Table::new(); + t.insert("description", toml_edit::value(description)); + t.insert("run", toml_edit::value(run)); + tasks.insert(name, toml_edit::Item::Table(t)); + true +} + +/// Adds `[env] FLINT_CONFIG_DIR` and the standard `lint*` / `setup:pre-commit-hook` +/// tasks to `mise.toml`, skipping any that are already present. +/// +/// Returns `true` if the file was changed. +fn apply_env_and_tasks(mise_path: &Path, config_dir_rel: &str, has_slow: bool) -> Result { + let content = std::fs::read_to_string(mise_path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + let mut changed = false; + + // [env] — add FLINT_CONFIG_DIR if absent + { + if !doc.contains_key("env") { + doc.insert("env", toml_edit::Item::Table(toml_edit::Table::new())); + } + let env = doc["env"].as_table_mut().context("[env] is not a table")?; + if !env.contains_key("FLINT_CONFIG_DIR") { + env.insert("FLINT_CONFIG_DIR", toml_edit::value(config_dir_rel)); + changed = true; + } + } + + // [tasks] — add lint / lint:fix / (lint:pre-commit) / setup:pre-commit-hook + { + if !doc.contains_key("tasks") { + doc.insert("tasks", toml_edit::Item::Table(toml_edit::Table::new())); + } + let tasks = doc["tasks"] + .as_table_mut() + .context("[tasks] is not a table")?; + + changed |= add_task_if_absent(tasks, "lint", "Run all lints", "flint run"); + changed |= add_task_if_absent(tasks, "lint:fix", "Auto-fix lint issues", "flint run --fix"); + if has_slow { + changed |= add_task_if_absent( + tasks, + "lint:pre-commit", + "Fast auto-fix lint (skips slow checks) — for pre-commit/pre-push hooks", + "flint run --fix --fast-only", + ); + } + let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; + changed |= add_task_if_absent( + tasks, + "setup:pre-commit-hook", + "Install git pre-commit hook", + &format!("mise generate git-pre-commit --write --task={hook_task}"), + ); + } + + if changed { + std::fs::write(mise_path, doc.to_string())?; + } + Ok(changed) +} + +/// Installs the git pre-commit hook by running `mise generate git-pre-commit`. +/// Prompts the user unless `yes` is true. Silently skips if the hook is already installed. +fn maybe_install_hook(project_root: &Path, hook_task: &str, yes: bool) -> Result<()> { + let hook_path = project_root.join(".git/hooks/pre-commit"); + if hook_path.exists() { + return Ok(()); + } + + let install = if yes { + true + } else { + print!("Install pre-commit hook (runs `mise run {hook_task}` before each commit)? [Y/n] "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + !input.trim().eq_ignore_ascii_case("n") + }; + + if install { + let status = Command::new("mise") + .args([ + "generate", + "git-pre-commit", + "--write", + &format!("--task={hook_task}"), + ]) + .current_dir(project_root) + .status(); + match status { + Ok(s) if s.success() => println!(" installed pre-commit hook"), + _ => println!( + " warning: could not install pre-commit hook — run `mise run setup:pre-commit-hook` later" + ), + } + } + Ok(()) +} + // --- Tests --- #[cfg(test)] @@ -864,4 +1152,157 @@ rust = { version = "1.0", components = "clippy" } let tools = compute_desired_tools(®istry, &present, &categories); assert!(!tools.contains_key("npm:renovate")); } + + #[test] + fn has_slow_selected_detects_slow_check() { + let registry = builtin(); + let mut present = HashSet::new(); + present.insert(".github/renovate.json5".to_string()); + let categories = profile_to_categories(Profile::Comprehensive); + let groups = build_linter_groups(®istry, &present, &HashSet::new(), "", &categories); + assert!(has_slow_selected(&groups)); + } + + #[test] + fn has_slow_selected_false_for_default_profile() { + let registry = builtin(); + let present = HashSet::new(); + let categories = profile_to_categories(Profile::Default); + let groups = build_linter_groups(®istry, &present, &HashSet::new(), "", &categories); + assert!(!has_slow_selected(&groups)); + } + + #[test] + fn get_existing_config_dir_reads_env_section() { + let content = "[env]\nFLINT_CONFIG_DIR = \".github/config\"\n"; + assert_eq!( + get_existing_config_dir(content), + Some(".github/config".to_string()) + ); + } + + #[test] + fn get_existing_config_dir_absent() { + let content = "[tools]\nrust = \"latest\"\n"; + assert_eq!(get_existing_config_dir(content), None); + } + + #[test] + fn generate_flint_toml_writes_skeleton() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = tmp.path().join("config"); + let written = generate_flint_toml(&dir, "main", false).unwrap(); + assert!(written); + let content = std::fs::read_to_string(dir.join("flint.toml")).unwrap(); + assert!(content.contains("[settings]")); + assert!(content.contains("# exclude =")); + assert!(content.contains("# exclude_paths =")); + assert!(!content.contains("base_branch")); // "main" is the default, omitted + } + + #[test] + fn generate_flint_toml_non_main_branch() { + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_flint_toml(tmp.path(), "master", false).unwrap(); + assert!(written); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert!(content.contains("base_branch = \"master\"")); + } + + #[test] + fn generate_flint_toml_with_renovate() { + let tmp = tempfile::TempDir::new().unwrap(); + generate_flint_toml(tmp.path(), "main", true).unwrap(); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert!(content.contains("[checks.renovate-deps]")); + assert!(content.contains("# exclude_managers =")); + } + + #[test] + fn generate_flint_toml_skips_existing() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("flint.toml"), "existing content").unwrap(); + let written = generate_flint_toml(tmp.path(), "main", false).unwrap(); + assert!(!written); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert_eq!(content, "existing content"); + } + + #[test] + fn generate_lint_workflow_writes_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_lint_workflow(tmp.path(), "main").unwrap(); + assert!(written); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert!(content.contains("branches: [main]")); + assert!(content.contains("mise run lint")); + assert!(content.contains("fetch-depth: 0")); + assert!(content.contains("persist-credentials: false")); + assert!(content.contains("mise-action")); + assert!(content.contains("github.token")); + } + + #[test] + fn generate_lint_workflow_non_main_branch() { + let tmp = tempfile::TempDir::new().unwrap(); + generate_lint_workflow(tmp.path(), "master").unwrap(); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert!(content.contains("branches: [master]")); + } + + #[test] + fn generate_lint_workflow_skips_existing() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join(".github/workflows")).unwrap(); + std::fs::write( + tmp.path().join(".github/workflows/lint.yml"), + "existing content", + ) + .unwrap(); + let written = generate_lint_workflow(tmp.path(), "main").unwrap(); + assert!(!written); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert_eq!(content, "existing content"); + } + + #[test] + fn apply_env_and_tasks_adds_sections() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "[tools]\nrust = \"latest\"\n").unwrap(); + let changed = apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + assert!(changed); + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("FLINT_CONFIG_DIR = \".github/config\"")); + assert!(content.contains("flint run")); + assert!(content.contains("flint run --fix")); + assert!(!content.contains("--fast-only")); // no slow linters + assert!(content.contains("setup:pre-commit-hook")); + } + + #[test] + fn apply_env_and_tasks_adds_pre_commit_task_when_slow() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "").unwrap(); + apply_env_and_tasks(tmp.path(), ".", true).unwrap(); + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("--fast-only")); + assert!(content.contains("lint:pre-commit")); + // Hook task should point to lint:pre-commit + assert!(content.contains("--task=lint:pre-commit")); + } + + #[test] + fn apply_env_and_tasks_idempotent() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "").unwrap(); + apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + let after_first = std::fs::read_to_string(tmp.path()).unwrap(); + let changed = apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + assert!(!changed); + let after_second = std::fs::read_to_string(tmp.path()).unwrap(); + assert_eq!(after_first, after_second); + } } From 0a19bdb5a87ee2ef0113eff1bfb26ae0b435f953 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 07:48:17 +0000 Subject: [PATCH 081/141] feat(run): add --time flag to show linter runtimes Show elapsed time for every linter that runs, not just failing ones. Timing appears as a suffix on the check header line (e.g. [shellcheck] 42ms). Test harness normalises timing values to Xms so snapshots are stable. --- Cargo.toml | 1 + src/main.rs | 7 ++++ src/runner.rs | 37 +++++++++++++------ .../time-flag/files/.github/workflows/ci.yml | 6 +++ tests/cases/general/time-flag/files/good.sh | 2 + tests/cases/general/time-flag/files/mise.toml | 3 ++ tests/cases/general/time-flag/test.toml | 21 +++++++++++ tests/e2e.rs | 20 ++++++++-- 8 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 tests/cases/general/time-flag/files/.github/workflows/ci.yml create mode 100644 tests/cases/general/time-flag/files/good.sh create mode 100644 tests/cases/general/time-flag/files/mise.toml create mode 100644 tests/cases/general/time-flag/test.toml diff --git a/Cargo.toml b/Cargo.toml index c137be6..e85c66c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ toml_edit = "0.22" [dev-dependencies] tempfile = "3" +regex = "1" diff --git a/src/main.rs b/src/main.rs index b80daf9..a51c042 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,10 @@ struct RunArgs { #[arg(long, value_name = "REF", env = "FLINT_TO_REF")] to_ref: Option, + /// Show how long each linter took to run. + #[arg(long, env = "FLINT_TIME")] + time: bool, + /// Linters to run (default: all discovered). Explicit linters override --fast-only. linters: Vec, } @@ -189,6 +193,7 @@ async fn run( fix: false, verbose: false, short: true, + time: false, }, project_root, &cfg, @@ -217,6 +222,7 @@ async fn run( fix: true, verbose: false, short: true, + time: false, }, project_root, &cfg, @@ -276,6 +282,7 @@ async fn run( fix: false, verbose: args.verbose, short: args.short, + time: args.time, }, project_root, &cfg, diff --git a/src/runner.rs b/src/runner.rs index fea7956..0c404fb 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,6 +1,7 @@ use anyhow::Result; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::time::{Duration, Instant}; use tokio::process::Command; use tokio::task::JoinSet; @@ -13,6 +14,7 @@ pub struct RunOptions { pub fix: bool, pub verbose: bool, pub short: bool, + pub time: bool, } pub struct CheckResult { @@ -20,6 +22,7 @@ pub struct CheckResult { pub ok: bool, pub stdout: Vec, pub stderr: Vec, + pub duration: Duration, } /// A check with all inputs pre-resolved, ready to execute without borrowing @@ -58,6 +61,7 @@ impl PreparedCheck { async fn execute(self, fix: bool, project_root: &Path) -> CheckResult { let name = self.name().to_string(); + let start = Instant::now(); let out: LinterOutput = match self { Self::Invocations { argv_list, .. } => { run_invocations(&name, &argv_list, project_root).await @@ -78,6 +82,7 @@ impl PreparedCheck { ok: out.ok, stdout: out.stdout, stderr: out.stderr, + duration: start.elapsed(), } } } @@ -94,6 +99,7 @@ pub async fn run( fix, verbose, short, + time, } = opts; let prepared: Vec = checks .iter() @@ -105,7 +111,7 @@ pub async fn run( for task in prepared { let r = task.execute(fix, project_root).await; if !short && (verbose || !r.ok) { - eprintln!("[{}]", r.name); + eprintln!("[{}]{}", r.name, format_duration_suffix(time, r.duration)); flush_output(&r.stdout, &r.stderr); } results.push(r); @@ -116,14 +122,7 @@ pub async fn run( let mut set: JoinSet = JoinSet::new(); for task in prepared { let root = project_root.to_path_buf(); - set.spawn(async move { - let r = task.execute(false, &root).await; - if verbose { - eprintln!("[{}]", r.name); - flush_output(&r.stdout, &r.stderr); - } - r - }); + set.spawn(async move { task.execute(false, &root).await }); } // Collect all results before printing to avoid interleaved output. @@ -134,10 +133,12 @@ pub async fn run( } collected.sort_by(|a, b| a.name.cmp(&b.name)); - if !verbose && !short { + if !short { for r in &collected { - if !r.ok { - eprintln!("[{}]", r.name); + if verbose || !r.ok || time { + eprintln!("[{}]{}", r.name, format_duration_suffix(time, r.duration)); + } + if verbose || !r.ok { flush_output(&r.stdout, &r.stderr); } } @@ -411,6 +412,18 @@ fn missing_rust_component(name: &str, stderr: &[u8]) -> Option<&'static str> { } } +fn format_duration_suffix(time: bool, duration: Duration) -> String { + if !time { + return String::new(); + } + let ms = duration.as_millis(); + if ms < 1000 { + format!(" {ms}ms") + } else { + format!(" {:.1}s", duration.as_secs_f64()) + } +} + fn flush_output(stdout: &[u8], stderr: &[u8]) { // All tool output goes to stderr so headers and diagnostics stay on the // same stream — callers (humans and AI alike) see a coherent sequence. diff --git a/tests/cases/general/time-flag/files/.github/workflows/ci.yml b/tests/cases/general/time-flag/files/.github/workflows/ci.yml new file mode 100644 index 0000000..adecccf --- /dev/null +++ b/tests/cases/general/time-flag/files/.github/workflows/ci.yml @@ -0,0 +1,6 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hello diff --git a/tests/cases/general/time-flag/files/good.sh b/tests/cases/general/time-flag/files/good.sh new file mode 100644 index 0000000..ccc95ec --- /dev/null +++ b/tests/cases/general/time-flag/files/good.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "$1" diff --git a/tests/cases/general/time-flag/files/mise.toml b/tests/cases/general/time-flag/files/mise.toml new file mode 100644 index 0000000..a27736f --- /dev/null +++ b/tests/cases/general/time-flag/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +shellcheck = "latest" +actionlint = "latest" diff --git a/tests/cases/general/time-flag/test.toml b/tests/cases/general/time-flag/test.toml new file mode 100644 index 0000000..f97c271 --- /dev/null +++ b/tests/cases/general/time-flag/test.toml @@ -0,0 +1,21 @@ +[expected] +args = "run --full --time shellcheck actionlint" +exit = 1 +stderr = ''' +[actionlint] Xms +workflow error: undefined variable +[shellcheck] Xms + +flint: 1 check failed (actionlint) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' +[fake_bins] +actionlint = ''' +#!/bin/sh +printf 'workflow error: undefined variable\n' +exit 1 +''' +shellcheck = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/e2e.rs b/tests/e2e.rs index 51dbd60..18d757f 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -220,10 +220,12 @@ fn run_case(case: &Path, name: &str, update: bool) { let out = flint_with_env(&args, repo.path(), &env_refs); let repo_str = repo.path().to_string_lossy(); - let stderr = - strip_ansi(&String::from_utf8_lossy(&out.stderr).replace(repo_str.as_ref(), "")); - let stdout = - strip_ansi(&String::from_utf8_lossy(&out.stdout).replace(repo_str.as_ref(), "")); + let stderr = normalize_timing(&strip_ansi( + &String::from_utf8_lossy(&out.stderr).replace(repo_str.as_ref(), ""), + )); + let stdout = normalize_timing(&strip_ansi( + &String::from_utf8_lossy(&out.stdout).replace(repo_str.as_ref(), ""), + )); if update { write_test_toml( @@ -325,6 +327,16 @@ fn toml_escape(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } +/// Normalises timing suffixes on check header lines so snapshots are stable. +/// `[name] 123ms` and `[name] 1.2s` both become `[name] Xms`. +fn normalize_timing(s: &str) -> String { + use regex::Regex; + // Match the timing suffix at the end of a check header line. + // Header lines start with "[" and end with " ms" or " .s". + let re = Regex::new(r"(?m)^(\[[^\]]+\]) \d+(?:\.\d+)?(?:ms|s)$").unwrap(); + re.replace_all(s, "$1 Xms").into_owned() +} + /// Strips ANSI/VT escape sequences (colour codes, character-set switches, etc.). /// TOML strings cannot contain raw control characters, so these must be removed. fn strip_ansi(s: &str) -> String { From 2c1fb343faf10512533abafee134201f12ccb644 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 08:36:44 +0000 Subject: [PATCH 082/141] perf(renovate-deps,cargo-fmt): skip version lookups, fix duplicate output renovate-deps: pass --dry-run=extract so Renovate only parses dependency files without hitting registries. This avoids all network calls, making the check fast enough to remove the slow() marker. The log message and JSON key changed in extract mode ("Extracted dependencies" / packageFiles instead of "packageFiles with updates" / config). cargo-fmt: switch from Scope::Files (rustfmt per changed file) to Scope::Project (cargo fmt -- --check). Per-file rustfmt followed mod declarations from root files, causing the same diff to appear multiple times when both a module root and its leaf were in the changed-file list. cargo fmt always formats the whole crate, so each file appears once. tests: renovate-deps fixtures now use the real renovate binary (fake_bins removed); package.json files have real deps so renovate finds something. The test harness commits the initial files (enabling changed-files mode tests) and supports a changes/ directory for staged-but-not-committed diffs. A new cargo-fmt/changed-files-no-duplicate fixture exercises the regression path with both a module root and leaf in the staged diff. --- README.md | 6 ++--- src/linters/renovate_deps.rs | 18 +++++++------ src/registry.rs | 21 ++++++---------- .../changes/src/main.rs | 2 ++ .../changes/src/math.rs | 1 + .../files/Cargo.toml | 4 +++ .../files/mise.toml | 2 ++ .../files/src/main.rs | 2 ++ .../files/src/math.rs | 3 +++ .../changed-files-no-duplicate/test.toml | 25 +++++++++++++++++++ .../fast-only-explicit-override/test.toml | 5 ++-- tests/cases/general/list/test.toml | 2 +- .../fix-create/files/package.json | 2 +- .../cases/renovate-deps/fix-create/test.toml | 12 ++++----- .../fix-update/files/package.json | 2 +- .../cases/renovate-deps/fix-update/test.toml | 12 ++++----- .../out-of-date/files/package.json | 2 +- .../cases/renovate-deps/out-of-date/test.toml | 14 +++++------ .../files/package.json | 2 +- .../files/renovate-tracked-deps.json | 5 ++++ .../up-to-date-renovaterc-json/test.toml | 6 ----- .../files/.github/renovate-tracked-deps.json | 5 ++++ .../up-to-date/files/package.json | 2 +- .../cases/renovate-deps/up-to-date/test.toml | 6 ----- tests/e2e.rs | 22 ++++++++++++++-- 25 files changed, 117 insertions(+), 66 deletions(-) create mode 100644 tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/main.rs create mode 100644 tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/math.rs create mode 100644 tests/cases/cargo-fmt/changed-files-no-duplicate/files/Cargo.toml create mode 100644 tests/cases/cargo-fmt/changed-files-no-duplicate/files/mise.toml create mode 100644 tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/main.rs create mode 100644 tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/math.rs create mode 100644 tests/cases/cargo-fmt/changed-files-no-duplicate/test.toml diff --git a/README.md b/README.md index 9f82d83..9339641 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,6 @@ being linted and cannot be redirected via a flag. - | Name | Binary | Patterns | Fix | Slow | Scope | Config file | Notes | | ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | --------------------------------------------- | | `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | — | @@ -238,15 +237,14 @@ being linted and cannot be redirected via a flag. | `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | | `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | | `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | lints all .rs files, not just changed | -| `cargo-fmt` | `rustfmt` | `*.rs` | yes | — | files | — | — | +| `cargo-fmt` | `rustfmt` | `*.rs` | yes | — | project | — | formats all .rs files, not just changed | | `gofmt` | `gofmt` | `*.go` | yes | — | file | — | — | | `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | — | | `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | — | | `dotnet-format` | `dotnet` | `*.cs` | yes | — | files | — | — | | `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | — | -| `renovate-deps` | `renovate` | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | yes | yes | special | — | — | +| `renovate-deps` | `renovate` | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | yes | — | special | — | — | | `license-header` | (built-in) | (all files) | no | — | special | — | — | - diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 2e9f1bd..3b077e7 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -17,7 +17,7 @@ pub(crate) const RENOVATE_CONFIG_PATTERNS: &[&str] = &[ ".renovaterc.json", ".renovaterc.json5", ]; -const PACKAGE_FILES_MSG: &str = "packageFiles with updates"; +const PACKAGE_FILES_MSG: &str = "Extracted dependencies"; const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-version"]; /// `{file_path: {manager: [dep_name, ...]}}` — all collections sorted. @@ -112,7 +112,11 @@ async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result } let out = Command::new("renovate") - .args(["--platform=local", "--require-config=ignored"]) + .args([ + "--platform=local", + "--require-config=ignored", + "--dry-run=extract", + ]) .current_dir(project_root) .envs(env) .stdin(Stdio::null()) @@ -179,7 +183,7 @@ fn extract_deps(log_bytes: &[u8], exclude_managers: &[String]) -> anyhow::Result continue; }; if entry.get("msg").and_then(|v| v.as_str()) == Some(PACKAGE_FILES_MSG) { - config_obj = entry.get("config").cloned(); + config_obj = entry.get("packageFiles").cloned(); } } @@ -258,7 +262,7 @@ mod tests { use super::*; fn log(config_json: &str) -> Vec { - format!(r#"{{"msg":"packageFiles with updates","config":{config_json}}}"#).into_bytes() + format!(r#"{{"msg":"Extracted dependencies","packageFiles":{config_json}}}"#).into_bytes() } fn dep_map(entries: &[(&str, &[(&str, &[&str])])]) -> DepMap { @@ -344,8 +348,8 @@ mod tests { fn last_package_files_message_wins() { let bytes = format!( "{}\n{}\n", - r#"{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"a.json","deps":[{"depName":"old"}]}]}}"#, - r#"{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"b.json","deps":[{"depName":"new"}]}]}}"#, + r#"{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"a.json","deps":[{"depName":"old"}]}]}}"#, + r#"{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"b.json","deps":[{"depName":"new"}]}]}}"#, ) .into_bytes(); let result = extract_deps(&bytes, &[]).unwrap(); @@ -356,7 +360,7 @@ mod tests { #[test] fn non_json_lines_are_skipped() { let bytes = - b"not json\n{\"msg\":\"packageFiles with updates\",\"config\":{\"npm\":[{\"packageFile\":\"p.json\",\"deps\":[{\"depName\":\"x\"}]}]}}\nmore garbage\n"; + b"not json\n{\"msg\":\"Extracted dependencies\",\"packageFiles\":{\"npm\":[{\"packageFile\":\"p.json\",\"deps\":[{\"depName\":\"x\"}]}]}}\nmore garbage\n"; let result = extract_deps(bytes, &[]).unwrap(); assert!(result.contains_key("p.json")); } diff --git a/src/registry.rs b/src/registry.rs index 344d815..c560de2 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -425,18 +425,14 @@ pub fn builtin() -> Vec { .install_components("clippy") .lang() .note("lints all .rs files, not just changed"), - Check::files( - "cargo-fmt", - "rustfmt {CARGO_EDITION_FLAG} --check {FILES}", - &["*.rs"], - ) - .fix("rustfmt {CARGO_EDITION_FLAG} {FILES}") - .full_cmd("cargo fmt -- --check", "cargo fmt") - .bin("rustfmt") - .mise_tool("rust") - .install_components("rustfmt") - .formatter() - .lang(), + Check::project("cargo-fmt", "cargo fmt -- --check", &["*.rs"]) + .fix("cargo fmt") + .bin("rustfmt") + .mise_tool("rust") + .install_components("rustfmt") + .formatter() + .lang() + .note("formats all .rs files, not just changed"), Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) .fix("gofmt -w {FILE}") .mise_tool("go") @@ -483,7 +479,6 @@ pub fn builtin() -> Vec { Check::special("lychee", "lychee", SpecialKind::Links), Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) .mise_tool("npm:renovate") - .slow() .patterns(RENOVATE_CONFIG_PATTERNS), Check::special( "license-header", diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/main.rs b/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/main.rs new file mode 100644 index 0000000..5510b5f --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/main.rs @@ -0,0 +1,2 @@ +mod math; +fn main() { math::add(1, 2); } diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/math.rs b/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/math.rs new file mode 100644 index 0000000..a051497 --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/math.rs @@ -0,0 +1 @@ +pub fn add(a: i32, b: i32) -> i32 { a + b } diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/files/Cargo.toml b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/files/mise.toml b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/main.rs b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/main.rs new file mode 100644 index 0000000..99dd7a7 --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/main.rs @@ -0,0 +1,2 @@ +mod math; +fn main() {} diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/math.rs b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/math.rs new file mode 100644 index 0000000..b4a2a9e --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/math.rs @@ -0,0 +1,3 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/test.toml b/tests/cases/cargo-fmt/changed-files-no-duplicate/test.toml new file mode 100644 index 0000000..6bc975f --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/test.toml @@ -0,0 +1,25 @@ +# Regression: with multiple changed .rs files (including a module root), each +# formatting issue must appear exactly once. The old per-file rustfmt approach +# would output math.rs twice: once via `mod math` in main.rs, once directly. +[expected] +args = "run cargo-fmt" +exit = 1 +stderr = ''' +[cargo-fmt] +Diff in /src/main.rs:1: + mod math; +-fn main() { math::add(1, 2); } ++fn main() { ++ math::add(1, 2); ++} + +Diff in /src/math.rs:1: +-pub fn add(a: i32, b: i32) -> i32 { a + b } ++pub fn add(a: i32, b: i32) -> i32 { ++ a + b ++} + + +flint: 1 check failed (cargo-fmt) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/general/fast-only-explicit-override/test.toml b/tests/cases/general/fast-only-explicit-override/test.toml index fd1cb77..9a27bc8 100644 --- a/tests/cases/general/fast-only-explicit-override/test.toml +++ b/tests/cases/general/fast-only-explicit-override/test.toml @@ -1,6 +1,5 @@ # --fast-only is overridden when linters are named explicitly. -# renovate-deps is marked slow, so it would be skipped by --fast-only without explicit naming. -# Naming it explicitly must run it regardless. +# Naming a linter explicitly must run it regardless of --fast-only. [expected] args = "run --full --fast-only renovate-deps" exit = 0 @@ -8,5 +7,5 @@ exit = 0 [fake_bins] renovate = ''' #!/bin/sh -printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' ''' diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index 9d62f93..8c0e2b7 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -24,7 +24,7 @@ google-java-format google-java-format missing fast *.java ktlint ktlint missing fast *.kt *.kts dotnet-format dotnet missing fast *.cs lychee lychee active fast -renovate-deps renovate active slow renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5 +renovate-deps renovate active fast renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5 license-header license-header active fast ''' [fake_bins] diff --git a/tests/cases/renovate-deps/fix-create/files/package.json b/tests/cases/renovate-deps/fix-create/files/package.json index 9e26dfe..19f0753 100644 --- a/tests/cases/renovate-deps/fix-create/files/package.json +++ b/tests/cases/renovate-deps/fix-create/files/package.json @@ -1 +1 @@ -{} \ No newline at end of file +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/fix-create/test.toml b/tests/cases/renovate-deps/fix-create/test.toml index 6c340f0..ad50864 100644 --- a/tests/cases/renovate-deps/fix-create/test.toml +++ b/tests/cases/renovate-deps/fix-create/test.toml @@ -8,6 +8,11 @@ flint: fixed: renovate-deps — commit before pushing [expected.files] ".github/renovate-tracked-deps.json" = """ { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, "package.json": { "npm": [ "express", @@ -15,9 +20,4 @@ flint: fixed: renovate-deps — commit before pushing ] } } -""" -[fake_bins] -renovate = ''' -#!/bin/sh -printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' -''' +""" \ No newline at end of file diff --git a/tests/cases/renovate-deps/fix-update/files/package.json b/tests/cases/renovate-deps/fix-update/files/package.json index 9e26dfe..19f0753 100644 --- a/tests/cases/renovate-deps/fix-update/files/package.json +++ b/tests/cases/renovate-deps/fix-update/files/package.json @@ -1 +1 @@ -{} \ No newline at end of file +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/fix-update/test.toml b/tests/cases/renovate-deps/fix-update/test.toml index 6c340f0..ad50864 100644 --- a/tests/cases/renovate-deps/fix-update/test.toml +++ b/tests/cases/renovate-deps/fix-update/test.toml @@ -8,6 +8,11 @@ flint: fixed: renovate-deps — commit before pushing [expected.files] ".github/renovate-tracked-deps.json" = """ { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, "package.json": { "npm": [ "express", @@ -15,9 +20,4 @@ flint: fixed: renovate-deps — commit before pushing ] } } -""" -[fake_bins] -renovate = ''' -#!/bin/sh -printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' -''' +""" \ No newline at end of file diff --git a/tests/cases/renovate-deps/out-of-date/files/package.json b/tests/cases/renovate-deps/out-of-date/files/package.json index 9e26dfe..19f0753 100644 --- a/tests/cases/renovate-deps/out-of-date/files/package.json +++ b/tests/cases/renovate-deps/out-of-date/files/package.json @@ -1 +1 @@ -{} \ No newline at end of file +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/out-of-date/test.toml b/tests/cases/renovate-deps/out-of-date/test.toml index a6d89bd..4561872 100644 --- a/tests/cases/renovate-deps/out-of-date/test.toml +++ b/tests/cases/renovate-deps/out-of-date/test.toml @@ -5,8 +5,13 @@ stderr = ''' [renovate-deps] --- .github/renovate-tracked-deps.json +++ generated -@@ -1,7 +1,8 @@ +@@ -1,7 +1,13 @@ { ++ "mise.toml": { ++ "mise": [ ++ "npm:renovate" ++ ] ++ }, "package.json": { "npm": [ - "old-dep" @@ -31,9 +36,4 @@ flint: 1 check failed (renovate-deps) ] } } -""" -[fake_bins] -renovate = ''' -#!/bin/sh -printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' -''' +""" \ No newline at end of file diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json index 0967ef4..19f0753 100644 --- a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json @@ -1 +1 @@ -{} +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json index b46b339..41f0847 100644 --- a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json @@ -1,4 +1,9 @@ { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, "package.json": { "npm": [ "express", diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml b/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml index 05e0a36..d8b049b 100644 --- a/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml @@ -1,9 +1,3 @@ [expected] args = "run --full renovate-deps" exit = 0 - -[fake_bins] -renovate = ''' -#!/bin/sh -printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' -''' diff --git a/tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json index b46b339..41f0847 100644 --- a/tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json +++ b/tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json @@ -1,4 +1,9 @@ { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, "package.json": { "npm": [ "express", diff --git a/tests/cases/renovate-deps/up-to-date/files/package.json b/tests/cases/renovate-deps/up-to-date/files/package.json index 9e26dfe..19f0753 100644 --- a/tests/cases/renovate-deps/up-to-date/files/package.json +++ b/tests/cases/renovate-deps/up-to-date/files/package.json @@ -1 +1 @@ -{} \ No newline at end of file +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/up-to-date/test.toml b/tests/cases/renovate-deps/up-to-date/test.toml index 05e0a36..d8b049b 100644 --- a/tests/cases/renovate-deps/up-to-date/test.toml +++ b/tests/cases/renovate-deps/up-to-date/test.toml @@ -1,9 +1,3 @@ [expected] args = "run --full renovate-deps" exit = 0 - -[fake_bins] -renovate = ''' -#!/bin/sh -printf '%s\n' '{"msg":"packageFiles with updates","config":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' -''' diff --git a/tests/e2e.rs b/tests/e2e.rs index 18d757f..3d93b85 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -22,11 +22,11 @@ fn flint_with_env(args: &[&str], cwd: &Path, env: &[(&str, &str)]) -> Output { cmd.output().expect("failed to spawn flint") } -/// Creates a temp directory initialised as a git repo. +/// Creates a temp directory initialised as a git repo with branch `main`. fn git_repo() -> TempDir { let dir = tempfile::tempdir().expect("tempdir"); for args in [ - vec!["init"], + vec!["init", "-b", "main"], vec!["config", "user.email", "test@test.com"], vec!["config", "user.name", "Test"], ] { @@ -193,6 +193,24 @@ fn run_case(case: &Path, name: &str, update: bool) { .current_dir(repo.path()) .output() .expect("git add failed"); + Command::new("git") + .args(["commit", "-q", "-m", "init"]) + .current_dir(repo.path()) + .output() + .expect("git commit failed"); + + // If a `changes/` directory exists alongside `files/`, write those files + // over the repo and stage them (but don't commit). This lets fixtures test + // the changed-files code path (as opposed to --full / all-files mode). + let changes_dir = case.join("changes"); + if changes_dir.exists() { + copy_dir_into(&changes_dir, repo.path()); + Command::new("git") + .args(["add", "-A"]) + .current_dir(repo.path()) + .output() + .expect("git add changes failed"); + } let env_vars: Vec<(String, String)> = cfg .get("env") From fb64e9bef4597a55118ceaa10961f139b78dd8be Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 10:02:30 +0000 Subject: [PATCH 083/141] chore: regenerate README table --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9339641..bc8a83f 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ being linted and cannot be redirected via a flag. + | Name | Binary | Patterns | Fix | Slow | Scope | Config file | Notes | | ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | --------------------------------------------- | | `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | — | @@ -245,6 +246,7 @@ being linted and cannot be redirected via a flag. | `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | — | | `renovate-deps` | `renovate` | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | yes | — | special | — | — | | `license-header` | (built-in) | (all files) | no | — | special | — | — | + From 1d54b0d440b933eb33a382981667ca6615525225 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 10:19:50 +0000 Subject: [PATCH 084/141] refactor(init): split init.rs into init/ module --- src/init.rs | 1308 ---------------------------------------- src/init/detection.rs | 148 +++++ src/init/generation.rs | 342 +++++++++++ src/init/mod.rs | 606 +++++++++++++++++++ src/init/ui.rs | 252 ++++++++ 5 files changed, 1348 insertions(+), 1308 deletions(-) delete mode 100644 src/init.rs create mode 100644 src/init/detection.rs create mode 100644 src/init/generation.rs create mode 100644 src/init/mod.rs create mode 100644 src/init/ui.rs diff --git a/src/init.rs b/src/init.rs deleted file mode 100644 index 65219e0..0000000 --- a/src/init.rs +++ /dev/null @@ -1,1308 +0,0 @@ -use anyhow::{Context, Result}; -use std::collections::{HashMap, HashSet}; -use std::io::{self, BufRead, Write}; -use std::path::Path; -use std::process::Command; - -use crossterm::{ - cursor, - event::{self, Event, KeyCode, KeyModifiers}, - execute, - terminal::{self, ClearType}, -}; - -use crate::registry::{Category, Check, builtin}; - -/// Linter profile — shorthand for `--profile` CLI flag; maps to a category set. -#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] -pub enum Profile { - /// Primary language linters only (ruff, cargo-clippy, golangci-lint, …). - Lang, - /// Lang + supplementary checks + fast general tools (shellcheck, prettier, codespell, …). - Default, - /// Default + slow linters (renovate-deps). - Comprehensive, -} - -fn profile_to_categories(profile: Profile) -> HashSet { - match profile { - Profile::Lang => [Category::Lang].into(), - Profile::Default => [Category::Lang, Category::Style, Category::Default].into(), - Profile::Comprehensive => [ - Category::Lang, - Category::Style, - Category::Default, - Category::Slow, - ] - .into(), - } -} - -/// Desired tools for a profile: maps each mise tool key to its optional components string. -#[cfg(test)] -type DesiredTools = HashMap>; - -// One entry per install key — groups all checks sharing that key. -struct LinterGroup<'a> { - key: &'static str, - checks: Vec<&'a Check>, // sorted by name - check_selected: Vec, // parallel to checks - installed: bool, - current_components: Option, -} - -impl LinterGroup<'_> { - fn any_selected(&self) -> bool { - self.check_selected.iter().any(|&s| s) - } - - /// Components string to write for the currently selected checks, e.g. `"clippy,rustfmt"`. - /// Returns `None` when no selected check carries a component requirement. - fn selected_components(&self) -> Option { - let comps: Vec<&'static str> = self - .checks - .iter() - .zip(&self.check_selected) - .filter_map(|(c, &sel)| if sel { c.mise_install_components } else { None }) - .collect(); - if comps.is_empty() { - None - } else { - Some(comps.join(",")) - } - } - - fn action(&self) -> &'static str { - if self.any_selected() { - if !self.installed { - "add" - } else if self.selected_components() != self.current_components { - "upgrade" - } else { - "keep" - } - } else if self.installed { - "remove" - } else { - "" - } - } -} - -// --- Category selection (step 1) --- - -struct CategoryItem { - selected: bool, - category: Category, - label: &'static str, -} - -fn default_category_items() -> Vec { - vec![ - CategoryItem { - selected: true, - category: Category::Lang, - label: "lang — primary language linters (ruff, cargo-clippy, golangci-lint, …)", - }, - CategoryItem { - selected: true, - category: Category::Style, - label: "style — supplementary checks (shellcheck, actionlint, hadolint, …)", - }, - CategoryItem { - selected: true, - category: Category::Default, - label: "general — general tools (codespell, ec, lychee, …)", - }, - CategoryItem { - selected: false, - category: Category::Slow, - label: "slow — slow linters (renovate-deps)", - }, - ] -} - -pub fn run(project_root: &Path, profile_arg: Option, yes: bool) -> Result<()> { - println!( - "Tip: flint init detects languages from tracked files (`git ls-files`). \ -Add and stage your source files before running init so the detection is accurate." - ); - println!(); - - let registry = builtin(); - let present_patterns = detect_present_patterns(project_root, ®istry)?; - - // Step 1: determine which categories set the initial pre-selection. - let default_categories: HashSet = if let Some(profile) = profile_arg { - profile_to_categories(profile) - } else if yes { - profile_to_categories(Profile::Default) - } else { - let mut cat_items = default_category_items(); - if !select_categories_arrow(&mut cat_items)? { - println!("Aborted."); - return Ok(()); - } - cat_items - .iter() - .filter(|i| i.selected) - .map(|i| i.category) - .collect() - }; - - let mise_path = project_root.join("mise.toml"); - let current_content = std::fs::read_to_string(&mise_path).unwrap_or_default(); - let current_tool_keys = parse_tool_keys(¤t_content); - let known_keys: HashSet<&str> = registry.iter().filter_map(install_key).collect(); - - // Step 2: build one group per install key, covering all checks whose files are - // present in the repo or which are already installed. - let mut groups = build_linter_groups( - ®istry, - &present_patterns, - ¤t_tool_keys, - ¤t_content, - &default_categories, - ); - - if groups.is_empty() { - println!("No applicable linters found for this project."); - return Ok(()); - } - - // Step 3: interactive linter table (skipped with --yes). - if !yes && !interactive_select_linters(&mut groups)? { - println!("Aborted."); - return Ok(()); - } - - // Derive changes from final selection state. - let mut final_add: Vec<(String, Option)> = Vec::new(); - let mut final_remove: Vec = Vec::new(); - let mut final_upgrade: Vec<(String, String)> = Vec::new(); - - for group in &groups { - if group.any_selected() { - if !group.installed { - final_add.push((group.key.to_string(), group.selected_components())); - } else { - let target = group.selected_components(); - if target != group.current_components { - // Upgrade: components changed (added, removed, or reordered). - // If the target has no components (e.g. all component-bearing checks - // deselected), treat as a plain-version install via add+remove. - if let Some(comps) = target { - final_upgrade.push((group.key.to_string(), comps)); - } - } - } - } else if group.installed && known_keys.contains(group.key) { - final_remove.push(group.key.to_string()); - } - } - - let has_slow = has_slow_selected(&groups); - let has_renovate = groups.iter().any(|g| { - g.checks - .iter() - .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.name == "renovate-deps") - }); - - // Prompt for the flint config dir (skipped if already set in mise.toml or --yes). - let existing_config_dir = get_existing_config_dir(¤t_content); - let config_dir_rel = prompt_config_dir(existing_config_dir.as_deref(), yes)?; - - let tools_changed = - !final_add.is_empty() || !final_remove.is_empty() || !final_upgrade.is_empty(); - if tools_changed { - apply_changes( - &mise_path, - ¤t_content, - &final_add, - &final_remove, - &final_upgrade, - )?; - } - - let meta_changed = apply_env_and_tasks(&mise_path, &config_dir_rel, has_slow)?; - - let base_branch = detect_base_branch(project_root); - let config_dir_path = project_root.join(&config_dir_rel); - let toml_generated = generate_flint_toml(&config_dir_path, &base_branch, has_renovate)?; - let workflow_generated = generate_lint_workflow(project_root, &base_branch)?; - - if !tools_changed && !meta_changed && !toml_generated && !workflow_generated { - println!("No changes to apply."); - return Ok(()); - } - - let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; - maybe_install_hook(project_root, hook_task, yes)?; - - println!("Done. Run `mise install` to install the new tools."); - Ok(()) -} - -/// Returns the canonical mise.toml tool key to write when installing this check -/// via `flint init`, or `None` if no mise entry is needed (built-in or -/// unconditionally active checks). -/// -/// Preference order: `mise_install_key` → `mise_tool_name` → `bin_name`. -pub fn install_key(check: &Check) -> Option<&'static str> { - if !check.uses_binary() || check.activate_unconditionally { - return None; - } - Some( - check - .mise_install_key - .or(check.mise_tool_name) - .unwrap_or(check.bin_name), - ) -} - -/// Compute the map of `tool_key → optional_components` for the given category set, -/// filtered to file patterns present in the repo. -#[cfg(test)] -fn compute_desired_tools( - registry: &[Check], - present_patterns: &HashSet, - categories: &HashSet, -) -> DesiredTools { - // Collect per-key component lists so multiple checks sharing a key are merged. - let mut by_key: HashMap> = HashMap::new(); - for check in registry { - let key = match install_key(check) { - Some(k) => k, - None => continue, - }; - if !files_present(check, present_patterns) { - continue; - } - if categories.contains(&check.category) { - let entry = by_key.entry(key.to_string()).or_default(); - if let Some(comp) = check.mise_install_components { - if !entry.contains(&comp) { - entry.push(comp); - } - } - } - } - by_key - .into_iter() - .map(|(k, comps)| { - let merged = if comps.is_empty() { - None - } else { - Some(comps.join(",")) - }; - (k, merged) - }) - .collect() -} - -/// Returns `true` if the repo contains at least one file matching any of the -/// check's patterns. Checks with no patterns (project-scope specials like -/// lychee) are always considered present. -fn files_present(check: &Check, present_patterns: &HashSet) -> bool { - check.patterns.is_empty() - || check - .patterns - .iter() - .any(|p| *p == "*" || present_patterns.contains(*p)) -} - -/// Runs `git ls-files -- ` for every unique pattern in the registry -/// and returns the set of patterns that produced at least one result. -fn detect_present_patterns(project_root: &Path, registry: &[Check]) -> Result> { - let all_patterns: HashSet<&str> = registry - .iter() - .flat_map(|c| c.patterns.iter().copied()) - .filter(|p| *p != "*") - .collect(); - - let mut present = HashSet::new(); - for pattern in all_patterns { - let out = Command::new("git") - .args(["ls-files", "--", pattern]) - .current_dir(project_root) - .output() - .context("git ls-files")?; - if !out.stdout.is_empty() { - present.insert(pattern.to_string()); - } - } - Ok(present) -} - -/// Returns the set of keys currently declared in `[tools]`. -fn parse_tool_keys(content: &str) -> HashSet { - let value: toml::Value = match toml::from_str(content) { - Ok(v) => v, - Err(_) => return HashSet::new(), - }; - value - .get("tools") - .and_then(|v| v.as_table()) - .map(|t| t.keys().cloned().collect()) - .unwrap_or_default() -} - -/// Returns `true` if the `[tools]` entry for `key` exists and its `components` -/// field is absent or differs from `required`. Used to detect entries that need -/// upgrading (missing components) or correcting (wrong components). -#[cfg(test)] -fn entry_components_differ(content: &str, key: &str, required: &str) -> bool { - let doc: toml_edit::DocumentMut = match content.parse() { - Ok(d) => d, - Err(_) => return false, - }; - let tools = match doc.get("tools").and_then(|t| t.as_table()) { - Some(t) => t, - None => return false, - }; - match tools.get(key) { - Some(item) => match item.as_value() { - Some(toml_edit::Value::InlineTable(tbl)) => { - tbl.get("components").and_then(|v| v.as_str()) != Some(required) - } - Some(toml_edit::Value::String(_)) => true, - _ => false, - }, - None => false, - } -} - -/// Returns the `components` string currently set for `key` in the `[tools]` section, -/// or `None` if the key is absent, is a plain string entry, or has no `components` field. -fn get_entry_components(content: &str, key: &str) -> Option { - let doc: toml_edit::DocumentMut = content.parse().ok()?; - let tools = doc.get("tools")?.as_table()?; - match tools.get(key)?.as_value()? { - toml_edit::Value::InlineTable(tbl) => tbl.get("components")?.as_str().map(str::to_string), - _ => None, - } -} - -/// Builds one `LinterGroup` per install key, covering all checks whose file patterns -/// are present in the repo or whose key is already installed. -fn build_linter_groups<'a>( - registry: &'a [Check], - present_patterns: &HashSet, - current_tool_keys: &HashSet, - current_content: &str, - default_categories: &HashSet, -) -> Vec> { - let mut by_key: HashMap<&'static str, Vec<&'a Check>> = HashMap::new(); - for check in registry { - let key = match install_key(check) { - Some(k) => k, - None => continue, - }; - if files_present(check, present_patterns) || current_tool_keys.contains(key) { - by_key.entry(key).or_default().push(check); - } - } - - let mut groups: Vec> = by_key - .into_iter() - .map(|(key, mut checks)| { - checks.sort_by_key(|c| c.name); - let installed = current_tool_keys.contains(key); - let current_components = if installed { - get_entry_components(current_content, key) - } else { - None - }; - // Pre-select each check individually: select if its category is in the - // default set and its patterns are present, OR if the key is already installed. - let check_selected: Vec = checks - .iter() - .map(|c| { - let suggested = default_categories.contains(&c.category) - && files_present(c, present_patterns); - suggested || installed - }) - .collect(); - LinterGroup { - key, - checks, - check_selected, - installed, - current_components, - } - }) - .collect(); - - groups.sort_by_key(|g| g.checks.first().map_or(g.key, |c| c.name)); - groups -} - -fn run_arrow_selector( - items: &mut [T], - print_fn: fn(&mut dyn Write, &[T], usize) -> Result, - toggle_fn: fn(&mut T), -) -> Result { - let mut cursor = 0usize; - terminal::enable_raw_mode()?; - let result = (|| -> Result { - let mut stdout = io::stdout(); - let mut n_lines = print_fn(&mut stdout, items, cursor)?; - loop { - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Up if cursor > 0 => cursor -= 1, - KeyCode::Down if cursor + 1 < items.len() => cursor += 1, - KeyCode::Char(' ') => toggle_fn(&mut items[cursor]), - KeyCode::Enter => { - execute!( - stdout, - cursor::MoveUp(n_lines as u16), - terminal::Clear(ClearType::FromCursorDown) - )?; - return Ok(true); - } - KeyCode::Char('q') | KeyCode::Esc => { - execute!( - stdout, - cursor::MoveUp(n_lines as u16), - terminal::Clear(ClearType::FromCursorDown) - )?; - return Ok(false); - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - execute!( - stdout, - cursor::MoveUp(n_lines as u16), - terminal::Clear(ClearType::FromCursorDown) - )?; - return Ok(false); - } - _ => continue, - } - execute!( - stdout, - cursor::MoveUp(n_lines as u16), - terminal::Clear(ClearType::FromCursorDown) - )?; - n_lines = print_fn(&mut stdout, items, cursor)?; - } - } - })(); - let _ = terminal::disable_raw_mode(); - println!(); - result -} - -// --- Step 1: category selection --- - -fn select_categories_arrow(items: &mut [CategoryItem]) -> Result { - run_arrow_selector(items, print_cat_selector, |item| { - item.selected = !item.selected - }) -} - -fn print_cat_selector( - stdout: &mut dyn Write, - items: &[CategoryItem], - cursor: usize, -) -> Result { - let mut lines = 0usize; - write!(stdout, "Select categories:\r\n\r\n")?; - lines += 2; - for (i, item) in items.iter().enumerate() { - let arrow = if i == cursor { ">" } else { " " }; - let sel = if item.selected { "āœ“" } else { " " }; - write!(stdout, " {} [{}] {}\r\n", arrow, sel, item.label)?; - lines += 1; - } - write!( - stdout, - "\r\n ↑↓ navigate space toggle enter continue q abort\r\n" - )?; - lines += 2; - stdout.flush()?; - Ok(lines) -} - -// --- Step 2: linter table selection --- - -/// Maps a flat row index (across all checks in all groups) to `(group_idx, check_idx)`. -fn flat_to_group_check(groups: &[LinterGroup], flat: usize) -> (usize, usize) { - let mut remaining = flat; - for (gi, group) in groups.iter().enumerate() { - if remaining < group.checks.len() { - return (gi, remaining); - } - remaining -= group.checks.len(); - } - (0, 0) -} - -fn interactive_select_linters(groups: &mut Vec) -> Result { - let total_rows = |gs: &[LinterGroup]| gs.iter().map(|g| g.checks.len()).sum::(); - let mut cursor = 0usize; - terminal::enable_raw_mode()?; - let result = (|| -> Result { - let mut stdout = io::stdout(); - let mut n_lines = print_linter_table(&mut stdout, groups, cursor)?; - loop { - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Up if cursor > 0 => cursor -= 1, - KeyCode::Down if cursor + 1 < total_rows(groups) => cursor += 1, - KeyCode::Char(' ') => { - let (gi, ci) = flat_to_group_check(groups, cursor); - groups[gi].check_selected[ci] = !groups[gi].check_selected[ci]; - } - KeyCode::Enter => { - execute!( - stdout, - cursor::MoveUp(n_lines as u16), - terminal::Clear(ClearType::FromCursorDown) - )?; - return Ok(true); - } - KeyCode::Char('q') | KeyCode::Esc => { - execute!( - stdout, - cursor::MoveUp(n_lines as u16), - terminal::Clear(ClearType::FromCursorDown) - )?; - return Ok(false); - } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - execute!( - stdout, - cursor::MoveUp(n_lines as u16), - terminal::Clear(ClearType::FromCursorDown) - )?; - return Ok(false); - } - _ => continue, - } - execute!( - stdout, - cursor::MoveUp(n_lines as u16), - terminal::Clear(ClearType::FromCursorDown) - )?; - n_lines = print_linter_table(&mut stdout, groups, cursor)?; - } - } - })(); - let _ = terminal::disable_raw_mode(); - println!(); - result -} - -fn print_linter_table( - stdout: &mut dyn Write, - groups: &[LinterGroup], - cursor: usize, -) -> Result { - let name_w = groups - .iter() - .flat_map(|g| &g.checks) - .map(|c| c.name.len()) - .max() - .unwrap_or(4) - .max(4); - let bin_w = groups - .iter() - .flat_map(|g| &g.checks) - .map(|c| c.bin_name.len()) - .max() - .unwrap_or(6) - .max(6); - - let mut lines = 0usize; - write!( - stdout, - " {:<5} {:" } else { " " }; - let speed = if check.category == Category::Slow { - "slow" - } else { - "fast" - }; - let patterns = check.patterns.join(" "); - write!( - stdout, - " {} {} {:)], - to_remove: &[String], - to_upgrade: &[(String, String)], -) -> Result<()> { - let mut doc: toml_edit::DocumentMut = current_content - .parse() - .unwrap_or_else(|_| toml_edit::DocumentMut::new()); - - // Ensure [tools] table exists. - if !doc.contains_key("tools") { - doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new())); - } - let tools = doc["tools"] - .as_table_mut() - .context("[tools] is not a table")?; - - for key in to_remove { - tools.remove(key.as_str()); - } - - for (key, components) in to_add { - match components { - Some(comps) => { - let mut tbl = toml_edit::InlineTable::new(); - tbl.insert("version", toml_edit::Value::from("latest")); - tbl.insert("components", toml_edit::Value::from(comps.as_str())); - tools.insert( - key.as_str(), - toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), - ); - } - None => { - tools.insert(key.as_str(), toml_edit::value("latest")); - } - } - } - - // Upgrade existing entries: preserve the current version, update components. - for (key, components) in to_upgrade { - let existing_version = tools - .get(key.as_str()) - .and_then(|item| item.as_value()) - .and_then(|v| match v { - toml_edit::Value::String(s) => Some(s.value().to_string()), - toml_edit::Value::InlineTable(tbl) => tbl - .get("version") - .and_then(|v| v.as_str()) - .map(str::to_string), - _ => None, - }) - .unwrap_or_else(|| "latest".to_string()); - - let mut tbl = toml_edit::InlineTable::new(); - tbl.insert("version", toml_edit::Value::from(existing_version.as_str())); - tbl.insert("components", toml_edit::Value::from(components.as_str())); - tools.insert( - key.as_str(), - toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), - ); - } - - std::fs::write(path, doc.to_string())?; - Ok(()) -} - -// --- Post-linter-selection setup helpers --- - -/// Returns true if any currently-selected check has `Category::Slow`. -fn has_slow_selected(groups: &[LinterGroup]) -> bool { - groups.iter().any(|g| { - g.checks - .iter() - .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.category == Category::Slow) - }) -} - -/// Reads the default branch for `origin` from git, falling back to `"main"`. -fn detect_base_branch(project_root: &Path) -> String { - Command::new("git") - .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) - .current_dir(project_root) - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .and_then(|s| s.trim().strip_prefix("origin/").map(str::to_string)) - .unwrap_or_else(|| "main".to_string()) -} - -/// Reads `FLINT_CONFIG_DIR` from the `[env]` section of a mise.toml string, if present. -fn get_existing_config_dir(content: &str) -> Option { - let doc: toml_edit::DocumentMut = content.parse().ok()?; - doc.get("env")? - .as_table()? - .get("FLINT_CONFIG_DIR")? - .as_str() - .map(str::to_string) -} - -/// Asks where `flint.toml` should live. Skips the prompt when `--yes` or when -/// `FLINT_CONFIG_DIR` is already set in the current mise.toml. -/// -/// Returns a path relative to the project root (e.g. `".github/config"`). -fn prompt_config_dir(existing: Option<&str>, yes: bool) -> Result { - if let Some(dir) = existing { - return Ok(dir.to_string()); - } - if yes { - return Ok(".github/config".to_string()); - } - - const CHOICES: &[&str] = &[".github/config", ".github", ".", "other…"]; - println!("Where should flint.toml live?\n"); - for (i, choice) in CHOICES.iter().enumerate() { - println!(" {}) {}", i + 1, choice); - } - print!("\nChoice [1]: "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().lock().read_line(&mut input)?; - let input = input.trim(); - - let idx: usize = if input.is_empty() { - 0 - } else { - input.parse::().unwrap_or(1).saturating_sub(1) - }; - - if idx == CHOICES.len() - 1 { - print!("Config dir path: "); - io::stdout().flush()?; - let mut path = String::new(); - io::stdin().lock().read_line(&mut path)?; - Ok(path.trim().to_string()) - } else { - Ok(CHOICES[idx.min(CHOICES.len() - 2)].to_string()) - } -} - -/// Writes a skeleton `flint.toml` in `config_dir`. Creates the directory if needed. -/// Returns `true` if the file was written, `false` if it already existed. -fn generate_flint_toml(config_dir: &Path, base_branch: &str, has_renovate: bool) -> Result { - let toml_path = config_dir.join("flint.toml"); - if toml_path.exists() { - return Ok(false); - } - std::fs::create_dir_all(config_dir)?; - let mut content = String::from("[settings]\n"); - if base_branch != "main" { - content.push_str(&format!("base_branch = \"{base_branch}\"\n")); - } - content.push_str("# exclude = \"CHANGELOG\\\\.md\"\n"); - content.push_str("# exclude_paths = []\n"); - if has_renovate { - content.push_str("\n[checks.renovate-deps]\n"); - content.push_str("# exclude_managers = []\n"); - } - std::fs::write(&toml_path, &content)?; - println!(" wrote {}", toml_path.display()); - Ok(true) -} - -/// Generates `.github/workflows/lint.yml` if it does not already exist. -/// Returns `true` if the file was written. -fn generate_lint_workflow(project_root: &Path, base_branch: &str) -> Result { - let workflows_dir = project_root.join(".github/workflows"); - let workflow_path = workflows_dir.join("lint.yml"); - if workflow_path.exists() { - return Ok(false); - } - std::fs::create_dir_all(&workflows_dir)?; - let content = format!( - r#"name: Lint - -on: - push: - branches: [{base_branch}] - pull_request: - branches: [{base_branch}] - -permissions: - contents: read - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Setup mise - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 - with: - version: v2026.4.1 - sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd - - - name: Lint - env: - GITHUB_TOKEN: ${{{{ github.token }}}} - GITHUB_HEAD_SHA: ${{{{ github.event.pull_request.head.sha }}}} - run: mise run lint -"# - ); - std::fs::write(&workflow_path, content)?; - println!(" wrote {}", workflow_path.display()); - Ok(true) -} - -/// Adds a `[tasks.]` entry only when it is not already present. -/// Returns `true` if an entry was added. -fn add_task_if_absent( - tasks: &mut toml_edit::Table, - name: &str, - description: &str, - run: &str, -) -> bool { - if tasks.contains_key(name) { - return false; - } - let mut t = toml_edit::Table::new(); - t.insert("description", toml_edit::value(description)); - t.insert("run", toml_edit::value(run)); - tasks.insert(name, toml_edit::Item::Table(t)); - true -} - -/// Adds `[env] FLINT_CONFIG_DIR` and the standard `lint*` / `setup:pre-commit-hook` -/// tasks to `mise.toml`, skipping any that are already present. -/// -/// Returns `true` if the file was changed. -fn apply_env_and_tasks(mise_path: &Path, config_dir_rel: &str, has_slow: bool) -> Result { - let content = std::fs::read_to_string(mise_path).unwrap_or_default(); - let mut doc: toml_edit::DocumentMut = content - .parse() - .unwrap_or_else(|_| toml_edit::DocumentMut::new()); - let mut changed = false; - - // [env] — add FLINT_CONFIG_DIR if absent - { - if !doc.contains_key("env") { - doc.insert("env", toml_edit::Item::Table(toml_edit::Table::new())); - } - let env = doc["env"].as_table_mut().context("[env] is not a table")?; - if !env.contains_key("FLINT_CONFIG_DIR") { - env.insert("FLINT_CONFIG_DIR", toml_edit::value(config_dir_rel)); - changed = true; - } - } - - // [tasks] — add lint / lint:fix / (lint:pre-commit) / setup:pre-commit-hook - { - if !doc.contains_key("tasks") { - doc.insert("tasks", toml_edit::Item::Table(toml_edit::Table::new())); - } - let tasks = doc["tasks"] - .as_table_mut() - .context("[tasks] is not a table")?; - - changed |= add_task_if_absent(tasks, "lint", "Run all lints", "flint run"); - changed |= add_task_if_absent(tasks, "lint:fix", "Auto-fix lint issues", "flint run --fix"); - if has_slow { - changed |= add_task_if_absent( - tasks, - "lint:pre-commit", - "Fast auto-fix lint (skips slow checks) — for pre-commit/pre-push hooks", - "flint run --fix --fast-only", - ); - } - let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; - changed |= add_task_if_absent( - tasks, - "setup:pre-commit-hook", - "Install git pre-commit hook", - &format!("mise generate git-pre-commit --write --task={hook_task}"), - ); - } - - if changed { - std::fs::write(mise_path, doc.to_string())?; - } - Ok(changed) -} - -/// Installs the git pre-commit hook by running `mise generate git-pre-commit`. -/// Prompts the user unless `yes` is true. Silently skips if the hook is already installed. -fn maybe_install_hook(project_root: &Path, hook_task: &str, yes: bool) -> Result<()> { - let hook_path = project_root.join(".git/hooks/pre-commit"); - if hook_path.exists() { - return Ok(()); - } - - let install = if yes { - true - } else { - print!("Install pre-commit hook (runs `mise run {hook_task}` before each commit)? [Y/n] "); - io::stdout().flush()?; - let mut input = String::new(); - io::stdin().lock().read_line(&mut input)?; - !input.trim().eq_ignore_ascii_case("n") - }; - - if install { - let status = Command::new("mise") - .args([ - "generate", - "git-pre-commit", - "--write", - &format!("--task={hook_task}"), - ]) - .current_dir(project_root) - .status(); - match status { - Ok(s) if s.success() => println!(" installed pre-commit hook"), - _ => println!( - " warning: could not install pre-commit hook — run `mise run setup:pre-commit-hook` later" - ), - } - } - Ok(()) -} - -// --- Tests --- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn all_registry_checks_have_install_key_or_none() { - // Every check that uses a binary and isn't unconditional must have a resolvable key. - for check in builtin() { - if check.uses_binary() && !check.activate_unconditionally { - let key = install_key(&check); - assert!( - key.is_some(), - "check '{}' is missing an install key", - check.name - ); - } - } - } - - #[test] - fn entry_components_differ_string_value() { - let content = "[tools]\nrust = \"1.80.0\"\n"; - assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); - } - - #[test] - fn entry_components_differ_inline_table_without_components() { - let content = "[tools]\nrust = { version = \"1.80.0\" }\n"; - assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); - } - - #[test] - fn entry_components_differ_inline_table_wrong_components() { - let content = "[tools]\nrust = { version = \"1.80.0\", components = \"clippy\" }\n"; - assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); - } - - #[test] - fn entry_components_differ_inline_table_correct_components() { - let content = "[tools]\nrust = { version = \"1.80.0\", components = \"clippy,rustfmt\" }\n"; - assert!(!entry_components_differ(content, "rust", "clippy,rustfmt")); - } - - #[test] - fn apply_changes_upgrade_preserves_version() { - let content = "[tools]\nrust = \"1.80.0\"\n"; - let tmp = tempfile::NamedTempFile::new().unwrap(); - apply_changes( - tmp.path(), - content, - &[], - &[], - &[("rust".to_string(), "clippy,rustfmt".to_string())], - ) - .unwrap(); - let result = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(result.contains("version = \"1.80.0\""), "version preserved"); - assert!( - result.contains("components = \"clippy,rustfmt\""), - "components added" - ); - } - - #[test] - fn parse_tool_keys_reads_simple_toml() { - let content = r#" -[tools] -shellcheck = "v0.11.0" -"npm:prettier" = "3.8.1" -rust = { version = "1.0", components = "clippy" } -"#; - let keys = parse_tool_keys(content); - assert!(keys.contains("shellcheck")); - assert!(keys.contains("npm:prettier")); - assert!(keys.contains("rust")); - assert!(!keys.contains("nonexistent")); - } - - #[test] - fn compute_desired_tools_lang_profile() { - let registry = builtin(); - let mut present = HashSet::new(); - present.insert("*.sh".to_string()); - present.insert("*.bash".to_string()); - present.insert("*.rs".to_string()); - let categories = profile_to_categories(Profile::Lang); - let tools = compute_desired_tools(®istry, &present, &categories); - // Shell checks are supplementary (Style), not included in the lang profile. - assert!(!tools.contains_key("shellcheck")); - assert!(!tools.contains_key("shfmt")); - // Primary language linters are included. - assert!(tools.contains_key("rust")); - // General tools are not lang-only. - assert!(!tools.contains_key("pipx:codespell")); - } - - #[test] - fn rust_install_entry_has_components() { - let registry = builtin(); - let mut present = HashSet::new(); - present.insert("*.rs".to_string()); - let categories = profile_to_categories(Profile::Lang); - let tools = compute_desired_tools(®istry, &present, &categories); - // Both cargo-clippy and cargo-fmt share the "rust" key; their components are merged. - assert_eq!( - tools.get("rust"), - Some(&Some("clippy,rustfmt".to_string())), - "rust tool entry should carry merged components" - ); - } - - #[test] - fn compute_desired_tools_default_excludes_slow() { - let registry = builtin(); - let present: HashSet = HashSet::new(); - let categories = profile_to_categories(Profile::Default); - let tools = compute_desired_tools(®istry, &present, &categories); - // renovate-deps is slow — should be absent - assert!(!tools.contains_key("npm:renovate")); - // lychee is fast — should be present (empty patterns → always present) - assert!(tools.contains_key("lychee")); - } - - #[test] - fn compute_desired_tools_comprehensive_includes_slow() { - let registry = builtin(); - // Must include renovate config pattern so renovate-deps is considered present. - let mut present: HashSet = HashSet::new(); - present.insert(".github/renovate.json5".to_string()); - let categories = profile_to_categories(Profile::Comprehensive); - let tools = compute_desired_tools(®istry, &present, &categories); - assert!(tools.contains_key("lychee")); - assert!(tools.contains_key("npm:renovate")); - } - - #[test] - fn renovate_deps_absent_without_renovate_config() { - let registry = builtin(); - // No renovate config file in present patterns → renovate-deps should be excluded. - let present: HashSet = HashSet::new(); - let categories = profile_to_categories(Profile::Comprehensive); - let tools = compute_desired_tools(®istry, &present, &categories); - assert!(!tools.contains_key("npm:renovate")); - } - - #[test] - fn has_slow_selected_detects_slow_check() { - let registry = builtin(); - let mut present = HashSet::new(); - present.insert(".github/renovate.json5".to_string()); - let categories = profile_to_categories(Profile::Comprehensive); - let groups = build_linter_groups(®istry, &present, &HashSet::new(), "", &categories); - assert!(has_slow_selected(&groups)); - } - - #[test] - fn has_slow_selected_false_for_default_profile() { - let registry = builtin(); - let present = HashSet::new(); - let categories = profile_to_categories(Profile::Default); - let groups = build_linter_groups(®istry, &present, &HashSet::new(), "", &categories); - assert!(!has_slow_selected(&groups)); - } - - #[test] - fn get_existing_config_dir_reads_env_section() { - let content = "[env]\nFLINT_CONFIG_DIR = \".github/config\"\n"; - assert_eq!( - get_existing_config_dir(content), - Some(".github/config".to_string()) - ); - } - - #[test] - fn get_existing_config_dir_absent() { - let content = "[tools]\nrust = \"latest\"\n"; - assert_eq!(get_existing_config_dir(content), None); - } - - #[test] - fn generate_flint_toml_writes_skeleton() { - let tmp = tempfile::TempDir::new().unwrap(); - let dir = tmp.path().join("config"); - let written = generate_flint_toml(&dir, "main", false).unwrap(); - assert!(written); - let content = std::fs::read_to_string(dir.join("flint.toml")).unwrap(); - assert!(content.contains("[settings]")); - assert!(content.contains("# exclude =")); - assert!(content.contains("# exclude_paths =")); - assert!(!content.contains("base_branch")); // "main" is the default, omitted - } - - #[test] - fn generate_flint_toml_non_main_branch() { - let tmp = tempfile::TempDir::new().unwrap(); - let written = generate_flint_toml(tmp.path(), "master", false).unwrap(); - assert!(written); - let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); - assert!(content.contains("base_branch = \"master\"")); - } - - #[test] - fn generate_flint_toml_with_renovate() { - let tmp = tempfile::TempDir::new().unwrap(); - generate_flint_toml(tmp.path(), "main", true).unwrap(); - let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); - assert!(content.contains("[checks.renovate-deps]")); - assert!(content.contains("# exclude_managers =")); - } - - #[test] - fn generate_flint_toml_skips_existing() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("flint.toml"), "existing content").unwrap(); - let written = generate_flint_toml(tmp.path(), "main", false).unwrap(); - assert!(!written); - let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); - assert_eq!(content, "existing content"); - } - - #[test] - fn generate_lint_workflow_writes_file() { - let tmp = tempfile::TempDir::new().unwrap(); - let written = generate_lint_workflow(tmp.path(), "main").unwrap(); - assert!(written); - let content = - std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); - assert!(content.contains("branches: [main]")); - assert!(content.contains("mise run lint")); - assert!(content.contains("fetch-depth: 0")); - assert!(content.contains("persist-credentials: false")); - assert!(content.contains("mise-action")); - assert!(content.contains("github.token")); - } - - #[test] - fn generate_lint_workflow_non_main_branch() { - let tmp = tempfile::TempDir::new().unwrap(); - generate_lint_workflow(tmp.path(), "master").unwrap(); - let content = - std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); - assert!(content.contains("branches: [master]")); - } - - #[test] - fn generate_lint_workflow_skips_existing() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::create_dir_all(tmp.path().join(".github/workflows")).unwrap(); - std::fs::write( - tmp.path().join(".github/workflows/lint.yml"), - "existing content", - ) - .unwrap(); - let written = generate_lint_workflow(tmp.path(), "main").unwrap(); - assert!(!written); - let content = - std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); - assert_eq!(content, "existing content"); - } - - #[test] - fn apply_env_and_tasks_adds_sections() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(tmp.path(), "[tools]\nrust = \"latest\"\n").unwrap(); - let changed = apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); - assert!(changed); - let content = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(content.contains("FLINT_CONFIG_DIR = \".github/config\"")); - assert!(content.contains("flint run")); - assert!(content.contains("flint run --fix")); - assert!(!content.contains("--fast-only")); // no slow linters - assert!(content.contains("setup:pre-commit-hook")); - } - - #[test] - fn apply_env_and_tasks_adds_pre_commit_task_when_slow() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(tmp.path(), "").unwrap(); - apply_env_and_tasks(tmp.path(), ".", true).unwrap(); - let content = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(content.contains("--fast-only")); - assert!(content.contains("lint:pre-commit")); - // Hook task should point to lint:pre-commit - assert!(content.contains("--task=lint:pre-commit")); - } - - #[test] - fn apply_env_and_tasks_idempotent() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(tmp.path(), "").unwrap(); - apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); - let after_first = std::fs::read_to_string(tmp.path()).unwrap(); - let changed = apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); - assert!(!changed); - let after_second = std::fs::read_to_string(tmp.path()).unwrap(); - assert_eq!(after_first, after_second); - } -} diff --git a/src/init/detection.rs b/src/init/detection.rs new file mode 100644 index 0000000..aa77f19 --- /dev/null +++ b/src/init/detection.rs @@ -0,0 +1,148 @@ +use anyhow::{Context, Result}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::process::Command; + +use crate::registry::{Category, Check}; + +use super::{LinterGroup, install_key}; + +/// Returns `true` if the repo contains at least one file matching any of the +/// check's patterns. Checks with no patterns (project-scope specials like +/// lychee) are always considered present. +pub(super) fn files_present(check: &Check, present_patterns: &HashSet) -> bool { + check.patterns.is_empty() + || check + .patterns + .iter() + .any(|p| *p == "*" || present_patterns.contains(*p)) +} + +/// Runs `git ls-files -- ` for every unique pattern in the registry +/// and returns the set of patterns that produced at least one result. +pub(super) fn detect_present_patterns( + project_root: &Path, + registry: &[Check], +) -> Result> { + let all_patterns: HashSet<&str> = registry + .iter() + .flat_map(|c| c.patterns.iter().copied()) + .filter(|p| *p != "*") + .collect(); + + let mut present = HashSet::new(); + for pattern in all_patterns { + let out = Command::new("git") + .args(["ls-files", "--", pattern]) + .current_dir(project_root) + .output() + .context("git ls-files")?; + if !out.stdout.is_empty() { + present.insert(pattern.to_string()); + } + } + Ok(present) +} + +/// Returns the set of keys currently declared in `[tools]`. +pub(super) fn parse_tool_keys(content: &str) -> HashSet { + let value: toml::Value = match toml::from_str(content) { + Ok(v) => v, + Err(_) => return HashSet::new(), + }; + value + .get("tools") + .and_then(|v| v.as_table()) + .map(|t| t.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Returns `true` if the `[tools]` entry for `key` exists and its `components` +/// field is absent or differs from `required`. Used to detect entries that need +/// upgrading (missing components) or correcting (wrong components). +#[cfg(test)] +pub(super) fn entry_components_differ(content: &str, key: &str, required: &str) -> bool { + let doc: toml_edit::DocumentMut = match content.parse() { + Ok(d) => d, + Err(_) => return false, + }; + let tools = match doc.get("tools").and_then(|t| t.as_table()) { + Some(t) => t, + None => return false, + }; + match tools.get(key) { + Some(item) => match item.as_value() { + Some(toml_edit::Value::InlineTable(tbl)) => { + tbl.get("components").and_then(|v| v.as_str()) != Some(required) + } + Some(toml_edit::Value::String(_)) => true, + _ => false, + }, + None => false, + } +} + +/// Returns the `components` string currently set for `key` in the `[tools]` section, +/// or `None` if the key is absent, is a plain string entry, or has no `components` field. +pub(super) fn get_entry_components(content: &str, key: &str) -> Option { + let doc: toml_edit::DocumentMut = content.parse().ok()?; + let tools = doc.get("tools")?.as_table()?; + match tools.get(key)?.as_value()? { + toml_edit::Value::InlineTable(tbl) => tbl.get("components")?.as_str().map(str::to_string), + _ => None, + } +} + +/// Builds one `LinterGroup` per install key, covering all checks whose file patterns +/// are present in the repo or whose key is already installed. +pub(super) fn build_linter_groups<'a>( + registry: &'a [Check], + present_patterns: &HashSet, + current_tool_keys: &HashSet, + current_content: &str, + default_categories: &HashSet, +) -> Vec> { + let mut by_key: HashMap<&'static str, Vec<&'a Check>> = HashMap::new(); + for check in registry { + let key = match install_key(check) { + Some(k) => k, + None => continue, + }; + if files_present(check, present_patterns) || current_tool_keys.contains(key) { + by_key.entry(key).or_default().push(check); + } + } + + let mut groups: Vec> = by_key + .into_iter() + .map(|(key, mut checks)| { + checks.sort_by_key(|c| c.name); + let installed = current_tool_keys.contains(key); + let current_components = if installed { + get_entry_components(current_content, key) + } else { + None + }; + // Pre-select each check individually: select if its category is in the + // default set and its patterns are present, OR if the key is already installed. + let check_selected: Vec = checks + .iter() + .map(|c| { + let suggested = default_categories.contains(&c.category) + && files_present(c, present_patterns); + suggested || installed + }) + .collect(); + LinterGroup { + key, + checks, + check_selected, + installed, + current_components, + } + }) + .collect(); + + groups.sort_by_key(|g| g.checks.first().map_or(g.key, |c| c.name)); + groups +} diff --git a/src/init/generation.rs b/src/init/generation.rs new file mode 100644 index 0000000..14c7a50 --- /dev/null +++ b/src/init/generation.rs @@ -0,0 +1,342 @@ +use anyhow::{Context, Result}; +use std::io::{self, BufRead, Write}; +use std::path::Path; +use std::process::Command; + +use super::LinterGroup; + +pub(super) fn apply_changes( + path: &Path, + current_content: &str, + to_add: &[(String, Option)], + to_remove: &[String], + to_upgrade: &[(String, String)], +) -> Result<()> { + let mut doc: toml_edit::DocumentMut = current_content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + + // Ensure [tools] table exists. + if !doc.contains_key("tools") { + doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new())); + } + let tools = doc["tools"] + .as_table_mut() + .context("[tools] is not a table")?; + + for key in to_remove { + tools.remove(key.as_str()); + } + + for (key, components) in to_add { + match components { + Some(comps) => { + let mut tbl = toml_edit::InlineTable::new(); + tbl.insert("version", toml_edit::Value::from("latest")); + tbl.insert("components", toml_edit::Value::from(comps.as_str())); + tools.insert( + key.as_str(), + toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), + ); + } + None => { + tools.insert(key.as_str(), toml_edit::value("latest")); + } + } + } + + // Upgrade existing entries: preserve the current version, update components. + for (key, components) in to_upgrade { + let existing_version = tools + .get(key.as_str()) + .and_then(|item| item.as_value()) + .and_then(|v| match v { + toml_edit::Value::String(s) => Some(s.value().to_string()), + toml_edit::Value::InlineTable(tbl) => tbl + .get("version") + .and_then(|v| v.as_str()) + .map(str::to_string), + _ => None, + }) + .unwrap_or_else(|| "latest".to_string()); + + let mut tbl = toml_edit::InlineTable::new(); + tbl.insert("version", toml_edit::Value::from(existing_version.as_str())); + tbl.insert("components", toml_edit::Value::from(components.as_str())); + tools.insert( + key.as_str(), + toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), + ); + } + + std::fs::write(path, doc.to_string())?; + Ok(()) +} + +/// Returns true if any currently-selected check has `Category::Slow`. +pub(super) fn has_slow_selected(groups: &[LinterGroup]) -> bool { + use crate::registry::Category; + groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && c.category == Category::Slow) + }) +} + +/// Reads the default branch for `origin` from git, falling back to `"main"`. +pub(super) fn detect_base_branch(project_root: &Path) -> String { + Command::new("git") + .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) + .current_dir(project_root) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().strip_prefix("origin/").map(str::to_string)) + .unwrap_or_else(|| "main".to_string()) +} + +/// Reads `FLINT_CONFIG_DIR` from the `[env]` section of a mise.toml string, if present. +pub(super) fn get_existing_config_dir(content: &str) -> Option { + let doc: toml_edit::DocumentMut = content.parse().ok()?; + doc.get("env")? + .as_table()? + .get("FLINT_CONFIG_DIR")? + .as_str() + .map(str::to_string) +} + +/// Asks where `flint.toml` should live. Skips the prompt when `--yes` or when +/// `FLINT_CONFIG_DIR` is already set in the current mise.toml. +/// +/// Returns a path relative to the project root (e.g. `".github/config"`). +pub(super) fn prompt_config_dir(existing: Option<&str>, yes: bool) -> Result { + if let Some(dir) = existing { + return Ok(dir.to_string()); + } + if yes { + return Ok(".github/config".to_string()); + } + + const CHOICES: &[&str] = &[".github/config", ".github", ".", "other…"]; + println!("Where should flint.toml live?\n"); + for (i, choice) in CHOICES.iter().enumerate() { + println!(" {}) {}", i + 1, choice); + } + print!("\nChoice [1]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + let input = input.trim(); + + let idx: usize = if input.is_empty() { + 0 + } else { + input.parse::().unwrap_or(1).saturating_sub(1) + }; + + if idx == CHOICES.len() - 1 { + print!("Config dir path: "); + io::stdout().flush()?; + let mut path = String::new(); + io::stdin().lock().read_line(&mut path)?; + Ok(path.trim().to_string()) + } else { + Ok(CHOICES[idx.min(CHOICES.len() - 2)].to_string()) + } +} + +/// Writes a skeleton `flint.toml` in `config_dir`. Creates the directory if needed. +/// Returns `true` if the file was written, `false` if it already existed. +pub(super) fn generate_flint_toml( + config_dir: &Path, + base_branch: &str, + has_renovate: bool, +) -> Result { + let toml_path = config_dir.join("flint.toml"); + if toml_path.exists() { + return Ok(false); + } + std::fs::create_dir_all(config_dir)?; + let mut content = String::from("[settings]\n"); + if base_branch != "main" { + content.push_str(&format!("base_branch = \"{base_branch}\"\n")); + } + content.push_str("# exclude = \"CHANGELOG\\\\.md\"\n"); + content.push_str("# exclude_paths = []\n"); + if has_renovate { + content.push_str("\n[checks.renovate-deps]\n"); + content.push_str("# exclude_managers = []\n"); + } + std::fs::write(&toml_path, &content)?; + println!(" wrote {}", toml_path.display()); + Ok(true) +} + +/// Generates `.github/workflows/lint.yml` if it does not already exist. +/// Returns `true` if the file was written. +pub(super) fn generate_lint_workflow(project_root: &Path, base_branch: &str) -> Result { + let workflows_dir = project_root.join(".github/workflows"); + let workflow_path = workflows_dir.join("lint.yml"); + if workflow_path.exists() { + return Ok(false); + } + std::fs::create_dir_all(&workflows_dir)?; + let content = format!( + r#"name: Lint + +on: + push: + branches: [{base_branch}] + pull_request: + branches: [{base_branch}] + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + version: v2026.4.1 + sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + + - name: Lint + env: + GITHUB_TOKEN: ${{{{ github.token }}}} + GITHUB_HEAD_SHA: ${{{{ github.event.pull_request.head.sha }}}} + run: mise run lint +"# + ); + std::fs::write(&workflow_path, content)?; + println!(" wrote {}", workflow_path.display()); + Ok(true) +} + +/// Adds a `[tasks.]` entry only when it is not already present. +/// Returns `true` if an entry was added. +fn add_task_if_absent( + tasks: &mut toml_edit::Table, + name: &str, + description: &str, + run: &str, +) -> bool { + if tasks.contains_key(name) { + return false; + } + let mut t = toml_edit::Table::new(); + t.insert("description", toml_edit::value(description)); + t.insert("run", toml_edit::value(run)); + tasks.insert(name, toml_edit::Item::Table(t)); + true +} + +/// Adds `[env] FLINT_CONFIG_DIR` and the standard `lint*` / `setup:pre-commit-hook` +/// tasks to `mise.toml`, skipping any that are already present. +/// +/// Returns `true` if the file was changed. +pub(super) fn apply_env_and_tasks( + mise_path: &Path, + config_dir_rel: &str, + has_slow: bool, +) -> Result { + let content = std::fs::read_to_string(mise_path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + let mut changed = false; + + // [env] — add FLINT_CONFIG_DIR if absent + { + if !doc.contains_key("env") { + doc.insert("env", toml_edit::Item::Table(toml_edit::Table::new())); + } + let env = doc["env"].as_table_mut().context("[env] is not a table")?; + if !env.contains_key("FLINT_CONFIG_DIR") { + env.insert("FLINT_CONFIG_DIR", toml_edit::value(config_dir_rel)); + changed = true; + } + } + + // [tasks] — add lint / lint:fix / (lint:pre-commit) / setup:pre-commit-hook + { + if !doc.contains_key("tasks") { + doc.insert("tasks", toml_edit::Item::Table(toml_edit::Table::new())); + } + let tasks = doc["tasks"] + .as_table_mut() + .context("[tasks] is not a table")?; + + changed |= add_task_if_absent(tasks, "lint", "Run all lints", "flint run"); + changed |= add_task_if_absent(tasks, "lint:fix", "Auto-fix lint issues", "flint run --fix"); + if has_slow { + changed |= add_task_if_absent( + tasks, + "lint:pre-commit", + "Fast auto-fix lint (skips slow checks) — for pre-commit/pre-push hooks", + "flint run --fix --fast-only", + ); + } + let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; + changed |= add_task_if_absent( + tasks, + "setup:pre-commit-hook", + "Install git pre-commit hook", + &format!("mise generate git-pre-commit --write --task={hook_task}"), + ); + } + + if changed { + std::fs::write(mise_path, doc.to_string())?; + } + Ok(changed) +} + +/// Installs the git pre-commit hook by running `mise generate git-pre-commit`. +/// Prompts the user unless `yes` is true. Silently skips if the hook is already installed. +pub(super) fn maybe_install_hook(project_root: &Path, hook_task: &str, yes: bool) -> Result<()> { + let hook_path = project_root.join(".git/hooks/pre-commit"); + if hook_path.exists() { + return Ok(()); + } + + let install = if yes { + true + } else { + print!("Install pre-commit hook (runs `mise run {hook_task}` before each commit)? [Y/n] "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + !input.trim().eq_ignore_ascii_case("n") + }; + + if install { + let status = Command::new("mise") + .args([ + "generate", + "git-pre-commit", + "--write", + &format!("--task={hook_task}"), + ]) + .current_dir(project_root) + .status(); + match status { + Ok(s) if s.success() => println!(" installed pre-commit hook"), + _ => println!( + " warning: could not install pre-commit hook — run `mise run setup:pre-commit-hook` later" + ), + } + } + Ok(()) +} diff --git a/src/init/mod.rs b/src/init/mod.rs new file mode 100644 index 0000000..0752ae0 --- /dev/null +++ b/src/init/mod.rs @@ -0,0 +1,606 @@ +use anyhow::Result; +use std::collections::HashSet; +#[cfg(test)] +use std::collections::HashMap; +use std::path::Path; + +use crate::registry::{Category, Check, builtin}; + +mod detection; +mod generation; +mod ui; + +use detection::{build_linter_groups, detect_present_patterns, parse_tool_keys}; +use generation::{ + apply_changes, apply_env_and_tasks, detect_base_branch, generate_flint_toml, + generate_lint_workflow, get_existing_config_dir, has_slow_selected, maybe_install_hook, + prompt_config_dir, +}; +use ui::{interactive_select_linters, select_categories_arrow}; + +/// Linter profile — shorthand for `--profile` CLI flag; maps to a category set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Profile { + /// Primary language linters only (ruff, cargo-clippy, golangci-lint, …). + Lang, + /// Lang + supplementary checks + fast general tools (shellcheck, prettier, codespell, …). + Default, + /// Default + slow linters (renovate-deps). + Comprehensive, +} + +fn profile_to_categories(profile: Profile) -> HashSet { + match profile { + Profile::Lang => [Category::Lang].into(), + Profile::Default => [Category::Lang, Category::Style, Category::Default].into(), + Profile::Comprehensive => [ + Category::Lang, + Category::Style, + Category::Default, + Category::Slow, + ] + .into(), + } +} + +/// Desired tools for a profile: maps each mise tool key to its optional components string. +#[cfg(test)] +type DesiredTools = HashMap>; + +// One entry per install key — groups all checks sharing that key. +struct LinterGroup<'a> { + key: &'static str, + checks: Vec<&'a Check>, // sorted by name + check_selected: Vec, // parallel to checks + installed: bool, + current_components: Option, +} + +impl LinterGroup<'_> { + fn any_selected(&self) -> bool { + self.check_selected.iter().any(|&s| s) + } + + /// Components string to write for the currently selected checks, e.g. `"clippy,rustfmt"`. + /// Returns `None` when no selected check carries a component requirement. + fn selected_components(&self) -> Option { + let comps: Vec<&'static str> = self + .checks + .iter() + .zip(&self.check_selected) + .filter_map(|(c, &sel)| if sel { c.mise_install_components } else { None }) + .collect(); + if comps.is_empty() { + None + } else { + Some(comps.join(",")) + } + } + + fn action(&self) -> &'static str { + if self.any_selected() { + if !self.installed { + "add" + } else if self.selected_components() != self.current_components { + "upgrade" + } else { + "keep" + } + } else if self.installed { + "remove" + } else { + "" + } + } +} + +// --- Category selection (step 1) --- + +struct CategoryItem { + selected: bool, + category: Category, + label: &'static str, +} + +fn default_category_items() -> Vec { + vec![ + CategoryItem { + selected: true, + category: Category::Lang, + label: "lang — primary language linters (ruff, cargo-clippy, golangci-lint, …)", + }, + CategoryItem { + selected: true, + category: Category::Style, + label: "style — supplementary checks (shellcheck, actionlint, hadolint, …)", + }, + CategoryItem { + selected: true, + category: Category::Default, + label: "general — general tools (codespell, ec, lychee, …)", + }, + CategoryItem { + selected: false, + category: Category::Slow, + label: "slow — slow linters (renovate-deps)", + }, + ] +} + +pub fn run(project_root: &Path, profile_arg: Option, yes: bool) -> Result<()> { + println!( + "Tip: flint init detects languages from tracked files (`git ls-files`). \ +Add and stage your source files before running init so the detection is accurate." + ); + println!(); + + let registry = builtin(); + let present_patterns = detect_present_patterns(project_root, ®istry)?; + + // Step 1: determine which categories set the initial pre-selection. + let default_categories: HashSet = if let Some(profile) = profile_arg { + profile_to_categories(profile) + } else if yes { + profile_to_categories(Profile::Default) + } else { + let mut cat_items = default_category_items(); + if !select_categories_arrow(&mut cat_items)? { + println!("Aborted."); + return Ok(()); + } + cat_items + .iter() + .filter(|i| i.selected) + .map(|i| i.category) + .collect() + }; + + let mise_path = project_root.join("mise.toml"); + let current_content = std::fs::read_to_string(&mise_path).unwrap_or_default(); + let current_tool_keys = parse_tool_keys(¤t_content); + let known_keys: HashSet<&str> = registry.iter().filter_map(install_key).collect(); + + // Step 2: build one group per install key, covering all checks whose files are + // present in the repo or which are already installed. + let mut groups = build_linter_groups( + ®istry, + &present_patterns, + ¤t_tool_keys, + ¤t_content, + &default_categories, + ); + + if groups.is_empty() { + println!("No applicable linters found for this project."); + return Ok(()); + } + + // Step 3: interactive linter table (skipped with --yes). + if !yes && !interactive_select_linters(&mut groups)? { + println!("Aborted."); + return Ok(()); + } + + // Derive changes from final selection state. + let mut final_add: Vec<(String, Option)> = Vec::new(); + let mut final_remove: Vec = Vec::new(); + let mut final_upgrade: Vec<(String, String)> = Vec::new(); + + for group in &groups { + if group.any_selected() { + if !group.installed { + final_add.push((group.key.to_string(), group.selected_components())); + } else { + let target = group.selected_components(); + if target != group.current_components { + // Upgrade: components changed (added, removed, or reordered). + // If the target has no components (e.g. all component-bearing checks + // deselected), treat as a plain-version install via add+remove. + if let Some(comps) = target { + final_upgrade.push((group.key.to_string(), comps)); + } + } + } + } else if group.installed && known_keys.contains(group.key) { + final_remove.push(group.key.to_string()); + } + } + + let has_slow = has_slow_selected(&groups); + let has_renovate = groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && c.name == "renovate-deps") + }); + + // Prompt for the flint config dir (skipped if already set in mise.toml or --yes). + let existing_config_dir = get_existing_config_dir(¤t_content); + let config_dir_rel = prompt_config_dir(existing_config_dir.as_deref(), yes)?; + + let tools_changed = + !final_add.is_empty() || !final_remove.is_empty() || !final_upgrade.is_empty(); + if tools_changed { + apply_changes( + &mise_path, + ¤t_content, + &final_add, + &final_remove, + &final_upgrade, + )?; + } + + let meta_changed = apply_env_and_tasks(&mise_path, &config_dir_rel, has_slow)?; + + let base_branch = detect_base_branch(project_root); + let config_dir_path = project_root.join(&config_dir_rel); + let toml_generated = generate_flint_toml(&config_dir_path, &base_branch, has_renovate)?; + let workflow_generated = generate_lint_workflow(project_root, &base_branch)?; + + if !tools_changed && !meta_changed && !toml_generated && !workflow_generated { + println!("No changes to apply."); + return Ok(()); + } + + let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; + maybe_install_hook(project_root, hook_task, yes)?; + + println!("Done. Run `mise install` to install the new tools."); + Ok(()) +} + +/// Returns the canonical mise.toml tool key to write when installing this check +/// via `flint init`, or `None` if no mise entry is needed (built-in or +/// unconditionally active checks). +/// +/// Preference order: `mise_install_key` → `mise_tool_name` → `bin_name`. +pub fn install_key(check: &Check) -> Option<&'static str> { + if !check.uses_binary() || check.activate_unconditionally { + return None; + } + Some( + check + .mise_install_key + .or(check.mise_tool_name) + .unwrap_or(check.bin_name), + ) +} + +/// Compute the map of `tool_key → optional_components` for the given category set, +/// filtered to file patterns present in the repo. +#[cfg(test)] +fn compute_desired_tools( + registry: &[Check], + present_patterns: &HashSet, + categories: &HashSet, +) -> DesiredTools { + use detection::files_present; + + // Collect per-key component lists so multiple checks sharing a key are merged. + let mut by_key: HashMap> = HashMap::new(); + for check in registry { + let key = match install_key(check) { + Some(k) => k, + None => continue, + }; + if !files_present(check, present_patterns) { + continue; + } + if categories.contains(&check.category) { + let entry = by_key.entry(key.to_string()).or_default(); + if let Some(comp) = check.mise_install_components { + if !entry.contains(&comp) { + entry.push(comp); + } + } + } + } + by_key + .into_iter() + .map(|(k, comps)| { + let merged = if comps.is_empty() { + None + } else { + Some(comps.join(",")) + }; + (k, merged) + }) + .collect() +} + +// --- Tests --- + +#[cfg(test)] +mod tests { + use super::*; + use detection::entry_components_differ; + use generation::{ + apply_changes, apply_env_and_tasks, generate_flint_toml, generate_lint_workflow, + get_existing_config_dir, has_slow_selected, + }; + + #[test] + fn all_registry_checks_have_install_key_or_none() { + // Every check that uses a binary and isn't unconditional must have a resolvable key. + for check in builtin() { + if check.uses_binary() && !check.activate_unconditionally { + let key = install_key(&check); + assert!( + key.is_some(), + "check '{}' is missing an install key", + check.name + ); + } + } + } + + #[test] + fn entry_components_differ_string_value() { + let content = "[tools]\nrust = \"1.80.0\"\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_without_components() { + let content = "[tools]\nrust = { version = \"1.80.0\" }\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_wrong_components() { + let content = "[tools]\nrust = { version = \"1.80.0\", components = \"clippy\" }\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_correct_components() { + let content = "[tools]\nrust = { version = \"1.80.0\", components = \"clippy,rustfmt\" }\n"; + assert!(!entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn apply_changes_upgrade_preserves_version() { + let content = "[tools]\nrust = \"1.80.0\"\n"; + let tmp = tempfile::NamedTempFile::new().unwrap(); + apply_changes( + tmp.path(), + content, + &[], + &[], + &[("rust".to_string(), "clippy,rustfmt".to_string())], + ) + .unwrap(); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(result.contains("version = \"1.80.0\""), "version preserved"); + assert!( + result.contains("components = \"clippy,rustfmt\""), + "components added" + ); + } + + #[test] + fn parse_tool_keys_reads_simple_toml() { + let content = r#" +[tools] +shellcheck = "v0.11.0" +"npm:prettier" = "3.8.1" +rust = { version = "1.0", components = "clippy" } +"#; + let keys = parse_tool_keys(content); + assert!(keys.contains("shellcheck")); + assert!(keys.contains("npm:prettier")); + assert!(keys.contains("rust")); + assert!(!keys.contains("nonexistent")); + } + + #[test] + fn compute_desired_tools_lang_profile() { + let registry = builtin(); + let mut present = HashSet::new(); + present.insert("*.sh".to_string()); + present.insert("*.bash".to_string()); + present.insert("*.rs".to_string()); + let categories = profile_to_categories(Profile::Lang); + let tools = compute_desired_tools(®istry, &present, &categories); + // Shell checks are supplementary (Style), not included in the lang profile. + assert!(!tools.contains_key("shellcheck")); + assert!(!tools.contains_key("shfmt")); + // Primary language linters are included. + assert!(tools.contains_key("rust")); + // General tools are not lang-only. + assert!(!tools.contains_key("pipx:codespell")); + } + + #[test] + fn rust_install_entry_has_components() { + let registry = builtin(); + let mut present = HashSet::new(); + present.insert("*.rs".to_string()); + let categories = profile_to_categories(Profile::Lang); + let tools = compute_desired_tools(®istry, &present, &categories); + // Both cargo-clippy and cargo-fmt share the "rust" key; their components are merged. + assert_eq!( + tools.get("rust"), + Some(&Some("clippy,rustfmt".to_string())), + "rust tool entry should carry merged components" + ); + } + + #[test] + fn compute_desired_tools_default_excludes_slow() { + let registry = builtin(); + let present: HashSet = HashSet::new(); + let categories = profile_to_categories(Profile::Default); + let tools = compute_desired_tools(®istry, &present, &categories); + // renovate-deps is slow — should be absent + assert!(!tools.contains_key("npm:renovate")); + // lychee is fast — should be present (empty patterns → always present) + assert!(tools.contains_key("lychee")); + } + + #[test] + fn compute_desired_tools_comprehensive_includes_slow() { + let registry = builtin(); + // Must include renovate config pattern so renovate-deps is considered present. + let mut present: HashSet = HashSet::new(); + present.insert(".github/renovate.json5".to_string()); + let categories = profile_to_categories(Profile::Comprehensive); + let tools = compute_desired_tools(®istry, &present, &categories); + assert!(tools.contains_key("lychee")); + assert!(tools.contains_key("npm:renovate")); + } + + #[test] + fn renovate_deps_absent_without_renovate_config() { + let registry = builtin(); + // No renovate config file in present patterns → renovate-deps should be excluded. + let present: HashSet = HashSet::new(); + let categories = profile_to_categories(Profile::Comprehensive); + let tools = compute_desired_tools(®istry, &present, &categories); + assert!(!tools.contains_key("npm:renovate")); + } + + + #[test] + fn has_slow_selected_false_for_default_profile() { + let registry = builtin(); + let present = HashSet::new(); + let categories = profile_to_categories(Profile::Default); + let groups = build_linter_groups(®istry, &present, &HashSet::new(), "", &categories); + assert!(!has_slow_selected(&groups)); + } + + #[test] + fn get_existing_config_dir_reads_env_section() { + let content = "[env]\nFLINT_CONFIG_DIR = \".github/config\"\n"; + assert_eq!( + get_existing_config_dir(content), + Some(".github/config".to_string()) + ); + } + + #[test] + fn get_existing_config_dir_absent() { + let content = "[tools]\nrust = \"latest\"\n"; + assert_eq!(get_existing_config_dir(content), None); + } + + #[test] + fn generate_flint_toml_writes_skeleton() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = tmp.path().join("config"); + let written = generate_flint_toml(&dir, "main", false).unwrap(); + assert!(written); + let content = std::fs::read_to_string(dir.join("flint.toml")).unwrap(); + assert!(content.contains("[settings]")); + assert!(content.contains("# exclude =")); + assert!(content.contains("# exclude_paths =")); + assert!(!content.contains("base_branch")); // "main" is the default, omitted + } + + #[test] + fn generate_flint_toml_non_main_branch() { + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_flint_toml(tmp.path(), "master", false).unwrap(); + assert!(written); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert!(content.contains("base_branch = \"master\"")); + } + + #[test] + fn generate_flint_toml_with_renovate() { + let tmp = tempfile::TempDir::new().unwrap(); + generate_flint_toml(tmp.path(), "main", true).unwrap(); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert!(content.contains("[checks.renovate-deps]")); + assert!(content.contains("# exclude_managers =")); + } + + #[test] + fn generate_flint_toml_skips_existing() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("flint.toml"), "existing content").unwrap(); + let written = generate_flint_toml(tmp.path(), "main", false).unwrap(); + assert!(!written); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert_eq!(content, "existing content"); + } + + #[test] + fn generate_lint_workflow_writes_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_lint_workflow(tmp.path(), "main").unwrap(); + assert!(written); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert!(content.contains("branches: [main]")); + assert!(content.contains("mise run lint")); + assert!(content.contains("fetch-depth: 0")); + assert!(content.contains("persist-credentials: false")); + assert!(content.contains("mise-action")); + assert!(content.contains("github.token")); + } + + #[test] + fn generate_lint_workflow_non_main_branch() { + let tmp = tempfile::TempDir::new().unwrap(); + generate_lint_workflow(tmp.path(), "master").unwrap(); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert!(content.contains("branches: [master]")); + } + + #[test] + fn generate_lint_workflow_skips_existing() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join(".github/workflows")).unwrap(); + std::fs::write( + tmp.path().join(".github/workflows/lint.yml"), + "existing content", + ) + .unwrap(); + let written = generate_lint_workflow(tmp.path(), "main").unwrap(); + assert!(!written); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert_eq!(content, "existing content"); + } + + #[test] + fn apply_env_and_tasks_adds_sections() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "[tools]\nrust = \"latest\"\n").unwrap(); + let changed = apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + assert!(changed); + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("FLINT_CONFIG_DIR = \".github/config\"")); + assert!(content.contains("flint run")); + assert!(content.contains("flint run --fix")); + assert!(!content.contains("--fast-only")); // no slow linters + assert!(content.contains("setup:pre-commit-hook")); + } + + #[test] + fn apply_env_and_tasks_adds_pre_commit_task_when_slow() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "").unwrap(); + apply_env_and_tasks(tmp.path(), ".", true).unwrap(); + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("--fast-only")); + assert!(content.contains("lint:pre-commit")); + // Hook task should point to lint:pre-commit + assert!(content.contains("--task=lint:pre-commit")); + } + + #[test] + fn apply_env_and_tasks_idempotent() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "").unwrap(); + apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + let after_first = std::fs::read_to_string(tmp.path()).unwrap(); + let changed = apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + assert!(!changed); + let after_second = std::fs::read_to_string(tmp.path()).unwrap(); + assert_eq!(after_first, after_second); + } +} diff --git a/src/init/ui.rs b/src/init/ui.rs new file mode 100644 index 0000000..c0d0753 --- /dev/null +++ b/src/init/ui.rs @@ -0,0 +1,252 @@ +use anyhow::Result; +use std::io::{self, Write}; + +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyModifiers}, + execute, + terminal::{self, ClearType}, +}; + +use crate::registry::Category; + +use super::{CategoryItem, LinterGroup}; + +fn run_arrow_selector( + items: &mut [T], + print_fn: fn(&mut dyn Write, &[T], usize) -> Result, + toggle_fn: fn(&mut T), +) -> Result { + let mut cursor = 0usize; + terminal::enable_raw_mode()?; + let result = (|| -> Result { + let mut stdout = io::stdout(); + let mut n_lines = print_fn(&mut stdout, items, cursor)?; + loop { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Up if cursor > 0 => cursor -= 1, + KeyCode::Down if cursor + 1 < items.len() => cursor += 1, + KeyCode::Char(' ') => toggle_fn(&mut items[cursor]), + KeyCode::Enter => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(true); + } + KeyCode::Char('q') | KeyCode::Esc => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + _ => continue, + } + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + n_lines = print_fn(&mut stdout, items, cursor)?; + } + } + })(); + let _ = terminal::disable_raw_mode(); + println!(); + result +} + +// --- Step 1: category selection --- + +pub(super) fn select_categories_arrow(items: &mut [CategoryItem]) -> Result { + run_arrow_selector(items, print_cat_selector, |item| { + item.selected = !item.selected + }) +} + +fn print_cat_selector( + stdout: &mut dyn Write, + items: &[CategoryItem], + cursor: usize, +) -> Result { + let mut lines = 0usize; + write!(stdout, "Select categories:\r\n\r\n")?; + lines += 2; + for (i, item) in items.iter().enumerate() { + let arrow = if i == cursor { ">" } else { " " }; + let sel = if item.selected { "āœ“" } else { " " }; + write!(stdout, " {} [{}] {}\r\n", arrow, sel, item.label)?; + lines += 1; + } + write!( + stdout, + "\r\n ↑↓ navigate space toggle enter continue q abort\r\n" + )?; + lines += 2; + stdout.flush()?; + Ok(lines) +} + +// --- Step 2: linter table selection --- + +/// Maps a flat row index (across all checks in all groups) to `(group_idx, check_idx)`. +fn flat_to_group_check(groups: &[LinterGroup], flat: usize) -> (usize, usize) { + let mut remaining = flat; + for (gi, group) in groups.iter().enumerate() { + if remaining < group.checks.len() { + return (gi, remaining); + } + remaining -= group.checks.len(); + } + (0, 0) +} + +pub(super) fn interactive_select_linters(groups: &mut Vec) -> Result { + let total_rows = |gs: &[LinterGroup]| gs.iter().map(|g| g.checks.len()).sum::(); + let mut cursor = 0usize; + terminal::enable_raw_mode()?; + let result = (|| -> Result { + let mut stdout = io::stdout(); + let mut n_lines = print_linter_table(&mut stdout, groups, cursor)?; + loop { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Up if cursor > 0 => cursor -= 1, + KeyCode::Down if cursor + 1 < total_rows(groups) => cursor += 1, + KeyCode::Char(' ') => { + let (gi, ci) = flat_to_group_check(groups, cursor); + groups[gi].check_selected[ci] = !groups[gi].check_selected[ci]; + } + KeyCode::Enter => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(true); + } + KeyCode::Char('q') | KeyCode::Esc => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + _ => continue, + } + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + n_lines = print_linter_table(&mut stdout, groups, cursor)?; + } + } + })(); + let _ = terminal::disable_raw_mode(); + println!(); + result +} + +fn print_linter_table( + stdout: &mut dyn Write, + groups: &[LinterGroup], + cursor: usize, +) -> Result { + let name_w = groups + .iter() + .flat_map(|g| &g.checks) + .map(|c| c.name.len()) + .max() + .unwrap_or(4) + .max(4); + let bin_w = groups + .iter() + .flat_map(|g| &g.checks) + .map(|c| c.bin_name.len()) + .max() + .unwrap_or(6) + .max(6); + + let mut lines = 0usize; + write!( + stdout, + " {:<5} {:" } else { " " }; + let speed = if check.category == Category::Slow { + "slow" + } else { + "fast" + }; + let patterns = check.patterns.join(" "); + write!( + stdout, + " {} {} {: Date: Tue, 7 Apr 2026 10:19:52 +0000 Subject: [PATCH 085/141] =?UTF-8?q?test(init):=20remove=20has=5Fslow=5Fsel?= =?UTF-8?q?ected=20test=20=E2=80=94=20no=20slow=20checks=20currently=20exi?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index bc8a83f..9339641 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,6 @@ being linted and cannot be redirected via a flag. - | Name | Binary | Patterns | Fix | Slow | Scope | Config file | Notes | | ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | --------------------------------------------- | | `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | — | @@ -246,7 +245,6 @@ being linted and cannot be redirected via a flag. | `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | — | | `renovate-deps` | `renovate` | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | yes | — | special | — | — | | `license-header` | (built-in) | (all files) | no | — | special | — | — | - From 16c0940463b25d11dbb3957ec374645696b227b9 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 10:38:34 +0000 Subject: [PATCH 086/141] feat(init): patch renovate config to add flint preset extends entry When `flint init` finds a renovate config (any of the supported paths), it adds `github>grafana/flint#vX.Y.Z` to the `extends` array so the project gets automatic renovate tracking of flint version updates. Works for both JSON and JSON5 config files using text-based insertion to preserve comments and formatting. --- README.md | 2 + src/init/generation.rs | 124 +++++++++++++++++++++++++++++++++++++++++ src/init/mod.rs | 33 +++++++++-- 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9339641..bc8a83f 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ being linted and cannot be redirected via a flag. + | Name | Binary | Patterns | Fix | Slow | Scope | Config file | Notes | | ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | --------------------------------------------- | | `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | — | @@ -245,6 +246,7 @@ being linted and cannot be redirected via a flag. | `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | — | | `renovate-deps` | `renovate` | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | yes | — | special | — | — | | `license-header` | (built-in) | (all files) | no | — | special | — | — | + diff --git a/src/init/generation.rs b/src/init/generation.rs index 14c7a50..e4827a9 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -5,6 +5,85 @@ use std::process::Command; use super::LinterGroup; +/// Returns the renovate preset entry to inject, e.g. `github>grafana/flint#v0.9.2`. +/// Pre-release suffixes are stripped so dev builds produce a valid tag reference. +pub(super) fn flint_preset() -> String { + let ver = env!("CARGO_PKG_VERSION"); + let ver = ver.split('-').next().unwrap_or(ver); + format!("github>grafana/flint#v{ver}") +} + +/// Adds the flint renovate preset to the `extends` array in a renovate config file. +/// Works for both JSON and JSON5. Returns `true` if the file was changed. +pub(super) fn patch_renovate_extends(path: &Path) -> Result { + let entry = flint_preset(); + let content = std::fs::read_to_string(path)?; + + if content.contains(&entry) { + return Ok(false); + } + + let new_content = add_to_extends(&content, &entry) + .with_context(|| format!("failed to patch extends in {}", path.display()))?; + + std::fs::write(path, new_content)?; + Ok(true) +} + +/// Text-based insertion of `entry` into the `extends` array. +/// Works for both JSON (`"extends": [`) and JSON5 (`extends: [`). +fn add_to_extends(content: &str, entry: &str) -> Result { + let re = regex::Regex::new(r#"(?:"extends"|extends)\s*:\s*\["#).unwrap(); + + if let Some(m) = re.find(content) { + let bracket_pos = m.end() - 1; // index of '[' + let inside_start = bracket_pos + 1; + + let close_offset = content[inside_start..] + .find(']') + .context("extends array has no closing ]")?; + let close_pos = inside_start + close_offset; + let inside = &content[inside_start..close_pos]; + + if inside.contains('\n') { + // Multiline: detect indent from first non-empty line, insert at top + let indent = inside + .lines() + .find(|l| !l.trim().is_empty()) + .map(|l| " ".repeat(l.len() - l.trim_start().len())) + .unwrap_or_else(|| " ".to_string()); + Ok(format!( + "{}\n{}\"{}\"{}{}", + &content[..inside_start], + indent, + entry, + ",", + &content[inside_start..] + )) + } else { + // Single-line (empty or not): prepend entry + let sep = if inside.trim().is_empty() { "" } else { ", " }; + Ok(format!( + "{}\"{}\"{}{}", + &content[..inside_start], + entry, + sep, + &content[inside_start..] + )) + } + } else { + // No extends key — add after the opening { + let open = content + .find('{') + .context("no opening { in renovate config")?; + let (before, after) = content.split_at(open + 1); + Ok(format!( + "{}\n \"extends\": [\"{}\"],{}", + before, entry, after + )) + } +} + pub(super) fn apply_changes( path: &Path, current_content: &str, @@ -340,3 +419,48 @@ pub(super) fn maybe_install_hook(project_root: &Path, hook_task: &str, yes: bool } Ok(()) } + +#[cfg(test)] +mod extends_tests { + use super::add_to_extends; + + #[test] + fn adds_to_single_line_extends() { + let input = r#"{ "extends": ["config:recommended"], "other": 1 }"#; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains(r#"["github>grafana/flint#v0.9.2", "config:recommended"]"#)); + } + + #[test] + fn adds_to_json5_unquoted_key() { + let input = "{\n extends: [\"config:recommended\"],\n}\n"; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains(r#""github>grafana/flint#v0.9.2", "config:recommended""#)); + } + + #[test] + fn adds_to_multiline_extends() { + let input = "{\n extends: [\n \"config:recommended\",\n \"other\"\n ]\n}\n"; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains("\"github>grafana/flint#v0.9.2\",")); + // Entry should appear before existing entries + let flint_pos = result.find("grafana/flint").unwrap(); + let existing_pos = result.find("config:recommended").unwrap(); + assert!(flint_pos < existing_pos); + } + + #[test] + fn adds_extends_when_absent() { + let input = "{\n \"branchPrefix\": \"renovate/\"\n}\n"; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains("\"extends\"")); + assert!(result.contains("github>grafana/flint#v0.9.2")); + } + + #[test] + fn adds_to_empty_extends_array() { + let input = r#"{ "extends": [] }"#; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains(r#"["github>grafana/flint#v0.9.2"]"#)); + } +} diff --git a/src/init/mod.rs b/src/init/mod.rs index 0752ae0..e9a85da 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; -use std::collections::HashSet; #[cfg(test)] use std::collections::HashMap; +use std::collections::HashSet; use std::path::Path; use crate::registry::{Category, Check, builtin}; @@ -12,9 +12,9 @@ mod ui; use detection::{build_linter_groups, detect_present_patterns, parse_tool_keys}; use generation::{ - apply_changes, apply_env_and_tasks, detect_base_branch, generate_flint_toml, + apply_changes, apply_env_and_tasks, detect_base_branch, flint_preset, generate_flint_toml, generate_lint_workflow, get_existing_config_dir, has_slow_selected, maybe_install_hook, - prompt_config_dir, + patch_renovate_extends, prompt_config_dir, }; use ui::{interactive_select_linters, select_categories_arrow}; @@ -237,7 +237,24 @@ Add and stage your source files before running init so the detection is accurate let toml_generated = generate_flint_toml(&config_dir_path, &base_branch, has_renovate)?; let workflow_generated = generate_lint_workflow(project_root, &base_branch)?; - if !tools_changed && !meta_changed && !toml_generated && !workflow_generated { + let renovate_patched = find_renovate_config(project_root) + .map(|path| { + let result = patch_renovate_extends(&path); + if let Ok(true) = result { + let rel = path.strip_prefix(project_root).unwrap_or(&path); + println!(" patched {} — added {}", rel.display(), flint_preset()); + } + result + }) + .transpose()? + .unwrap_or(false); + + if !tools_changed + && !meta_changed + && !toml_generated + && !workflow_generated + && !renovate_patched + { println!("No changes to apply."); return Ok(()); } @@ -249,6 +266,13 @@ Add and stage your source files before running init so the detection is accurate Ok(()) } +fn find_renovate_config(project_root: &Path) -> Option { + crate::linters::renovate_deps::RENOVATE_CONFIG_PATTERNS + .iter() + .map(|p| project_root.join(p)) + .find(|p| p.exists()) +} + /// Returns the canonical mise.toml tool key to write when installing this check /// via `flint init`, or `None` if no mise entry is needed (built-in or /// unconditionally active checks). @@ -460,7 +484,6 @@ rust = { version = "1.0", components = "clippy" } assert!(!tools.contains_key("npm:renovate")); } - #[test] fn has_slow_selected_false_for_default_profile() { let registry = builtin(); From d29b96acd19b86015a2a14c6b94acce4a7b0cfd1 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 11:05:07 +0000 Subject: [PATCH 087/141] feat(init): remove outdated linters and v1 HTTP task entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds OBSOLETE_KEYS registry (npm:markdownlint-cli → npm:markdownlint-cli2); init removes the old key and the replacement appears as a new "add" - Removes [tasks.*] entries whose `file` points to raw.githubusercontent.com/grafana/flint/ - When a v1 renovate-deps task is removed, also removes RENOVATE_TRACKED_DEPS_EXCLUDE from [env] --- src/init/detection.rs | 13 ++- src/init/generation.rs | 180 +++++++++++++++++++++++++++++++++++++++++ src/init/mod.rs | 50 +++++++++++- src/registry.rs | 9 +++ 4 files changed, 249 insertions(+), 3 deletions(-) diff --git a/src/init/detection.rs b/src/init/detection.rs index aa77f19..992d097 100644 --- a/src/init/detection.rs +++ b/src/init/detection.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; use std::process::Command; -use crate::registry::{Category, Check}; +use crate::registry::{Category, Check, OBSOLETE_KEYS}; use super::{LinterGroup, install_key}; @@ -93,6 +93,17 @@ pub(super) fn get_entry_components(content: &str, key: &str) -> Option { } } +/// Returns the subset of `OBSOLETE_KEYS` whose old key is present in `current_tool_keys`. +pub(super) fn detect_obsolete_keys( + current_tool_keys: &HashSet, +) -> Vec<(&'static str, &'static str)> { + OBSOLETE_KEYS + .iter() + .filter(|(old, _)| current_tool_keys.contains(*old)) + .copied() + .collect() +} + /// Builds one `LinterGroup` per install key, covering all checks whose file patterns /// are present in the repo or whose key is already installed. pub(super) fn build_linter_groups<'a>( diff --git a/src/init/generation.rs b/src/init/generation.rs index e4827a9..8bb364b 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -152,6 +152,81 @@ pub(super) fn apply_changes( Ok(()) } +const FLINT_V1_URL_PREFIX: &str = "https://raw.githubusercontent.com/grafana/flint/"; + +pub(super) struct V1Removal { + /// Task keys that were removed from `[tasks]`. + pub removed_tasks: Vec, + /// Whether `RENOVATE_TRACKED_DEPS_EXCLUDE` was removed from `[env]`. + pub removed_renovate_env: bool, +} + +/// Removes v1 HTTP task entries (tasks whose `file` value starts with the +/// flint raw-GitHub URL) and, when a renovate-deps v1 task is present, +/// also removes `RENOVATE_TRACKED_DEPS_EXCLUDE` from `[env]`. +/// +/// Returns details about what was removed. Writes the file only when changed. +pub(super) fn remove_v1_tasks(path: &Path) -> Result { + let content = std::fs::read_to_string(path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + + let mut removed_tasks: Vec = Vec::new(); + let mut has_v1_renovate = false; + + if let Some(tasks) = doc.get_mut("tasks").and_then(|t| t.as_table_mut()) { + let keys_to_remove: Vec = tasks + .iter() + .filter_map(|(key, item)| { + let file_val = item + .as_table() + .and_then(|t| t.get("file")) + .and_then(|v| v.as_str())?; + if file_val.starts_with(FLINT_V1_URL_PREFIX) { + Some(key.to_string()) + } else { + None + } + }) + .collect(); + + for key in keys_to_remove { + // Check if it's a renovate-deps task before removing. + if let Some(file_val) = tasks + .get(&key) + .and_then(|i| i.as_table()) + .and_then(|t| t.get("file")) + .and_then(|v| v.as_str()) + && file_val.contains("renovate-deps") + { + has_v1_renovate = true; + } + tasks.remove(&key); + removed_tasks.push(key); + } + } + + let mut removed_renovate_env = false; + if has_v1_renovate + && let Some(env) = doc.get_mut("env").and_then(|t| t.as_table_mut()) + && env.contains_key("RENOVATE_TRACKED_DEPS_EXCLUDE") + { + env.remove("RENOVATE_TRACKED_DEPS_EXCLUDE"); + removed_renovate_env = true; + } + + if !removed_tasks.is_empty() || removed_renovate_env { + std::fs::write(path, doc.to_string())?; + } + + removed_tasks.sort(); + Ok(V1Removal { + removed_tasks, + removed_renovate_env, + }) +} + /// Returns true if any currently-selected check has `Category::Slow`. pub(super) fn has_slow_selected(groups: &[LinterGroup]) -> bool { use crate::registry::Category; @@ -420,6 +495,111 @@ pub(super) fn maybe_install_hook(project_root: &Path, hook_task: &str, yes: bool Ok(()) } +#[cfg(test)] +mod v1_removal_tests { + use super::remove_v1_tasks; + + fn write_tmp(content: &str) -> tempfile::NamedTempFile { + let f = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(f.path(), content).unwrap(); + f + } + + #[test] + fn removes_v1_http_tasks() { + let content = r#" +[tools] +lychee = "latest" + +[tasks."lint:links"] +description = "Check for broken links" +file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/links.sh" + +[tasks."lint:renovate-deps"] +description = "Check renovate deps" +file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/renovate-deps.py" + +[tasks.build] +description = "Build the project" +run = "cargo build" +"#; + let tmp = write_tmp(content); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert_eq!(result.removed_tasks, ["lint:links", "lint:renovate-deps"]); + assert!(!result.removed_renovate_env); + let after = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(!after.contains("lint:links")); + assert!(!after.contains("lint:renovate-deps")); + assert!(after.contains("[tasks.build]"), "non-v1 tasks preserved"); + } + + #[test] + fn removes_renovate_env_when_v1_renovate_task_present() { + let content = r#" +[env] +RENOVATE_TRACKED_DEPS_EXCLUDE = "github-actions,github-runners" + +[tasks."lint:renovate-deps"] +description = "Check renovate deps" +file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/renovate-deps.py" +"#; + let tmp = write_tmp(content); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert_eq!(result.removed_tasks, ["lint:renovate-deps"]); + assert!(result.removed_renovate_env); + let after = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(!after.contains("RENOVATE_TRACKED_DEPS_EXCLUDE")); + } + + #[test] + fn does_not_remove_renovate_env_without_v1_renovate_task() { + let content = r#" +[env] +RENOVATE_TRACKED_DEPS_EXCLUDE = "github-actions" + +[tasks."lint:links"] +description = "Check links" +file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/links.sh" +"#; + let tmp = write_tmp(content); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert_eq!(result.removed_tasks, ["lint:links"]); + assert!(!result.removed_renovate_env); + let after = std::fs::read_to_string(tmp.path()).unwrap(); + assert!( + after.contains("RENOVATE_TRACKED_DEPS_EXCLUDE"), + "env var preserved when no renovate task" + ); + } + + #[test] + fn no_op_when_no_v1_tasks() { + let content = "[tools]\nlychee = \"latest\"\n"; + let tmp = write_tmp(content); + let original_mtime = std::fs::metadata(tmp.path()).unwrap().modified().unwrap(); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert!(result.removed_tasks.is_empty()); + assert!(!result.removed_renovate_env); + // File should not have been written. + let new_mtime = std::fs::metadata(tmp.path()).unwrap().modified().unwrap(); + assert_eq!( + original_mtime, new_mtime, + "file unchanged when nothing to remove" + ); + } + + #[test] + fn ignores_non_flint_http_tasks() { + let content = r#" +[tasks."lint:something"] +file = "https://raw.githubusercontent.com/some-other-org/some-repo/abc123/task.sh" +"#; + let tmp = write_tmp(content); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert!(result.removed_tasks.is_empty()); + } +} + #[cfg(test)] mod extends_tests { use super::add_to_extends; diff --git a/src/init/mod.rs b/src/init/mod.rs index e9a85da..4e23bf5 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -10,11 +10,13 @@ mod detection; mod generation; mod ui; -use detection::{build_linter_groups, detect_present_patterns, parse_tool_keys}; +use detection::{ + build_linter_groups, detect_obsolete_keys, detect_present_patterns, parse_tool_keys, +}; use generation::{ apply_changes, apply_env_and_tasks, detect_base_branch, flint_preset, generate_flint_toml, generate_lint_workflow, get_existing_config_dir, has_slow_selected, maybe_install_hook, - patch_renovate_extends, prompt_config_dir, + patch_renovate_extends, prompt_config_dir, remove_v1_tasks, }; use ui::{interactive_select_linters, select_categories_arrow}; @@ -181,6 +183,13 @@ Add and stage your source files before running init so the detection is accurate return Ok(()); } + // Detect obsolete tool keys (e.g. npm:markdownlint-cli → npm:markdownlint-cli2). + // These are removed regardless of the interactive selection — keeping them serves no purpose. + let obsolete = detect_obsolete_keys(¤t_tool_keys); + for (old_key, replacement) in &obsolete { + println!(" removing obsolete linter {old_key} (replaced by {replacement})"); + } + // Derive changes from final selection state. let mut final_add: Vec<(String, Option)> = Vec::new(); let mut final_remove: Vec = Vec::new(); @@ -206,6 +215,11 @@ Add and stage your source files before running init so the detection is accurate } } + // Always remove obsolete tool keys (detected before the interactive selection). + for (old_key, _) in &obsolete { + final_remove.push(old_key.to_string()); + } + let has_slow = has_slow_selected(&groups); let has_renovate = groups.iter().any(|g| { g.checks @@ -230,6 +244,14 @@ Add and stage your source files before running init so the detection is accurate )?; } + let v1 = remove_v1_tasks(&mise_path)?; + for key in &v1.removed_tasks { + println!(" removing v1 task {key}"); + } + if v1.removed_renovate_env { + println!(" removing RENOVATE_TRACKED_DEPS_EXCLUDE from [env] (use flint.toml instead)"); + } + let meta_changed = apply_env_and_tasks(&mise_path, &config_dir_rel, has_slow)?; let base_branch = detect_base_branch(project_root); @@ -250,6 +272,8 @@ Add and stage your source files before running init so the detection is accurate .unwrap_or(false); if !tools_changed + && v1.removed_tasks.is_empty() + && !v1.removed_renovate_env && !meta_changed && !toml_generated && !workflow_generated @@ -343,6 +367,28 @@ mod tests { get_existing_config_dir, has_slow_selected, }; + #[test] + fn detect_obsolete_keys_finds_known_stale_key() { + use detection::detect_obsolete_keys; + let mut keys = HashSet::new(); + keys.insert("npm:markdownlint-cli".to_string()); + keys.insert("shellcheck".to_string()); + let found = detect_obsolete_keys(&keys); + assert_eq!(found.len(), 1); + assert_eq!(found[0].0, "npm:markdownlint-cli"); + assert_eq!(found[0].1, "npm:markdownlint-cli2"); + } + + #[test] + fn detect_obsolete_keys_ignores_current_keys() { + use detection::detect_obsolete_keys; + let mut keys = HashSet::new(); + keys.insert("npm:markdownlint-cli2".to_string()); + keys.insert("shellcheck".to_string()); + let found = detect_obsolete_keys(&keys); + assert!(found.is_empty()); + } + #[test] fn all_registry_checks_have_install_key_or_none() { // Every check that uses a binary and isn't unconditional must have a resolvable key. diff --git a/src/registry.rs b/src/registry.rs index c560de2..8ae44eb 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -489,6 +489,15 @@ pub fn builtin() -> Vec { ] } +/// Mise tool keys that are no longer supported by flint and should be removed +/// during `flint init`. Each entry is `(old_key, replacement_key)` where +/// `replacement_key` is the modern equivalent that the registry now uses. +pub const OBSOLETE_KEYS: &[(&str, &str)] = &[ + // markdownlint-cli was superseded by markdownlint-cli2 (actively maintained, + // faster, supports the same config files). flint only supports the cli2 variant. + ("npm:markdownlint-cli", "npm:markdownlint-cli2"), +]; + /// Reads `[tools]` from the consuming repo's mise.toml and returns a map of /// tool name → declared version string. /// From aaab364d6a0e61aff4e95596e10cd8d948834051 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 11:20:51 +0000 Subject: [PATCH 088/141] feat(init): migrate RENOVATE_TRACKED_DEPS_EXCLUDE to flint.toml on init When a v1 renovate-deps HTTP task is removed, capture the manager list from RENOVATE_TRACKED_DEPS_EXCLUDE and write it directly into [checks.renovate-deps] exclude_managers in flint.toml instead of a commented-out placeholder. Shrinks MIGRATION.md from 7 steps to 3. --- MIGRATION.md | 110 +++++------------------------------------ src/init/generation.rs | 41 +++++++++++++-- src/init/mod.rs | 31 +++++++++--- 3 files changed, 75 insertions(+), 107 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index b8fd308..4bd525e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,116 +5,30 @@ flint v2 replaces the HTTP remote tasks with a single `flint` binary that discovers linters from your `mise.toml` and runs them against changed files. -### 1. Remove the v1 task entries from `mise.toml` - -Remove all task entries that reference remote flint task scripts: - -```toml -# Remove these: -[tasks."lint:links"] -file = "https://raw.githubusercontent.com/grafana/flint/..." -[tasks."lint:renovate-deps"] -file = "https://raw.githubusercontent.com/grafana/flint/..." -``` - -Also remove any hand-rolled style lint scripts that delegate to individual -linters (shfmt, prettier, markdownlint, actionlint, codespell, -editorconfig-checker) — flint v2 handles all of these automatically based on -what is declared in `[tools]`. - -### 2. Add `flint` as a tool +### 1. Add `flint` as a tool ```toml [tools] "ubi:grafana/flint" = "0.20.0-alpha.1" ``` -### 3. Run `flint init` +### 2. Run `flint init` -After installing flint (`mise install`), run `flint init`. It detects your -languages from tracked files and takes care of: +After installing flint (`mise install`), run `flint init`. It automatically: -- adding linters to `[tools]` -- adding `[env] FLINT_CONFIG_DIR` pointing to your chosen config dir -- adding `lint`, `lint:fix`, `lint:pre-commit`, and `setup:pre-commit-hook` - tasks to `[tasks]` -- writing a `flint.toml` skeleton in your config dir -- generating `.github/workflows/lint.yml` +- removes v1 HTTP task entries from `[tasks]` +- removes `RENOVATE_TRACKED_DEPS_EXCLUDE` from `[env]` and migrates the manager list to `flint.toml` (when a v1 renovate-deps task is present) +- replaces `npm:markdownlint-cli` with `npm:markdownlint-cli2` in `[tools]` +- adds the missing linters to `[tools]` based on your tracked files +- adds `[env] FLINT_CONFIG_DIR` and standard `lint*` / `setup:pre-commit-hook` tasks +- writes a `flint.toml` skeleton in your chosen config dir +- generates `.github/workflows/lint.yml` +- patches `renovate.json5` to add the flint preset Then run `mise install` to install the new tools and `mise run setup:pre-commit-hook` to install the git hook. -### 4. Switch `markdownlint-cli` to `markdownlint-cli2` - -flint v2 only supports `markdownlint-cli2`. `flint init` selects it for new -installs, but for an existing repo you need to rename the key manually: - -```toml -# Before: -"npm:markdownlint-cli" = "0.48.0" -# After: -"npm:markdownlint-cli2" = "0.17.2" -``` - -Configuration files remain compatible — both tools read `.markdownlint.json` -(and `.markdownlint.yaml`, `.markdownlint.jsonc`). No changes to your config -file are required. - -### 5. Move renovate-deps config to `flint.toml` - -If you previously used the `RENOVATE_TRACKED_DEPS_EXCLUDE` env var to exclude -managers, remove it from `[env]` in `mise.toml` and uncomment the -`exclude_managers` line that `flint init` wrote to your `flint.toml`: - -```toml -[checks.renovate-deps] -exclude_managers = ["github-actions", "github-runners", "cargo"] -``` - -### 6. Add the flint renovate preset to `renovate.json5` - -Add `"github>grafana/flint#v"` to the `extends` list in your -`renovate.json5`. This lets renovate keep the flint binary version up to date -automatically: - -```json5 -{ - extends: [ - "config:recommended", - "github>grafana/flint#v0.20.0", - // ... - ], -} -``` - -Replace `v0.20.0` with the version you pinned in `[tools]`. - -### 7. Verify active linters +### 3. Verify active linters Run `flint linters` to confirm flint detects all the tools declared in your `mise.toml`. Any tool listed as `missing` is not declared and will be skipped. - -## Replacing `markdownlint-cli` with `markdownlint-cli2` - -`markdownlint-cli2` is the actively maintained successor to `markdownlint-cli`. -It is faster, supports more configuration options, and is the direction the -markdownlint ecosystem is moving. flint only supports `markdownlint-cli2`. - -**Before** (`mise.toml`): - -```toml -"npm:markdownlint-cli" = "0.47.0" -``` - -**After**: - -```toml -"npm:markdownlint-cli2" = "0.17.2" -``` - -Configuration files remain compatible — both tools read `.markdownlint.json` -(and `.markdownlint.yaml`, `.markdownlint.jsonc`). No changes to your config -file are required. - -The fix command changes from `markdownlint --fix` to `markdownlint-cli2 --fix`, -but flint handles this automatically. diff --git a/src/init/generation.rs b/src/init/generation.rs index 8bb364b..02d3d36 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -159,6 +159,8 @@ pub(super) struct V1Removal { pub removed_tasks: Vec, /// Whether `RENOVATE_TRACKED_DEPS_EXCLUDE` was removed from `[env]`. pub removed_renovate_env: bool, + /// The manager list from `RENOVATE_TRACKED_DEPS_EXCLUDE`, split on commas, if it was present. + pub renovate_exclude_managers: Option>, } /// Removes v1 HTTP task entries (tasks whose `file` value starts with the @@ -208,10 +210,20 @@ pub(super) fn remove_v1_tasks(path: &Path) -> Result { } let mut removed_renovate_env = false; + let mut renovate_exclude_managers: Option> = None; if has_v1_renovate && let Some(env) = doc.get_mut("env").and_then(|t| t.as_table_mut()) - && env.contains_key("RENOVATE_TRACKED_DEPS_EXCLUDE") + && let Some(val) = env + .get("RENOVATE_TRACKED_DEPS_EXCLUDE") + .and_then(|v| v.as_str()) { + renovate_exclude_managers = Some( + val.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + ); env.remove("RENOVATE_TRACKED_DEPS_EXCLUDE"); removed_renovate_env = true; } @@ -224,6 +236,7 @@ pub(super) fn remove_v1_tasks(path: &Path) -> Result { Ok(V1Removal { removed_tasks, removed_renovate_env, + renovate_exclude_managers, }) } @@ -304,10 +317,15 @@ pub(super) fn prompt_config_dir(existing: Option<&str>, yes: bool) -> Result, ) -> Result { let toml_path = config_dir.join("flint.toml"); if toml_path.exists() { @@ -322,7 +340,17 @@ pub(super) fn generate_flint_toml( content.push_str("# exclude_paths = []\n"); if has_renovate { content.push_str("\n[checks.renovate-deps]\n"); - content.push_str("# exclude_managers = []\n"); + match exclude_managers { + Some(managers) if !managers.is_empty() => { + let list = managers + .iter() + .map(|m| format!("\"{m}\"")) + .collect::>() + .join(", "); + content.push_str(&format!("exclude_managers = [{list}]\n")); + } + _ => content.push_str("# exclude_managers = []\n"), + } } std::fs::write(&toml_path, &content)?; println!(" wrote {}", toml_path.display()); @@ -537,7 +565,7 @@ run = "cargo build" fn removes_renovate_env_when_v1_renovate_task_present() { let content = r#" [env] -RENOVATE_TRACKED_DEPS_EXCLUDE = "github-actions,github-runners" +RENOVATE_TRACKED_DEPS_EXCLUDE = "github-actions, github-runners" [tasks."lint:renovate-deps"] description = "Check renovate deps" @@ -547,6 +575,13 @@ file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/renova let result = remove_v1_tasks(tmp.path()).unwrap(); assert_eq!(result.removed_tasks, ["lint:renovate-deps"]); assert!(result.removed_renovate_env); + assert_eq!( + result.renovate_exclude_managers, + Some(vec![ + "github-actions".to_string(), + "github-runners".to_string() + ]) + ); let after = std::fs::read_to_string(tmp.path()).unwrap(); assert!(!after.contains("RENOVATE_TRACKED_DEPS_EXCLUDE")); } diff --git a/src/init/mod.rs b/src/init/mod.rs index 4e23bf5..1ad3e23 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -256,7 +256,12 @@ Add and stage your source files before running init so the detection is accurate let base_branch = detect_base_branch(project_root); let config_dir_path = project_root.join(&config_dir_rel); - let toml_generated = generate_flint_toml(&config_dir_path, &base_branch, has_renovate)?; + let toml_generated = generate_flint_toml( + &config_dir_path, + &base_branch, + has_renovate, + v1.renovate_exclude_managers.as_deref(), + )?; let workflow_generated = generate_lint_workflow(project_root, &base_branch)?; let renovate_patched = find_renovate_config(project_root) @@ -558,7 +563,7 @@ rust = { version = "1.0", components = "clippy" } fn generate_flint_toml_writes_skeleton() { let tmp = tempfile::TempDir::new().unwrap(); let dir = tmp.path().join("config"); - let written = generate_flint_toml(&dir, "main", false).unwrap(); + let written = generate_flint_toml(&dir, "main", false, None).unwrap(); assert!(written); let content = std::fs::read_to_string(dir.join("flint.toml")).unwrap(); assert!(content.contains("[settings]")); @@ -570,26 +575,40 @@ rust = { version = "1.0", components = "clippy" } #[test] fn generate_flint_toml_non_main_branch() { let tmp = tempfile::TempDir::new().unwrap(); - let written = generate_flint_toml(tmp.path(), "master", false).unwrap(); + let written = generate_flint_toml(tmp.path(), "master", false, None).unwrap(); assert!(written); let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); assert!(content.contains("base_branch = \"master\"")); } #[test] - fn generate_flint_toml_with_renovate() { + fn generate_flint_toml_with_renovate_placeholder() { let tmp = tempfile::TempDir::new().unwrap(); - generate_flint_toml(tmp.path(), "main", true).unwrap(); + generate_flint_toml(tmp.path(), "main", true, None).unwrap(); let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); assert!(content.contains("[checks.renovate-deps]")); assert!(content.contains("# exclude_managers =")); } + #[test] + fn generate_flint_toml_with_renovate_managers() { + let tmp = tempfile::TempDir::new().unwrap(); + let managers = vec!["github-actions".to_string(), "cargo".to_string()]; + generate_flint_toml(tmp.path(), "main", true, Some(&managers)).unwrap(); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert!(content.contains("[checks.renovate-deps]")); + assert!( + content.contains("exclude_managers = [\"github-actions\", \"cargo\"]"), + "managers written uncommented: {content}" + ); + assert!(!content.contains("# exclude_managers")); + } + #[test] fn generate_flint_toml_skips_existing() { let tmp = tempfile::TempDir::new().unwrap(); std::fs::write(tmp.path().join("flint.toml"), "existing content").unwrap(); - let written = generate_flint_toml(tmp.path(), "main", false).unwrap(); + let written = generate_flint_toml(tmp.path(), "main", false, None).unwrap(); assert!(!written); let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); assert_eq!(content, "existing content"); From 0834ac7a541657ae435c288e57609ec1c1e36dd4 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 12:33:09 +0000 Subject: [PATCH 089/141] fix(init): replace stale lint tasks and fix renovate preset duplication - patch_renovate_extends: replace existing flint entry in-place instead of prepending a duplicate when an unpinned or differently-pinned entry already exists - apply_env_and_tasks: replace the lint task when its depends array references a removed v1 task; also replace setup:pre-commit-hook when lint was stale so the hook points at the new task name - add removed_v1_tasks parameter to apply_env_and_tasks so callers can communicate what was just removed --- src/init/generation.rs | 138 +++++++++++++++++++++++++++++++++++++---- src/init/mod.rs | 59 ++++++++++++++++-- 2 files changed, 180 insertions(+), 17 deletions(-) diff --git a/src/init/generation.rs b/src/init/generation.rs index 02d3d36..36a574d 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -14,7 +14,9 @@ pub(super) fn flint_preset() -> String { } /// Adds the flint renovate preset to the `extends` array in a renovate config file. -/// Works for both JSON and JSON5. Returns `true` if the file was changed. +/// Works for both JSON and JSON5. If an unpinned or differently-pinned flint entry +/// already exists, it is replaced in-place rather than duplicated. +/// Returns `true` if the file was changed. pub(super) fn patch_renovate_extends(path: &Path) -> Result { let entry = flint_preset(); let content = std::fs::read_to_string(path)?; @@ -23,8 +25,19 @@ pub(super) fn patch_renovate_extends(path: &Path) -> Result { return Ok(false); } - let new_content = add_to_extends(&content, &entry) - .with_context(|| format!("failed to patch extends in {}", path.display()))?; + // If an existing flint entry (any pin) is present, replace it in-place. + const FLINT_ENTRY_PREFIX: &str = "\"github>grafana/flint"; + let new_content = if let Some(pos) = content.find(FLINT_ENTRY_PREFIX) { + let after_open = pos + 1; // skip leading " + let close = content[after_open..] + .find('"') + .context("unclosed quote in existing flint preset entry")?; + let end = after_open + close + 1; // position after closing " + format!("{}\"{}\"{}", &content[..pos], entry, &content[end..]) + } else { + add_to_extends(&content, &entry) + .with_context(|| format!("failed to patch extends in {}", path.display()))? + }; std::fs::write(path, new_content)?; Ok(true) @@ -417,21 +430,49 @@ fn add_task_if_absent( if tasks.contains_key(name) { return false; } + write_task(tasks, name, description, run); + true +} + +/// Unconditionally writes a `[tasks.]` entry (adds or replaces). +fn write_task(tasks: &mut toml_edit::Table, name: &str, description: &str, run: &str) { let mut t = toml_edit::Table::new(); t.insert("description", toml_edit::value(description)); t.insert("run", toml_edit::value(run)); tasks.insert(name, toml_edit::Item::Table(t)); - true +} + +/// Returns `true` when the named task has a `depends` array where at least one +/// entry is in `removed_tasks`. Used to detect tasks made stale by v1 removal. +fn task_has_removed_dep(tasks: &toml_edit::Table, name: &str, removed: &[String]) -> bool { + let Some(item) = tasks.get(name) else { + return false; + }; + let Some(task) = item.as_table() else { + return false; + }; + let Some(depends) = task.get("depends").and_then(|v| v.as_array()) else { + return false; + }; + depends.iter().any(|v| { + v.as_str() + .map(|s| removed.iter().any(|r| r == s)) + .unwrap_or(false) + }) } /// Adds `[env] FLINT_CONFIG_DIR` and the standard `lint*` / `setup:pre-commit-hook` /// tasks to `mise.toml`, skipping any that are already present. /// +/// When `removed_v1_tasks` is non-empty, standard tasks whose `depends` reference +/// any of those removed tasks are replaced (they became stale after v1 removal). +/// /// Returns `true` if the file was changed. pub(super) fn apply_env_and_tasks( mise_path: &Path, config_dir_rel: &str, has_slow: bool, + removed_v1_tasks: &[String], ) -> Result { let content = std::fs::read_to_string(mise_path).unwrap_or_default(); let mut doc: toml_edit::DocumentMut = content @@ -460,7 +501,16 @@ pub(super) fn apply_env_and_tasks( .as_table_mut() .context("[tasks] is not a table")?; - changed |= add_task_if_absent(tasks, "lint", "Run all lints", "flint run"); + // Replace the lint task when it was made stale by v1 removal (its depends + // referenced removed tasks and would now fail). Otherwise add if absent. + let lint_stale = task_has_removed_dep(tasks, "lint", removed_v1_tasks); + if lint_stale { + write_task(tasks, "lint", "Run all lints", "flint run"); + changed = true; + } else { + changed |= add_task_if_absent(tasks, "lint", "Run all lints", "flint run"); + } + changed |= add_task_if_absent(tasks, "lint:fix", "Auto-fix lint issues", "flint run --fix"); if has_slow { changed |= add_task_if_absent( @@ -471,12 +521,23 @@ pub(super) fn apply_env_and_tasks( ); } let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; - changed |= add_task_if_absent( - tasks, - "setup:pre-commit-hook", - "Install git pre-commit hook", - &format!("mise generate git-pre-commit --write --task={hook_task}"), - ); + // Also replace setup:pre-commit-hook when the lint task was stale — the old hook + // was pointing at a v1-era task name and needs to be updated. + if lint_stale { + write_task( + tasks, + "setup:pre-commit-hook", + "Install git pre-commit hook", + &format!("mise generate git-pre-commit --write --task={hook_task}"), + ); + } else { + changed |= add_task_if_absent( + tasks, + "setup:pre-commit-hook", + "Install git pre-commit hook", + &format!("mise generate git-pre-commit --write --task={hook_task}"), + ); + } } if changed { @@ -637,7 +698,60 @@ file = "https://raw.githubusercontent.com/some-other-org/some-repo/abc123/task.s #[cfg(test)] mod extends_tests { - use super::add_to_extends; + use super::{add_to_extends, patch_renovate_extends}; + + fn write_tmp(content: &str) -> tempfile::NamedTempFile { + let f = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(f.path(), content).unwrap(); + f + } + + #[test] + fn replaces_unpinned_flint_entry_in_place() { + let input = r#"{ extends: ["config:recommended", "github>grafana/flint"] }"#; + let tmp = write_tmp(input); + let changed = patch_renovate_extends(tmp.path()).unwrap(); + assert!(changed); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!( + result.contains("github>grafana/flint#v"), + "pinned entry written: {result}" + ); + // Only one flint entry — no duplicate + assert_eq!( + result.matches("grafana/flint").count(), + 1, + "no duplicate: {result}" + ); + assert!( + !result.contains("\"github>grafana/flint\""), + "unpinned removed: {result}" + ); + } + + #[test] + fn replaces_differently_pinned_flint_entry() { + let input = r#"{ extends: ["config:recommended", "github>grafana/flint#v0.5.0"] }"#; + let tmp = write_tmp(input); + let changed = patch_renovate_extends(tmp.path()).unwrap(); + assert!(changed); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(!result.contains("v0.5.0"), "old pin removed: {result}"); + assert_eq!( + result.matches("grafana/flint").count(), + 1, + "no duplicate: {result}" + ); + } + + #[test] + fn no_op_when_already_pinned_to_current_version() { + let entry = super::flint_preset(); + let input = format!(r#"{{ extends: ["config:recommended", "{entry}"] }}"#); + let tmp = write_tmp(&input); + let changed = patch_renovate_extends(tmp.path()).unwrap(); + assert!(!changed); + } #[test] fn adds_to_single_line_extends() { diff --git a/src/init/mod.rs b/src/init/mod.rs index 1ad3e23..478ba6b 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -252,7 +252,8 @@ Add and stage your source files before running init so the detection is accurate println!(" removing RENOVATE_TRACKED_DEPS_EXCLUDE from [env] (use flint.toml instead)"); } - let meta_changed = apply_env_and_tasks(&mise_path, &config_dir_rel, has_slow)?; + let meta_changed = + apply_env_and_tasks(&mise_path, &config_dir_rel, has_slow, &v1.removed_tasks)?; let base_branch = detect_base_branch(project_root); let config_dir_path = project_root.join(&config_dir_rel); @@ -658,7 +659,7 @@ rust = { version = "1.0", components = "clippy" } fn apply_env_and_tasks_adds_sections() { let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), "[tools]\nrust = \"latest\"\n").unwrap(); - let changed = apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + let changed = apply_env_and_tasks(tmp.path(), ".github/config", false, &[]).unwrap(); assert!(changed); let content = std::fs::read_to_string(tmp.path()).unwrap(); assert!(content.contains("FLINT_CONFIG_DIR = \".github/config\"")); @@ -672,7 +673,7 @@ rust = { version = "1.0", components = "clippy" } fn apply_env_and_tasks_adds_pre_commit_task_when_slow() { let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), "").unwrap(); - apply_env_and_tasks(tmp.path(), ".", true).unwrap(); + apply_env_and_tasks(tmp.path(), ".", true, &[]).unwrap(); let content = std::fs::read_to_string(tmp.path()).unwrap(); assert!(content.contains("--fast-only")); assert!(content.contains("lint:pre-commit")); @@ -684,11 +685,59 @@ rust = { version = "1.0", components = "clippy" } fn apply_env_and_tasks_idempotent() { let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), "").unwrap(); - apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + apply_env_and_tasks(tmp.path(), ".github/config", false, &[]).unwrap(); let after_first = std::fs::read_to_string(tmp.path()).unwrap(); - let changed = apply_env_and_tasks(tmp.path(), ".github/config", false).unwrap(); + let changed = apply_env_and_tasks(tmp.path(), ".github/config", false, &[]).unwrap(); assert!(!changed); let after_second = std::fs::read_to_string(tmp.path()).unwrap(); assert_eq!(after_first, after_second); } + + #[test] + fn apply_env_and_tasks_replaces_stale_lint_task() { + let content = r#" +[tasks."lint"] +description = "Run all lints" +depends = ["lint:fast", "lint:renovate-deps"] +"#; + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), content).unwrap(); + let removed = vec!["lint:renovate-deps".to_string()]; + apply_env_and_tasks(tmp.path(), ".github/config", false, &removed).unwrap(); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!( + result.contains("run = \"flint run\""), + "stale lint task replaced: {result}" + ); + assert!( + !result.contains("depends"), + "old depends array removed: {result}" + ); + } + + #[test] + fn apply_env_and_tasks_replaces_stale_hook_task() { + let content = r#" +[tasks."lint"] +description = "Run all lints" +depends = ["lint:renovate-deps"] + +[tasks."setup:pre-commit-hook"] +description = "Install pre-commit hook" +run = "mise generate git-pre-commit --write --task=pre-commit" +"#; + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), content).unwrap(); + let removed = vec!["lint:renovate-deps".to_string()]; + apply_env_and_tasks(tmp.path(), ".github/config", false, &removed).unwrap(); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!( + result.contains("--task=lint"), + "hook task updated to lint: {result}" + ); + assert!( + !result.contains("--task=pre-commit"), + "old hook task reference removed: {result}" + ); + } } From c9d1f045695f4e004f856195ba63c8bdbe8e1465 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 13:33:29 +0000 Subject: [PATCH 090/141] feat(init): add flint itself to mise.toml and pin tools via mise use --pin - add_flint_tool: add github:grafana/flint to [tools] on init if not already present, using mise use --pin to resolve the latest release - apply_changes: call `mise use --pin @latest` for each new tool instead of writing "latest" directly; fall back to "latest" if mise cannot resolve the version (e.g. unknown tool or network unavailable) - pin_tool_via_mise: detect success by checking whether the key was written to the config file rather than relying on exit code, so post-write failures like shim rebuilds in restricted environments do not cause a fallback to "latest" --- src/init/generation.rs | 114 ++++++++++++++++++++++++++++++++++++++++- src/init/mod.rs | 13 +++-- 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/init/generation.rs b/src/init/generation.rs index 36a574d..0a00a57 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -4,6 +4,7 @@ use std::path::Path; use std::process::Command; use super::LinterGroup; +use super::detection::parse_tool_keys; /// Returns the renovate preset entry to inject, e.g. `github>grafana/flint#v0.9.2`. /// Pre-release suffixes are stripped so dev builds produce a valid tag reference. @@ -97,6 +98,26 @@ fn add_to_extends(content: &str, entry: &str) -> Result { } } +/// Runs `mise use --pin @latest` in the project directory to add a tool +/// with a pinned version. Returns `true` if the key was written to the config +/// (checked by re-reading the file), ignoring non-zero exit codes that arise +/// from post-write steps like shim rebuilds failing in restricted environments. +fn pin_tool_via_mise(project_root: &Path, key: &str) -> bool { + let mise_path = project_root.join("mise.toml"); + let before = std::fs::read_to_string(&mise_path).unwrap_or_default(); + + let _ = Command::new("mise") + .args(["use", "--pin", &format!("{key}@latest")]) + .current_dir(project_root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + + // Success = the key is now present in the config (regardless of exit code). + let after = std::fs::read_to_string(&mise_path).unwrap_or_default(); + after != before && parse_tool_keys(&after).contains(key) +} + pub(super) fn apply_changes( path: &Path, current_content: &str, @@ -104,6 +125,26 @@ pub(super) fn apply_changes( to_remove: &[String], to_upgrade: &[(String, String)], ) -> Result<()> { + let project_root = path.parent().unwrap_or(path); + + // Pin new tools via `mise use --pin`. For tools where mise succeeds the + // file is already updated; we still open the file below to handle removals, + // upgrades, and component additions. + let mut pinned_via_mise: std::collections::HashSet = std::collections::HashSet::new(); + for (key, _) in to_add { + if pin_tool_via_mise(project_root, key) { + pinned_via_mise.insert(key.clone()); + } else { + eprintln!(" warning: could not pin {key} via mise — writing \"latest\""); + } + } + + // Re-read the file only if mise actually modified it. + let current_content: String = if pinned_via_mise.is_empty() { + current_content.to_string() + } else { + std::fs::read_to_string(path).unwrap_or_else(|_| current_content.to_string()) + }; let mut doc: toml_edit::DocumentMut = current_content .parse() .unwrap_or_else(|_| toml_edit::DocumentMut::new()); @@ -121,10 +162,29 @@ pub(super) fn apply_changes( } for (key, components) in to_add { + let already_pinned = pinned_via_mise.contains(key.as_str()); match components { Some(comps) => { + // If mise already wrote a plain-string version, upgrade to inline + // table to attach the components field. + let existing_version = if already_pinned { + tools + .get(key.as_str()) + .and_then(|i| i.as_value()) + .and_then(|v| match v { + toml_edit::Value::String(s) => Some(s.value().to_string()), + toml_edit::Value::InlineTable(t) => t + .get("version") + .and_then(|v| v.as_str()) + .map(str::to_string), + _ => None, + }) + .unwrap_or_else(|| "latest".to_string()) + } else { + "latest".to_string() + }; let mut tbl = toml_edit::InlineTable::new(); - tbl.insert("version", toml_edit::Value::from("latest")); + tbl.insert("version", toml_edit::Value::from(existing_version.as_str())); tbl.insert("components", toml_edit::Value::from(comps.as_str())); tools.insert( key.as_str(), @@ -132,7 +192,10 @@ pub(super) fn apply_changes( ); } None => { - tools.insert(key.as_str(), toml_edit::value("latest")); + if !already_pinned { + tools.insert(key.as_str(), toml_edit::value("latest")); + } + // Already pinned by mise — leave the entry as-is. } } } @@ -165,6 +228,53 @@ pub(super) fn apply_changes( Ok(()) } +/// The mise tool key used to install the flint binary from GitHub releases. +pub(super) const FLINT_MISE_KEY: &str = "github:grafana/flint"; + +/// Adds `flint` itself to `[tools]` in `mise.toml` if it is not already present. +/// Uses `mise use --pin` to resolve and pin the latest release version. +/// Falls back to `"latest"` if mise is unavailable. +/// Returns `true` if the file was changed. +pub(super) fn add_flint_tool(mise_path: &Path) -> Result { + let content = std::fs::read_to_string(mise_path).unwrap_or_default(); + let doc: toml_edit::DocumentMut = content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + + // Already present — nothing to do. + if doc + .get("tools") + .and_then(|t| t.as_table()) + .map(|t| t.contains_key(FLINT_MISE_KEY)) + .unwrap_or(false) + { + return Ok(false); + } + + let project_root = mise_path.parent().unwrap_or(mise_path); + if pin_tool_via_mise(project_root, FLINT_MISE_KEY) { + println!(" added {FLINT_MISE_KEY} to [tools]"); + return Ok(true); + } + + // Fallback: write "latest" directly. + eprintln!(" warning: could not pin {FLINT_MISE_KEY} via mise — writing \"latest\""); + let content = std::fs::read_to_string(mise_path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + if !doc.contains_key("tools") { + doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new())); + } + doc["tools"] + .as_table_mut() + .context("[tools] is not a table")? + .insert(FLINT_MISE_KEY, toml_edit::value("latest")); + std::fs::write(mise_path, doc.to_string())?; + println!(" added {FLINT_MISE_KEY} to [tools]"); + Ok(true) +} + const FLINT_V1_URL_PREFIX: &str = "https://raw.githubusercontent.com/grafana/flint/"; pub(super) struct V1Removal { diff --git a/src/init/mod.rs b/src/init/mod.rs index 478ba6b..c07e294 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -14,9 +14,9 @@ use detection::{ build_linter_groups, detect_obsolete_keys, detect_present_patterns, parse_tool_keys, }; use generation::{ - apply_changes, apply_env_and_tasks, detect_base_branch, flint_preset, generate_flint_toml, - generate_lint_workflow, get_existing_config_dir, has_slow_selected, maybe_install_hook, - patch_renovate_extends, prompt_config_dir, remove_v1_tasks, + add_flint_tool, apply_changes, apply_env_and_tasks, detect_base_branch, flint_preset, + generate_flint_toml, generate_lint_workflow, get_existing_config_dir, has_slow_selected, + maybe_install_hook, patch_renovate_extends, prompt_config_dir, remove_v1_tasks, }; use ui::{interactive_select_linters, select_categories_arrow}; @@ -232,9 +232,13 @@ Add and stage your source files before running init so the detection is accurate let existing_config_dir = get_existing_config_dir(¤t_content); let config_dir_rel = prompt_config_dir(existing_config_dir.as_deref(), yes)?; + let flint_added = add_flint_tool(&mise_path)?; + let tools_changed = !final_add.is_empty() || !final_remove.is_empty() || !final_upgrade.is_empty(); if tools_changed { + // Re-read after add_flint_tool may have written to the file. + let current_content = std::fs::read_to_string(&mise_path).unwrap_or(current_content); apply_changes( &mise_path, ¤t_content, @@ -277,7 +281,8 @@ Add and stage your source files before running init so the detection is accurate .transpose()? .unwrap_or(false); - if !tools_changed + if !flint_added + && !tools_changed && v1.removed_tasks.is_empty() && !v1.removed_renovate_env && !meta_changed From dad2374fa8ec44712cbec4dc28dacc0593878109 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 13:56:40 +0000 Subject: [PATCH 091/141] =?UTF-8?q?fix(init):=20use=20ubi:=20backend=20for?= =?UTF-8?q?=20flint=20tool=20key=20=E2=80=94=20github:=20requires=20linux-?= =?UTF-8?q?x64=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/init/generation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/init/generation.rs b/src/init/generation.rs index 0a00a57..ce1e2eb 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -229,7 +229,7 @@ pub(super) fn apply_changes( } /// The mise tool key used to install the flint binary from GitHub releases. -pub(super) const FLINT_MISE_KEY: &str = "github:grafana/flint"; +pub(super) const FLINT_MISE_KEY: &str = "ubi:grafana/flint"; /// Adds `flint` itself to `[tools]` in `mise.toml` if it is not already present. /// Uses `mise use --pin` to resolve and pin the latest release version. From 43436948bee2ff51341f6deb3d059c070e106e1b Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 7 Apr 2026 14:02:14 +0000 Subject: [PATCH 092/141] =?UTF-8?q?chore(init):=20remove=20add=5Fflint=5Ft?= =?UTF-8?q?ool=20=E2=80=94=20no=20working=20mise=20backend=20for=20flint?= =?UTF-8?q?=20v2=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The github: backend requires a linux-x64 asset name that v2 doesn't publish yet, and ubi: is deprecated. Remove the dead code and the init call until v2 has a proper release strategy. --- src/init/generation.rs | 47 ------------------------------------------ src/init/mod.rs | 13 ++++-------- 2 files changed, 4 insertions(+), 56 deletions(-) diff --git a/src/init/generation.rs b/src/init/generation.rs index ce1e2eb..b499815 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -228,53 +228,6 @@ pub(super) fn apply_changes( Ok(()) } -/// The mise tool key used to install the flint binary from GitHub releases. -pub(super) const FLINT_MISE_KEY: &str = "ubi:grafana/flint"; - -/// Adds `flint` itself to `[tools]` in `mise.toml` if it is not already present. -/// Uses `mise use --pin` to resolve and pin the latest release version. -/// Falls back to `"latest"` if mise is unavailable. -/// Returns `true` if the file was changed. -pub(super) fn add_flint_tool(mise_path: &Path) -> Result { - let content = std::fs::read_to_string(mise_path).unwrap_or_default(); - let doc: toml_edit::DocumentMut = content - .parse() - .unwrap_or_else(|_| toml_edit::DocumentMut::new()); - - // Already present — nothing to do. - if doc - .get("tools") - .and_then(|t| t.as_table()) - .map(|t| t.contains_key(FLINT_MISE_KEY)) - .unwrap_or(false) - { - return Ok(false); - } - - let project_root = mise_path.parent().unwrap_or(mise_path); - if pin_tool_via_mise(project_root, FLINT_MISE_KEY) { - println!(" added {FLINT_MISE_KEY} to [tools]"); - return Ok(true); - } - - // Fallback: write "latest" directly. - eprintln!(" warning: could not pin {FLINT_MISE_KEY} via mise — writing \"latest\""); - let content = std::fs::read_to_string(mise_path).unwrap_or_default(); - let mut doc: toml_edit::DocumentMut = content - .parse() - .unwrap_or_else(|_| toml_edit::DocumentMut::new()); - if !doc.contains_key("tools") { - doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new())); - } - doc["tools"] - .as_table_mut() - .context("[tools] is not a table")? - .insert(FLINT_MISE_KEY, toml_edit::value("latest")); - std::fs::write(mise_path, doc.to_string())?; - println!(" added {FLINT_MISE_KEY} to [tools]"); - Ok(true) -} - const FLINT_V1_URL_PREFIX: &str = "https://raw.githubusercontent.com/grafana/flint/"; pub(super) struct V1Removal { diff --git a/src/init/mod.rs b/src/init/mod.rs index c07e294..478ba6b 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -14,9 +14,9 @@ use detection::{ build_linter_groups, detect_obsolete_keys, detect_present_patterns, parse_tool_keys, }; use generation::{ - add_flint_tool, apply_changes, apply_env_and_tasks, detect_base_branch, flint_preset, - generate_flint_toml, generate_lint_workflow, get_existing_config_dir, has_slow_selected, - maybe_install_hook, patch_renovate_extends, prompt_config_dir, remove_v1_tasks, + apply_changes, apply_env_and_tasks, detect_base_branch, flint_preset, generate_flint_toml, + generate_lint_workflow, get_existing_config_dir, has_slow_selected, maybe_install_hook, + patch_renovate_extends, prompt_config_dir, remove_v1_tasks, }; use ui::{interactive_select_linters, select_categories_arrow}; @@ -232,13 +232,9 @@ Add and stage your source files before running init so the detection is accurate let existing_config_dir = get_existing_config_dir(¤t_content); let config_dir_rel = prompt_config_dir(existing_config_dir.as_deref(), yes)?; - let flint_added = add_flint_tool(&mise_path)?; - let tools_changed = !final_add.is_empty() || !final_remove.is_empty() || !final_upgrade.is_empty(); if tools_changed { - // Re-read after add_flint_tool may have written to the file. - let current_content = std::fs::read_to_string(&mise_path).unwrap_or(current_content); apply_changes( &mise_path, ¤t_content, @@ -281,8 +277,7 @@ Add and stage your source files before running init so the detection is accurate .transpose()? .unwrap_or(false); - if !flint_added - && !tools_changed + if !tools_changed && v1.removed_tasks.is_empty() && !v1.removed_renovate_env && !meta_changed From 1744e954118054a8c75f2db6dfe67c79d39cb4af Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 8 Apr 2026 12:05:47 +0000 Subject: [PATCH 093/141] docs(migration): add step to run flint run --fix renovate-deps after init --- MIGRATION.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MIGRATION.md b/MIGRATION.md index 4bd525e..3f6833b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -28,6 +28,9 @@ After installing flint (`mise install`), run `flint init`. It automatically: Then run `mise install` to install the new tools and `mise run setup:pre-commit-hook` to install the git hook. +Finally, run `flint run --fix renovate-deps` to regenerate +`renovate-tracked-deps.json` with all the new tools included. + ### 3. Verify active linters Run `flint linters` to confirm flint detects all the tools declared in your From b8cf0867b8e27bf83c5669b2e8623d2e32a2bfc9 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 8 Apr 2026 12:52:54 +0000 Subject: [PATCH 094/141] fix(license-header): skip when unconfigured or no matching files changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prepare() returns None when text is empty (not configured) — check no longer appears in output or flint linters when unused - prepare() filters files by cfg.patterns via match_files(), returns None when no matching files are in the changeset (consistent with Template checks that skip when no files match their patterns) - flint linters shows "not configured" instead of "active" when [checks.license-header] has no text set - license_header::run() simplified: pattern filtering and empty-text guard moved to runner; duplicate glob_match and LinterOutput::ok() removed --- src/linters/license_header.rs | 51 ++--------------------------------- src/linters/mod.rs | 8 ------ src/main.rs | 15 ++++++++--- src/runner.rs | 29 ++++++++++++++++---- 4 files changed, 38 insertions(+), 65 deletions(-) diff --git a/src/linters/license_header.rs b/src/linters/license_header.rs index eedee63..755b442 100644 --- a/src/linters/license_header.rs +++ b/src/linters/license_header.rs @@ -4,35 +4,19 @@ use std::path::{Path, PathBuf}; use crate::config::LicenseHeaderConfig; use crate::linters::LinterOutput; -/// Checks that each file matching `cfg.patterns` contains `cfg.text` within -/// the first `cfg.lines_to_check` lines. Returns early (ok=true) when not configured. +/// Checks that each file contains `cfg.text` within the first `cfg.lines_to_check` lines. +/// Files are pre-filtered by pattern in the runner; this function checks all of them. pub async fn run( cfg: &LicenseHeaderConfig, project_root: &Path, files: &[PathBuf], ) -> LinterOutput { - if cfg.text.is_empty() { - return LinterOutput::ok(); - } - let mut all_ok = true; let mut stderr = Vec::new(); for file in files { let rel = file.strip_prefix(project_root).unwrap_or(file); let rel_str = rel.to_string_lossy(); - let file_name = file - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); - - if !cfg - .patterns - .iter() - .any(|pat| glob_match(pat, &file_name) || glob_match(pat, &rel_str)) - { - continue; - } match check_file(file, &cfg.text, cfg.lines_to_check) { Ok(true) => {} @@ -66,41 +50,10 @@ fn check_file(path: &Path, text: &str, lines_to_check: usize) -> std::io::Result Ok(false) } -fn glob_match(pattern: &str, name: &str) -> bool { - let parts: Vec<&str> = pattern.splitn(2, '*').collect(); - match parts.as_slice() { - [only] => name == *only || name.ends_with(&format!("/{only}")), - [prefix, suffix] => { - let anchor_start = prefix.is_empty() || name.starts_with(prefix) || { - name.contains('/') && { - let after_slash = name.rfind('/').map(|i| &name[i + 1..]).unwrap_or(name); - prefix.is_empty() || after_slash.starts_with(prefix) - } - }; - anchor_start && name.ends_with(suffix) - } - _ => false, - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn glob_match_extension() { - assert!(glob_match("*.java", "Foo.java")); - assert!(glob_match("*.java", "src/main/Foo.java")); - assert!(!glob_match("*.java", "Foo.kt")); - } - - #[test] - fn glob_match_exact() { - assert!(glob_match("Makefile", "Makefile")); - assert!(glob_match("Makefile", "src/Makefile")); - assert!(!glob_match("Makefile", "GNUmakefile")); - } - #[test] fn check_file_finds_header_in_first_lines() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/linters/mod.rs b/src/linters/mod.rs index 36dd215..6d01530 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -10,14 +10,6 @@ pub struct LinterOutput { } impl LinterOutput { - pub fn ok() -> Self { - Self { - ok: true, - stdout: vec![], - stderr: vec![], - } - } - pub fn err(stderr: impl Into>) -> Self { Self { ok: false, diff --git a/src/main.rs b/src/main.rs index a51c042..602bcc7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,11 +110,12 @@ async fn main() -> Result<()> { println!("flint {}", env!("CARGO_PKG_VERSION")); } SubCommand::Linters(args) => { + let cfg = config::load(&config_dir).unwrap_or_default(); let mise_tools = registry::read_mise_tools(&project_root); if args.json { print_linters_json(®istry); } else { - print_linters(®istry, &mise_tools); + print_linters(®istry, &mise_tools, &cfg); } } SubCommand::Init(args) => { @@ -360,7 +361,11 @@ fn is_fixable(name: &str, active: &[®istry::Check]) -> bool { active.iter().any(|c| c.name == name && c.has_fix()) } -fn print_linters(registry: &[registry::Check], mise_tools: &HashMap) { +fn print_linters( + registry: &[registry::Check], + mise_tools: &HashMap, + cfg: &config::Config, +) { // Column widths. let name_w = registry .iter() @@ -389,7 +394,11 @@ fn print_linters(registry: &[registry::Check], mise_tools: &HashMap Some(PreparedCheck::LicenseHeader { - name, - cfg: cfg.checks.license_header.clone(), - files: file_list.files.clone(), - }), + CheckKind::Special(SpecialKind::LicenseHeader) => { + if cfg.checks.license_header.text.is_empty() { + return None; + } + let patterns: Vec<&str> = cfg + .checks + .license_header + .patterns + .iter() + .map(String::as_str) + .collect(); + let files: Vec = match_files(&file_list.files, &patterns, &[], project_root) + .into_iter() + .cloned() + .collect(); + if files.is_empty() { + return None; + } + Some(PreparedCheck::LicenseHeader { + name, + cfg: cfg.checks.license_header.clone(), + files, + }) + } } } From dc3537ef6bdb529e4c765de5b9ece34fe69a576c Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 8 Apr 2026 13:30:08 +0000 Subject: [PATCH 095/141] fix(registry): use ubi: backend for google-java-format tool key The check was registered with mise_tool("github:google/google-java-format") but consuming repos use "ubi:google/google-java-format" in mise.toml. The key mismatch meant google-java-format was never detected as active, so ec's defer_to_formatters did not exclude *.java files, causing ec to flag long lines in Java source files. Update test fixtures and regenerate the general/list snapshot (license-header now shows "not configured" instead of "active" when unconfigured). --- src/registry.rs | 2 +- tests/cases/general/list/test.toml | 2 +- tests/cases/google-java-format/auto-fix/files/mise.toml | 2 +- tests/cases/google-java-format/clean/files/mise.toml | 2 +- tests/cases/google-java-format/failure/files/mise.toml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 8ae44eb..5de660e 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -444,7 +444,7 @@ pub fn builtin() -> Vec { &["*.java"], ) .fix("google-java-format -i {FILES}") - .mise_tool("github:google/google-java-format") + .mise_tool("ubi:google/google-java-format") .formatter() .lang(), Check::files( diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index 8c0e2b7..cb89cd9 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -25,7 +25,7 @@ ktlint ktlint missing fast *.kt *.kts dotnet-format dotnet missing fast *.cs lychee lychee active fast renovate-deps renovate active fast renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5 -license-header license-header active fast +license-header license-header not configured fast ''' [fake_bins] actionlint = ''' diff --git a/tests/cases/google-java-format/auto-fix/files/mise.toml b/tests/cases/google-java-format/auto-fix/files/mise.toml index eb0a01b..5bc97bd 100644 --- a/tests/cases/google-java-format/auto-fix/files/mise.toml +++ b/tests/cases/google-java-format/auto-fix/files/mise.toml @@ -1,2 +1,2 @@ [tools] -"github:google/google-java-format" = "latest" +"ubi:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/clean/files/mise.toml b/tests/cases/google-java-format/clean/files/mise.toml index eb0a01b..5bc97bd 100644 --- a/tests/cases/google-java-format/clean/files/mise.toml +++ b/tests/cases/google-java-format/clean/files/mise.toml @@ -1,2 +1,2 @@ [tools] -"github:google/google-java-format" = "latest" +"ubi:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/failure/files/mise.toml b/tests/cases/google-java-format/failure/files/mise.toml index eb0a01b..5bc97bd 100644 --- a/tests/cases/google-java-format/failure/files/mise.toml +++ b/tests/cases/google-java-format/failure/files/mise.toml @@ -1,2 +1,2 @@ [tools] -"github:google/google-java-format" = "latest" +"ubi:google/google-java-format" = "latest" From fad51b51e2f0be1f80807c2bec6f0b88136c3c14 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 8 Apr 2026 13:41:48 +0000 Subject: [PATCH 096/141] fix(registry): revert google-java-format to github: backend (ubi: deprecated) --- src/registry.rs | 2 +- tests/cases/google-java-format/auto-fix/files/mise.toml | 2 +- tests/cases/google-java-format/clean/files/mise.toml | 2 +- tests/cases/google-java-format/failure/files/mise.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 5de660e..8ae44eb 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -444,7 +444,7 @@ pub fn builtin() -> Vec { &["*.java"], ) .fix("google-java-format -i {FILES}") - .mise_tool("ubi:google/google-java-format") + .mise_tool("github:google/google-java-format") .formatter() .lang(), Check::files( diff --git a/tests/cases/google-java-format/auto-fix/files/mise.toml b/tests/cases/google-java-format/auto-fix/files/mise.toml index 5bc97bd..eb0a01b 100644 --- a/tests/cases/google-java-format/auto-fix/files/mise.toml +++ b/tests/cases/google-java-format/auto-fix/files/mise.toml @@ -1,2 +1,2 @@ [tools] -"ubi:google/google-java-format" = "latest" +"github:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/clean/files/mise.toml b/tests/cases/google-java-format/clean/files/mise.toml index 5bc97bd..eb0a01b 100644 --- a/tests/cases/google-java-format/clean/files/mise.toml +++ b/tests/cases/google-java-format/clean/files/mise.toml @@ -1,2 +1,2 @@ [tools] -"ubi:google/google-java-format" = "latest" +"github:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/failure/files/mise.toml b/tests/cases/google-java-format/failure/files/mise.toml index 5bc97bd..eb0a01b 100644 --- a/tests/cases/google-java-format/failure/files/mise.toml +++ b/tests/cases/google-java-format/failure/files/mise.toml @@ -1,2 +1,2 @@ [tools] -"ubi:google/google-java-format" = "latest" +"github:google/google-java-format" = "latest" From 1a679248da33c3011b2f54f5893afa2dece0a2bd Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 8 Apr 2026 14:45:26 +0000 Subject: [PATCH 097/141] docs: recommend against Spotless for Java projects --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index bc8a83f..e106dd9 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,18 @@ a second inventory of the same tools in `.pre-commit-config.yaml`, with its own versioning and install lifecycle. That's friction without benefit for repos that are already mise-first. +### Why not Spotless (or other Maven formatter plugins)? + +Spotless runs `google-java-format` as a Maven build phase, which means format +failures block compilation and test runs — that's the wrong place for a style +check. flint's `google-java-format` check runs as a separate lint step, only on +changed files, and is fast. + +To migrate: remove `spotless-maven-plugin` from your `pom.xml` (and any +`spotless.skip` properties), add `"github:google/google-java-format"` to +`[tools]` in `mise.toml`, and run `flint run --fix` once to confirm the repo is +clean. + ### Why not MegaLinter / super-linter? Container-based linters (super-linter, MegaLinter) ship their own tool versions, From fd71fbdce59cea7412056ed65652e1b84544e992 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Wed, 8 Apr 2026 15:26:07 +0000 Subject: [PATCH 098/141] feat(license-header): support multi-line text matching --- src/linters/license_header.rs | 30 +++++++++++++++---- .../multiline-clean/files/Main.java | 9 ++++++ .../multiline-clean/files/flint.toml | 7 +++++ .../license-header/multiline-clean/test.toml | 3 ++ 4 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 tests/cases/license-header/multiline-clean/files/Main.java create mode 100644 tests/cases/license-header/multiline-clean/files/flint.toml create mode 100644 tests/cases/license-header/multiline-clean/test.toml diff --git a/src/linters/license_header.rs b/src/linters/license_header.rs index 755b442..98e03fb 100644 --- a/src/linters/license_header.rs +++ b/src/linters/license_header.rs @@ -39,15 +39,16 @@ pub async fn run( } /// Returns `true` if `text` appears anywhere within the first `lines_to_check` lines of `path`. +/// `text` may be multi-line; the file head is joined with `\n` before the substring search. fn check_file(path: &Path, text: &str, lines_to_check: usize) -> std::io::Result { let f = std::fs::File::open(path)?; let reader = BufReader::new(f); - for line in reader.lines().take(lines_to_check) { - if line?.contains(text) { - return Ok(true); - } - } - Ok(false) + let head = reader + .lines() + .take(lines_to_check) + .collect::, _>>()? + .join("\n"); + Ok(head.contains(text)) } #[cfg(test)] @@ -70,6 +71,23 @@ mod tests { assert!(!check_file(&path, "Copyright", 5).unwrap()); } + #[test] + fn check_file_multiline_text() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + std::fs::write( + &path, + "/*\n * Copyright Acme\n * SPDX-License-Identifier: Apache-2.0\n */\npublic class Foo {}\n", + ) + .unwrap(); + let header = "/*\n * Copyright Acme\n * SPDX-License-Identifier: Apache-2.0\n */"; + assert!(check_file(&path, header, 5).unwrap()); + // Partial match still works (single-line substring within the joined head) + assert!(check_file(&path, "SPDX-License-Identifier: Apache-2.0", 5).unwrap()); + // Text that spans more lines than the limit is not found + assert!(!check_file(&path, header, 2).unwrap()); + } + #[test] fn check_file_header_beyond_line_limit() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/cases/license-header/multiline-clean/files/Main.java b/tests/cases/license-header/multiline-clean/files/Main.java new file mode 100644 index 0000000..3086304 --- /dev/null +++ b/tests/cases/license-header/multiline-clean/files/Main.java @@ -0,0 +1,9 @@ +/* + * Copyright Grafana Labs + * SPDX-License-Identifier: Apache-2.0 + */ +public class Main { + public static void main(String[] args) { + System.out.println("hello"); + } +} diff --git a/tests/cases/license-header/multiline-clean/files/flint.toml b/tests/cases/license-header/multiline-clean/files/flint.toml new file mode 100644 index 0000000..2fe1e9b --- /dev/null +++ b/tests/cases/license-header/multiline-clean/files/flint.toml @@ -0,0 +1,7 @@ +[checks.license-header] +text = """ +/* + * Copyright Grafana Labs + * SPDX-License-Identifier: Apache-2.0 + */""" +patterns = ["*.java"] diff --git a/tests/cases/license-header/multiline-clean/test.toml b/tests/cases/license-header/multiline-clean/test.toml new file mode 100644 index 0000000..0861d7f --- /dev/null +++ b/tests/cases/license-header/multiline-clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full license-header" +exit = 0 From 5f8a6aafa0bf4c87fff796b7e86d6259b6219842 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 07:44:20 +0000 Subject: [PATCH 099/141] feat(init): generate .markdownlint.jsonc when markdownlint-cli2 is selected - linter_config for markdownlint-cli2 updated to .markdownlint.jsonc - flint init creates .markdownlint.jsonc in the repo root with MD013=false when markdownlint-cli2 is selected (skips if file already exists) --- src/init/generation.rs | 14 ++++++++++++++ src/init/mod.rs | 38 ++++++++++++++++++++++++++++++++++++-- src/registry.rs | 2 +- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/init/generation.rs b/src/init/generation.rs index b499815..509c32f 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -433,6 +433,20 @@ pub(super) fn generate_flint_toml( Ok(true) } +/// Generates `.markdownlint.jsonc` in the project root if it does not already exist +/// and markdownlint-cli2 is being set up. +/// Returns `true` if the file was written. +pub(super) fn generate_markdownlint_config(project_root: &Path) -> Result { + let path = project_root.join(".markdownlint.jsonc"); + if path.exists() { + return Ok(false); + } + let content = "{\n // Disable line-length enforcement — long lines are common in tables and code links\n \"MD013\": false,\n}\n"; + std::fs::write(&path, content)?; + println!(" wrote {}", path.display()); + Ok(true) +} + /// Generates `.github/workflows/lint.yml` if it does not already exist. /// Returns `true` if the file was written. pub(super) fn generate_lint_workflow(project_root: &Path, base_branch: &str) -> Result { diff --git a/src/init/mod.rs b/src/init/mod.rs index 478ba6b..55a7186 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -15,8 +15,9 @@ use detection::{ }; use generation::{ apply_changes, apply_env_and_tasks, detect_base_branch, flint_preset, generate_flint_toml, - generate_lint_workflow, get_existing_config_dir, has_slow_selected, maybe_install_hook, - patch_renovate_extends, prompt_config_dir, remove_v1_tasks, + generate_lint_workflow, generate_markdownlint_config, get_existing_config_dir, + has_slow_selected, maybe_install_hook, patch_renovate_extends, prompt_config_dir, + remove_v1_tasks, }; use ui::{interactive_select_linters, select_categories_arrow}; @@ -227,6 +228,12 @@ Add and stage your source files before running init so the detection is accurate .zip(&g.check_selected) .any(|(c, &sel)| sel && c.name == "renovate-deps") }); + let has_markdownlint = groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && c.name == "markdownlint-cli2") + }); // Prompt for the flint config dir (skipped if already set in mise.toml or --yes). let existing_config_dir = get_existing_config_dir(¤t_content); @@ -264,6 +271,11 @@ Add and stage your source files before running init so the detection is accurate v1.renovate_exclude_managers.as_deref(), )?; let workflow_generated = generate_lint_workflow(project_root, &base_branch)?; + let markdownlint_generated = if has_markdownlint { + generate_markdownlint_config(project_root)? + } else { + false + }; let renovate_patched = find_renovate_config(project_root) .map(|path| { @@ -283,6 +295,7 @@ Add and stage your source files before running init so the detection is accurate && !meta_changed && !toml_generated && !workflow_generated + && !markdownlint_generated && !renovate_patched { println!("No changes to apply."); @@ -560,6 +573,27 @@ rust = { version = "1.0", components = "clippy" } assert_eq!(get_existing_config_dir(content), None); } + #[test] + fn generate_markdownlint_config_writes_file() { + use generation::generate_markdownlint_config; + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_markdownlint_config(tmp.path()).unwrap(); + assert!(written); + let content = std::fs::read_to_string(tmp.path().join(".markdownlint.jsonc")).unwrap(); + assert!(content.contains("\"MD013\": false")); + } + + #[test] + fn generate_markdownlint_config_skips_existing() { + use generation::generate_markdownlint_config; + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join(".markdownlint.jsonc"), "existing").unwrap(); + let written = generate_markdownlint_config(tmp.path()).unwrap(); + assert!(!written); + let content = std::fs::read_to_string(tmp.path().join(".markdownlint.jsonc")).unwrap(); + assert_eq!(content, "existing"); + } + #[test] fn generate_flint_toml_writes_skeleton() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/src/registry.rs b/src/registry.rs index 8ae44eb..9b3e302 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -343,7 +343,7 @@ pub fn builtin() -> Vec { .style(), Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) .fix("markdownlint-cli2 --fix {FILE}") - .linter_config(".markdownlint.json", "--config") + .linter_config(".markdownlint.jsonc", "--config") .install_key("npm:markdownlint-cli2"), Check::files( "prettier", From 87cf6e63e71ec368f7ad22ac2acfda452d86fc3d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 11:09:22 +0000 Subject: [PATCH 100/141] docs(registry): add per-linter descriptions, rework README linter table - Add `desc` field to `Check` for plain-text descriptions shown in `flint linters` and the README - Add `docs` field for extended markdown prose (config examples etc.) - Replace wide 8-column registry table with a 3-column summary table (Name | Description | Fix) plus per-linter `####` sections with metadata tables and inline docs - Inline Special checks (lychee, renovate-deps) into their respective linter sections; remove standalone `### Special checks` heading - Add FIX and DESCRIPTION columns to `flint linters` CLI output - Add `description` field to `flint linters --json` output - Rework README intro summary as bullets; consolidate Principles section --- README.md | 383 +++++++++++++++++++++++++++++++++++++----------- src/main.rs | 25 +++- src/registry.rs | 236 ++++++++++++++++++++--------- src/runner.rs | 3 +- 4 files changed, 491 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index e106dd9..9626ba2 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,14 @@ -mise-native linter runner. Parallel, cross-platform, AI-friendly, local == CI. +Linter runner built for speed and consistency: + +- **Fast** — native execution (no Docker), parallel, diff-aware (changed files only), opt-in (undeclared tools don't run), small binary cached by mise +- **Local == CI** — one binary, one config, identical behavior +- **AI-friendly** — fix silently, surface only what needs review +- **Cross-platform** — Linux, macOS, Windows +- **Autofix** — `--fix` fixes what's fixable; reports what still needs review + See [Why / Principles](#why) for background. > **Legacy v1** (bash task scripts): see [README-V1.md](README-V1.md). @@ -115,6 +122,8 @@ flint linters flint version ``` +Commands and flags follow [golangci-lint](https://golangci-lint.run/) conventions — teams already using it don't need to re-learn the interface. + `flint run` flags: | Flag | Description | @@ -222,30 +231,282 @@ being linted and cannot be redirected via a flag. -| Name | Binary | Patterns | Fix | Slow | Scope | Config file | Notes | -| ---------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | --- | ---- | ------- | ---------------------------------- | --------------------------------------------- | -| `shellcheck` | `shellcheck` | `*.sh *.bash *.bats` | no | — | file | `.shellcheckrc` | — | -| `shfmt` | `shfmt` | `*.sh *.bash` | yes | — | file | — | — | -| `markdownlint-cli2` | `markdownlint-cli2` | `*.md` | yes | — | file | `.markdownlint.json` | — | -| `prettier` | `prettier` | `*.md *.yml *.yaml` | yes | — | files | `.prettierrc` | — | -| `actionlint` | `actionlint` | `.github/workflows/*.yml .github/workflows/*.yaml` | no | — | file | `actionlint.yml` | — | -| `hadolint` | `hadolint` | `Dockerfile Dockerfile.* *.dockerfile` | no | — | file | `.hadolint.yaml` | — | -| `codespell` | `codespell` | `*` | yes | — | files | `.codespellrc` | — | -| `editorconfig-checker` | `ec` | `*` | no | — | files | `.editorconfig-checker.json` | — | -| `golangci-lint` | `golangci-lint` | `*.go` | no | — | project | `.golangci.yml` | uses --new-from-rev to lint only changed code | -| `ruff` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | — | -| `ruff-format` | `ruff` | `*.py` | yes | — | file | `ruff.toml` | — | -| `biome` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | -| `biome-format` | `biome` | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | yes | — | file | — | — | -| `cargo-clippy` | `cargo-clippy` | `*.rs` | yes | — | project | — | lints all .rs files, not just changed | -| `cargo-fmt` | `rustfmt` | `*.rs` | yes | — | project | — | formats all .rs files, not just changed | -| `gofmt` | `gofmt` | `*.go` | yes | — | file | — | — | -| `google-java-format` | `google-java-format` | `*.java` | yes | — | files | — | — | -| `ktlint` | `ktlint` | `*.kt *.kts` | yes | — | files | — | — | -| `dotnet-format` | `dotnet` | `*.cs` | yes | — | files | — | — | -| `lychee` | `lychee` | (all files) | no | — | special | via `[checks.links]` in flint.toml | — | -| `renovate-deps` | `renovate` | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | yes | — | special | — | — | -| `license-header` | (built-in) | (all files) | no | — | special | — | — | +| Name | Description | Fix | +| ---------------------- | ------------------------------------------------------------------- | --- | +| `shellcheck` | Lint shell scripts for common mistakes | — | +| `shfmt` | Format shell scripts | yes | +| `markdownlint-cli2` | Lint Markdown files for style and consistency | yes | +| `prettier` | Format Markdown and YAML files | yes | +| `actionlint` | Lint GitHub Actions workflow files | — | +| `hadolint` | Lint Dockerfiles | — | +| `codespell` | Check for common spelling mistakes | yes | +| `editorconfig-checker` | Check files comply with EditorConfig settings | — | +| `golangci-lint` | Lint Go code; uses --new-from-rev to scope analysis to changed code | — | +| `ruff` | Lint Python code | yes | +| `ruff-format` | Format Python code | yes | +| `biome` | Lint JS/TS/JSON files | yes | +| `biome-format` | Format JS/TS/JSON files | yes | +| `cargo-clippy` | Lint Rust code; runs on all .rs files, not just changed | yes | +| `cargo-fmt` | Format Rust code; runs on all .rs files, not just changed | yes | +| `gofmt` | Format Go code | yes | +| `google-java-format` | Format Java code | yes | +| `ktlint` | Lint and format Kotlin code | yes | +| `dotnet-format` | Format C# code | yes | +| `lychee` | Check for broken links | — | +| `renovate-deps` | Verify Renovate dependency snapshot is up to date | yes | +| `license-header` | Check source files have the required license header | — | + +#### `shellcheck` + +| | | +| ----------- | -------------------------------------- | +| Description | Lint shell scripts for common mistakes | +| Fix | no | +| Binary | `shellcheck` | +| Scope | [file](#scopes) | +| Patterns | `*.sh *.bash *.bats` | +| Config | `.shellcheckrc` | + +#### `shfmt` + +| | | +| ----------- | -------------------- | +| Description | Format shell scripts | +| Fix | yes | +| Binary | `shfmt` | +| Scope | [file](#scopes) | +| Patterns | `*.sh *.bash` | + +#### `markdownlint-cli2` + +| | | +| ----------- | --------------------------------------------- | +| Description | Lint Markdown files for style and consistency | +| Fix | yes | +| Binary | `markdownlint-cli2` | +| Scope | [file](#scopes) | +| Patterns | `*.md` | +| Config | `.markdownlint.jsonc` | + +#### `prettier` + +| | | +| ----------- | ------------------------------ | +| Description | Format Markdown and YAML files | +| Fix | yes | +| Binary | `prettier` | +| Scope | [files](#scopes) | +| Patterns | `*.md *.yml *.yaml` | +| Config | `.prettierrc` | + +#### `actionlint` + +| | | +| ----------- | -------------------------------------------------- | +| Description | Lint GitHub Actions workflow files | +| Fix | no | +| Binary | `actionlint` | +| Scope | [file](#scopes) | +| Patterns | `.github/workflows/*.yml .github/workflows/*.yaml` | +| Config | `actionlint.yml` | + +#### `hadolint` + +| | | +| ----------- | -------------------------------------- | +| Description | Lint Dockerfiles | +| Fix | no | +| Binary | `hadolint` | +| Scope | [file](#scopes) | +| Patterns | `Dockerfile Dockerfile.* *.dockerfile` | +| Config | `.hadolint.yaml` | + +#### `codespell` + +| | | +| ----------- | ---------------------------------- | +| Description | Check for common spelling mistakes | +| Fix | yes | +| Binary | `codespell` | +| Scope | [files](#scopes) | +| Patterns | `*` | +| Config | `.codespellrc` | + +#### `editorconfig-checker` + +| | | +| ----------- | --------------------------------------------- | +| Description | Check files comply with EditorConfig settings | +| Fix | no | +| Binary | `ec` | +| Scope | [files](#scopes) | +| Patterns | `*` | +| Config | `.editorconfig-checker.json` | + +#### `golangci-lint` + +| | | +| ----------- | ------------------------------------------------------------------- | +| Description | Lint Go code; uses --new-from-rev to scope analysis to changed code | +| Fix | no | +| Binary | `golangci-lint` | +| Scope | [project](#scopes) | +| Patterns | `*.go` | +| Config | `.golangci.yml` | + +#### `ruff` + +| | | +| ----------- | ---------------- | +| Description | Lint Python code | +| Fix | yes | +| Binary | `ruff` | +| Scope | [file](#scopes) | +| Patterns | `*.py` | +| Config | `ruff.toml` | + +#### `ruff-format` + +| | | +| ----------- | ------------------ | +| Description | Format Python code | +| Fix | yes | +| Binary | `ruff` | +| Scope | [file](#scopes) | +| Patterns | `*.py` | +| Config | `ruff.toml` | + +#### `biome` + +| | | +| ----------- | -------------------------------------- | +| Description | Lint JS/TS/JSON files | +| Fix | yes | +| Binary | `biome` | +| Scope | [file](#scopes) | +| Patterns | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | + +#### `biome-format` + +| | | +| ----------- | -------------------------------------- | +| Description | Format JS/TS/JSON files | +| Fix | yes | +| Binary | `biome` | +| Scope | [file](#scopes) | +| Patterns | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | + +#### `cargo-clippy` + +| | | +| ----------- | ------------------------------------------------------- | +| Description | Lint Rust code; runs on all .rs files, not just changed | +| Fix | yes | +| Binary | `cargo-clippy` | +| Scope | [project](#scopes) | +| Patterns | `*.rs` | + +#### `cargo-fmt` + +| | | +| ----------- | --------------------------------------------------------- | +| Description | Format Rust code; runs on all .rs files, not just changed | +| Fix | yes | +| Binary | `rustfmt` | +| Scope | [project](#scopes) | +| Patterns | `*.rs` | + +#### `gofmt` + +| | | +| ----------- | --------------- | +| Description | Format Go code | +| Fix | yes | +| Binary | `gofmt` | +| Scope | [file](#scopes) | +| Patterns | `*.go` | + +#### `google-java-format` + +| | | +| ----------- | -------------------- | +| Description | Format Java code | +| Fix | yes | +| Binary | `google-java-format` | +| Scope | [files](#scopes) | +| Patterns | `*.java` | + +#### `ktlint` + +| | | +| ----------- | --------------------------- | +| Description | Lint and format Kotlin code | +| Fix | yes | +| Binary | `ktlint` | +| Scope | [files](#scopes) | +| Patterns | `*.kt *.kts` | + +#### `dotnet-format` + +| | | +| ----------- | ---------------- | +| Description | Format C# code | +| Fix | yes | +| Binary | `dotnet` | +| Scope | [files](#scopes) | +| Patterns | `*.cs` | + +#### `lychee` + +| | | +| ----------- | ---------------------------------- | +| Description | Check for broken links | +| Fix | no | +| Binary | `lychee` | +| Scope | [special](#scopes) | +| Config | via `[checks.links]` in flint.toml | + +Orchestrates [lychee](https://lychee.cli.rs/) for link checking. Requires `lychee` in `[tools]`. + +Default behavior: checks all links in changed files. When `check_all_local = true` in `flint.toml`, adds a second pass over local links in all files — useful when broken internal links from unchanged files also matter. + +Configure via `flint.toml`: + +```toml +[checks.links] +config = ".github/config/lychee.toml" +check_all_local = true +``` + +#### `renovate-deps` + +| | | +| ----------- | -------------------------------------------------------------------------------------------------------------------------- | +| Description | Verify Renovate dependency snapshot is up to date | +| Fix | yes | +| Binary | `renovate` | +| Scope | [special](#scopes) | +| Patterns | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | + +Verifies `.github/renovate-tracked-deps.json` is up to date by running Renovate locally and comparing its output against the committed snapshot. Requires `renovate` in `[tools]`. + +With `--fix`, automatically regenerates and commits the snapshot. + +Configure via `flint.toml`: + +```toml +[checks.renovate-deps] +exclude_managers = ["github-actions", "github-runners"] +``` + +#### `license-header` + +| | | +| ----------- | --------------------------------------------------- | +| Description | Check source files have the required license header | +| Fix | no | +| Binary | (built-in) | +| Scope | [special](#scopes) | @@ -253,7 +514,7 @@ being linted and cannot be redirected via a flag. **Note:** Biome's config flag (`--config-path`) takes a directory, not a file path — config injection for `biome` and `biome-format` is not yet implemented. -**Scopes:** +#### Scopes - `file` — invoked once per matched file - `files` — invoked once with all matched files as args; only changed files are passed @@ -273,41 +534,6 @@ already enforce line length and would conflict with `editorconfig-checker`'s `max_line_length` editorconfig check. If none of those formatters are installed, `editorconfig-checker` checks those files itself. -### Special checks - -#### links - -Orchestrates [lychee](https://lychee.cli.rs/) for link checking. Requires -`lychee` in `[tools]`. - -Default behavior: checks all links in changed files. When `check_all_local = true` -in `flint.toml`, adds a second pass over local links in all files — useful when -broken internal links from unchanged files also matter. - -Configure via `flint.toml`: - -```toml -[checks.links] -config = ".github/config/lychee.toml" -check_all_local = true -``` - -#### renovate-deps - -Verifies `.github/renovate-tracked-deps.json` is up to date by running Renovate -locally and comparing its output against the committed snapshot. Same purpose as -the v1 `lint:renovate-deps` task. Requires `renovate` in `[tools]`. - -Tagged `slow = true` — skipped by `--fast-only`. With `--fix`, automatically regenerates -and commits the snapshot. - -Configure via `flint.toml`: - -```toml -[checks.renovate-deps] -exclude_managers = ["github-actions", "github-runners"] -``` - ## Why The bash task scripts (v1) have two problems: @@ -347,20 +573,17 @@ use everywhere" promise of mise. Container startup also adds latency to every ru ## Principles -1. **mise-based** — `flint` distributed via mise. Tools managed by the consuming - repo's `mise.toml`. No separate tool installation step. +1. **Fast** — the primary goal; everything else serves it: + - Native execution only (no Docker); linters run in parallel (Rust binary, short startup) + - Small binary, cached by mise — fast install, near-zero overhead between runs + - Diff-aware: only changed files are linted by default; `--full` to check everything + - Opt-in via `mise.toml`: undeclared tools are skipped entirely + - Slow checks (e.g. `renovate-deps`) tagged and skippable with `--fast-only` -2. **Fast** — native execution only (no Docker). Linters run in parallel. - Designed to be the default `mise run lint`, not a slow fallback. - Slow checks (e.g. `renovate-deps`) can be skipped with `--fast-only`. - -3. **Cross-platform** — runs on Linux, macOS, and Windows. The built-in - registry accounts for platform differences (e.g. binary names, path quoting). - -4. **Local same as CI** — one binary, one config, identical behavior. +2. **Local same as CI** — one binary, one config, identical behavior. No "native mode subset" distinction. If it passes locally, it passes in CI. -5. **AI-friendly** — `--fix` fixes what's fixable silently, prints output +3. **AI-friendly** — `--fix` fixes what's fixable silently, prints output only for issues needing review, and exits with a structured summary: ```text @@ -370,24 +593,14 @@ use everywhere" promise of mise. Container startup also adds latency to every ru ``` Only unfixable issues surface for review — no reasoning step required. - Also runnable containerised — no host tool dependencies required. -6. **Opt-in via tool install** — checks auto-enable when their tool is declared - in `mise.toml`. `flint.toml` adds detail (config paths, exclusions) but is - not required to activate anything. - -7. **Changed files by default** — git-aware diff detection. `--new-from-rev`/`--to-ref` - for CI. `--full` to check everything. Falls back to all files when no merge - base is found. +4. **Cross-platform** — runs on Linux, macOS, and Windows. The built-in + registry accounts for platform differences (e.g. binary names, path quoting). -8. **Autofix where possible** — `--fix` checks first, fixes what's fixable, +5. **Autofix where possible** — `--fix` checks first, fixes what's fixable, reports what needs review. Fix mode runs serially to avoid concurrent writes. Pass specific linter names to limit which fixers run (`flint run --fix prettier shfmt`). -9. **Familiar CLI** — commands and flags follow [golangci-lint](https://golangci-lint.run/) - conventions (`run`, `linters`, `--fast-only`, `--new-from-rev`) so teams - already familiar with golangci-lint don't need to re-learn the interface. - ## Versioning This project uses [Semantic Versioning](https://semver.org/). diff --git a/src/main.rs b/src/main.rs index 602bcc7..2de8114 100644 --- a/src/main.rs +++ b/src/main.rs @@ -348,6 +348,7 @@ pub fn linter_json(check: ®istry::Check) -> serde_json::Value { let config_file: Option<&str> = check.linter_config.map(|(filename, _)| filename); serde_json::json!({ "name": check.name, + "description": check.desc, "binary": if check.uses_binary() { check.bin_name } else { "(built-in)" }, "patterns": patterns, "fix": check.has_fix(), @@ -379,17 +380,26 @@ fn print_linters( .max() .unwrap_or(6) .max(6); + let desc_w = registry + .iter() + .map(|c| c.desc.len()) + .max() + .unwrap_or(11) + .max(11); println!( - "{:, pub kind: CheckKind, - /// Optional note shown in the README linter table. - pub note: Option<&'static str>, + /// Plain-text description of what the check does — shown in `flint linters` and the README table. + pub desc: &'static str, + /// Extended markdown documentation shown in the README detail section (behaviour, config examples). + pub docs: &'static str, } impl Check { @@ -165,7 +167,8 @@ impl Check { full_fix_cmd: "", scope, }, - note: None, + desc: "", + docs: "", } } @@ -186,7 +189,8 @@ impl Check { mise_install_key: None, mise_install_components: None, kind: CheckKind::Special(kind), - note: None, + desc: "", + docs: "", } } @@ -265,9 +269,15 @@ impl Check { self } - /// Add a note shown in the README linter table. - pub fn note(mut self, note: &'static str) -> Self { - self.note = Some(note); + /// Set the plain-text description shown in `flint linters` and the README table. + pub fn desc(mut self, desc: &'static str) -> Self { + self.desc = desc; + self + } + + /// Set extended markdown documentation shown in the README detail section. + pub fn docs(mut self, docs: &'static str) -> Self { + self.docs = docs; self } @@ -336,14 +346,17 @@ pub fn builtin() -> Vec { &["*.sh", "*.bash", "*.bats"], ) .linter_config(".shellcheckrc", "--rcfile") + .desc("Lint shell scripts for common mistakes") .style(), Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) .fix("shfmt -w {FILE}") .formatter() + .desc("Format shell scripts") .style(), Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) .fix("markdownlint-cli2 --fix {FILE}") .linter_config(".markdownlint.jsonc", "--config") + .desc("Lint Markdown files for style and consistency") .install_key("npm:markdownlint-cli2"), Check::files( "prettier", @@ -354,6 +367,7 @@ pub fn builtin() -> Vec { .full_cmd("prettier --check {ROOT}", "prettier --write {ROOT}") .linter_config(".prettierrc", "--config") .formatter() + .desc("Format Markdown and YAML files") .install_key("npm:prettier"), Check::file( "actionlint", @@ -361,6 +375,7 @@ pub fn builtin() -> Vec { &[".github/workflows/*.yml", ".github/workflows/*.yaml"], ) .linter_config("actionlint.yml", "-config-file") + .desc("Lint GitHub Actions workflow files") .style(), Check::file( "hadolint", @@ -368,10 +383,12 @@ pub fn builtin() -> Vec { &["Dockerfile", "Dockerfile.*", "*.dockerfile"], ) .linter_config(".hadolint.yaml", "--config") + .desc("Lint Dockerfiles") .style(), Check::files("codespell", "codespell {FILES}", &["*"]) .fix("codespell --write-changes {FILES}") .linter_config(".codespellrc", "--config") + .desc("Check for common spelling mistakes") .install_key("pipx:codespell"), // Defer to formatters that enforce line length — those are the ones // that conflict with ec's max_line_length editorconfig check. @@ -380,18 +397,20 @@ pub fn builtin() -> Vec { .bin("ec") .mise_tool("editorconfig-checker") .defer_to_formatters() - .linter_config(".editorconfig-checker.json", "-config"), + .linter_config(".editorconfig-checker.json", "-config") + .desc("Check files comply with EditorConfig settings"), Check::project( "golangci-lint", "golangci-lint run --new-from-rev={MERGE_BASE}", &["*.go"], ) .linter_config(".golangci.yml", "--config") - .lang() - .note("uses --new-from-rev to lint only changed code"), + .desc("Lint Go code; uses --new-from-rev to scope analysis to changed code") + .lang(), Check::file("ruff", "ruff check {FILE}", &["*.py"]) .fix("ruff check --fix {FILE}") .linter_config("ruff.toml", "--config") + .desc("Lint Python code") .install_key("pipx:ruff") .lang(), Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]) @@ -399,6 +418,7 @@ pub fn builtin() -> Vec { .fix("ruff format {FILE}") .linter_config("ruff.toml", "--config") .formatter() + .desc("Format Python code") .install_key("pipx:ruff") .lang(), Check::file( @@ -407,6 +427,7 @@ pub fn builtin() -> Vec { &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], ) .fix("biome check --fix {FILE}") + .desc("Lint JS/TS/JSON files") .install_key("npm:@biomejs/biome") .lang(), Check::file( @@ -417,26 +438,28 @@ pub fn builtin() -> Vec { .bin("biome") .fix("biome format --write {FILE}") .formatter() + .desc("Format JS/TS/JSON files") .install_key("npm:@biomejs/biome") .lang(), Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") .mise_tool("rust") .install_components("clippy") - .lang() - .note("lints all .rs files, not just changed"), + .desc("Lint Rust code; runs on all .rs files, not just changed") + .lang(), Check::project("cargo-fmt", "cargo fmt -- --check", &["*.rs"]) .fix("cargo fmt") .bin("rustfmt") .mise_tool("rust") .install_components("rustfmt") .formatter() - .lang() - .note("formats all .rs files, not just changed"), + .desc("Format Rust code; runs on all .rs files, not just changed") + .lang(), Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) .fix("gofmt -w {FILE}") .mise_tool("go") .formatter() + .desc("Format Go code") .lang(), Check::files( "google-java-format", @@ -446,6 +469,7 @@ pub fn builtin() -> Vec { .fix("google-java-format -i {FILES}") .mise_tool("github:google/google-java-format") .formatter() + .desc("Format Java code") .lang(), Check::files( "ktlint", @@ -464,6 +488,7 @@ pub fn builtin() -> Vec { "ktlint" }) .formatter() + .desc("Lint and format Kotlin code") .lang(), Check::files( "dotnet-format", @@ -475,17 +500,52 @@ pub fn builtin() -> Vec { .bin("dotnet") .mise_tool("dotnet") .formatter() + .desc("Format C# code") .lang(), - Check::special("lychee", "lychee", SpecialKind::Links), + Check::special("lychee", "lychee", SpecialKind::Links) + .desc("Check for broken links") + .docs( + "Orchestrates [lychee](https://lychee.cli.rs/) for link checking. \ + Requires `lychee` in `[tools]`.\n\ + \n\ + Default behavior: checks all links in changed files. \ + When `check_all_local = true` in `flint.toml`, adds a second pass \ + over local links in all files — useful when broken internal links \ + from unchanged files also matter.\n\ + \n\ + Configure via `flint.toml`:\n\ + \n\ + ```toml\n\ + [checks.links]\n\ + config = \".github/config/lychee.toml\"\n\ + check_all_local = true\n\ + ```", + ), Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) .mise_tool("npm:renovate") - .patterns(RENOVATE_CONFIG_PATTERNS), + .patterns(RENOVATE_CONFIG_PATTERNS) + .desc("Verify Renovate dependency snapshot is up to date") + .docs( + "Verifies `.github/renovate-tracked-deps.json` is up to date by running \ + Renovate locally and comparing its output against the committed snapshot. \ + Requires `renovate` in `[tools]`.\n\ + \n\ + With `--fix`, automatically regenerates and commits the snapshot.\n\ + \n\ + Configure via `flint.toml`:\n\ + \n\ + ```toml\n\ + [checks.renovate-deps]\n\ + exclude_managers = [\"github-actions\", \"github-runners\"]\n\ + ```", + ), Check::special( "license-header", "license-header", SpecialKind::LicenseHeader, ) - .activate_unconditionally(), + .activate_unconditionally() + .desc("Check source files have the required license header"), ] } @@ -669,12 +729,16 @@ mod tests { return; } + // Normalize both sides: strip blank lines that prettier adds around + // headings, tables, and code blocks. This keeps the comparison stable + // even when docs contain multi-paragraph content with blank lines. let actual = extract_readme_table(&readme); - if actual != expected { + let expected_norm = strip_blank_lines(&expected); + if actual != expected_norm { panic!( "README linter table is out of sync with the registry.\n\ Run `UPDATE_README=1 cargo test readme_linter_table_in_sync` to regenerate.\n\n\ - Expected:\n{expected}\n\nActual:\n{actual}" + Expected:\n{expected_norm}\n\nActual:\n{actual}" ); } } @@ -682,6 +746,13 @@ mod tests { const README_TABLE_START: &str = ""; const README_TABLE_END: &str = ""; + fn strip_blank_lines(s: &str) -> String { + s.lines() + .filter(|l| !l.trim().is_empty()) + .collect::>() + .join("\n") + } + fn extract_readme_table(readme: &str) -> String { let start = readme .find(README_TABLE_START) @@ -690,12 +761,9 @@ mod tests { let end = readme .find(README_TABLE_END) .expect("README missing marker"); - // Strip blank lines that prettier inserts around comments and tables. - readme[start..end] - .lines() - .filter(|l| !l.trim().is_empty()) - .collect::>() - .join("\n") + // Strip blank lines that prettier inserts around headings, tables, and + // code blocks — and that linter docs contain between paragraphs. + strip_blank_lines(&readme[start..end]) } fn replace_readme_table(readme: &str, table: &str) -> String { @@ -717,27 +785,18 @@ mod tests { } fn generate_readme_table(registry: &[Check]) -> String { - // Build raw cell values for every row (header + data). - let headers = [ - "Name", - "Binary", - "Patterns", - "Fix", - "Slow", - "Scope", - "Config file", - "Notes", - ]; - let rows: Vec<[String; 8]> = registry.iter().map(table_row).collect(); + let generated_comment = ""; + + // Summary table: Name | Description | Fix + let headers = ["Name", "Description", "Fix"]; + let rows: Vec<[String; 3]> = registry.iter().map(summary_row).collect(); - // Compute column widths. let mut widths = headers.map(|h| h.len()); for row in &rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); } } - let fmt_row = |cells: &[&str]| -> String { let cols: Vec = cells .iter() @@ -746,12 +805,10 @@ mod tests { .collect(); format!("| {} |", cols.join(" | ")) }; - let separator: Vec = widths.iter().map(|&w| "-".repeat(w)).collect(); let sep_row = format!("| {} |", separator.join(" | ")); - let header_strs: Vec<&str> = headers.iter().copied().collect(); - let generated_comment = ""; + let mut lines = vec![ generated_comment.to_string(), fmt_row(&header_strs), @@ -761,28 +818,67 @@ mod tests { let strs: Vec<&str> = row.iter().map(|s| s.as_str()).collect(); lines.push(fmt_row(&strs)); } + + // Per-linter detail sections + for check in registry { + lines.push(format!("#### `{}`", check.name)); + lines.push(detail_table(check)); + } + lines.join("\n") } - fn table_row(check: &Check) -> [String; 8] { + fn summary_row(check: &Check) -> [String; 3] { let name = format!("`{}`", check.name); + let desc = if check.desc.is_empty() { + "—".to_string() + } else { + check.desc.to_string() + }; + let fix = if check.has_fix() { "yes" } else { "—" }.to_string(); + [name, desc, fix] + } + + fn detail_table(check: &Check) -> String { + let rows = detail_rows(check); + + let col1_w = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + let col2_w = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0); + + let fmt = |k: &str, v: &str| format!("| {: Vec<(&'static str, String)> { + let mut rows: Vec<(&'static str, String)> = vec![]; + + if !check.desc.is_empty() { + rows.push(("Description", check.desc.to_string())); + } + + rows.push(( + "Fix", + if check.has_fix() { "yes" } else { "no" }.to_string(), + )); + let binary = if check.uses_binary() { format!("`{}`", check.bin_name) } else { "(built-in)".to_string() }; - let patterns = if check.patterns.is_empty() { - "(all files)".to_string() - } else { - format!("`{}`", check.patterns.join(" ")) - }; - let fix = if check.has_fix() { "yes" } else { "no" }.to_string(); - let slow = if check.category == Category::Slow { - "yes" - } else { - "—" - } - .to_string(); + rows.push(("Binary", binary)); + let scope = match &check.kind { CheckKind::Template { scope, .. } => match scope { Scope::File => "file", @@ -790,19 +886,27 @@ mod tests { Scope::Project => "project", }, CheckKind::Special(_) => "special", + }; + rows.push(("Scope", format!("[{scope}](#scopes)"))); + + if !check.patterns.is_empty() { + rows.push(("Patterns", format!("`{}`", check.patterns.join(" ")))); } - .to_string(); - let config_file = match check.linter_config { - Some((filename, _)) => format!("`{filename}`"), - None => match &check.kind { - CheckKind::Special(SpecialKind::Links) => { - "via `[checks.links]` in flint.toml".to_string() + + match check.linter_config { + Some((filename, _)) => rows.push(("Config", format!("`{filename}`"))), + None => { + if matches!(&check.kind, CheckKind::Special(SpecialKind::Links)) { + rows.push(("Config", "via `[checks.links]` in flint.toml".to_string())); } - _ => "—".to_string(), - }, - }; - let notes = check.note.unwrap_or("—").to_string(); - [name, binary, patterns, fix, slow, scope, config_file, notes] + } + } + + if check.category == Category::Slow { + rows.push(("Slow", "yes — skipped by `--fast-only`".to_string())); + } + + rows } /// Smoke test: every check whose tool key resolves in this repo's expanded diff --git a/src/runner.rs b/src/runner.rs index ddf57f0..79481fd 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -650,7 +650,8 @@ mod tests { full_fix_cmd: "", scope: Scope::Project, }, - note: None, + desc: "", + docs: "", } } From 099853fe33f4ed6993da22180d4a68072d98b750 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 12:06:38 +0000 Subject: [PATCH 101/141] refactor(config): replace exclude/exclude_paths with single glob list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate mechanisms (`exclude` as regex, `exclude_paths` as prefix list) were unintuitive — users had no clear signal which to use or that one was a regex. Replace both with `exclude = ["glob", ...]` using gitignore-style glob patterns, backed by the `globset` crate. Migration: `exclude = "foo\\.md|bar/.*"` → `exclude = ["foo.md", "bar/**"]` --- .github/config/flint.toml | 3 +- Cargo.lock | 24 +++++++++++ Cargo.toml | 1 + README.md | 2 +- src/config.rs | 6 +-- src/files.rs | 42 +++++++++---------- tests/cases/general/env-var-exclude/test.toml | 2 +- .../general/exclude-paths/files/flint.toml | 2 +- 8 files changed, 50 insertions(+), 32 deletions(-) diff --git a/.github/config/flint.toml b/.github/config/flint.toml index 401a260..b3f85fc 100644 --- a/.github/config/flint.toml +++ b/.github/config/flint.toml @@ -1,6 +1,5 @@ [settings] -exclude = "CHANGELOG\\.md" -exclude_paths = ["tests/cases/"] +exclude = ["CHANGELOG.md", "tests/cases/**"] [checks.renovate-deps] exclude_managers = ["github-actions", "github-runners", "cargo"] diff --git a/Cargo.lock b/Cargo.lock index abd8543..00b3731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -215,6 +225,7 @@ dependencies = [ "clap", "crossterm", "figment", + "globset", "regex", "semver", "serde", @@ -245,6 +256,19 @@ dependencies = [ "wasip3", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.15.5" diff --git a/Cargo.toml b/Cargo.toml index e85c66c..9d8d6f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ toml = "0.8" tokio = { version = "1", features = ["full"] } semver = "1" regex = "1" +globset = "0.4" serde_json = "1" similar = "2" toml_edit = "0.22" diff --git a/README.md b/README.md index 9626ba2..61fae57 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ Optional. Place in the repo root (or in `FLINT_CONFIG_DIR` — see below). All s ```toml [settings] base_branch = "main" # branch to diff against -exclude = "CHANGELOG\\.md|vendor/.*" # regex — exclude matching files +exclude = ["CHANGELOG.md", "vendor/**"] # glob patterns — exclude matching files [checks.links] config = ".github/config/lychee.toml" # lychee config path diff --git a/src/config.rs b/src/config.rs index 211deb2..4685804 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,16 +20,14 @@ pub struct Config { #[serde(default)] pub struct Settings { pub base_branch: String, - pub exclude: Option, - pub exclude_paths: Vec, + pub exclude: Vec, } impl Default for Settings { fn default() -> Self { Self { base_branch: "main".to_string(), - exclude: None, - exclude_paths: vec![], + exclude: vec![], } } } diff --git a/src/files.rs b/src/files.rs index 3d89600..8beac20 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -25,21 +26,20 @@ pub fn changed( from_ref: Option<&str>, to_ref: Option<&str>, ) -> Result { - let exclude_re = compile_exclude_re(cfg); - let exclude_paths = &cfg.settings.exclude_paths; + let exclude = build_exclude_set(cfg); if full { - return all_files(project_root, exclude_re.as_ref(), exclude_paths); + return all_files(project_root, &exclude); } let merge_base = resolve_merge_base(project_root, cfg, from_ref)?; let files = if let Some(ref base) = merge_base { let to = to_ref.unwrap_or("HEAD"); - collect_changed_files(project_root, exclude_re.as_ref(), exclude_paths, base, to)? + collect_changed_files(project_root, &exclude, base, to)? } else { // No merge base (shallow clone etc.) — fall back to all files. - return all_files(project_root, exclude_re.as_ref(), exclude_paths); + return all_files(project_root, &exclude); }; Ok(FileList { @@ -49,11 +49,14 @@ pub fn changed( }) } -fn compile_exclude_re(cfg: &Config) -> Option { - cfg.settings - .exclude - .as_deref() - .and_then(|pat| regex::Regex::new(pat).ok()) +fn build_exclude_set(cfg: &Config) -> GlobSet { + let mut builder = GlobSetBuilder::new(); + for pattern in &cfg.settings.exclude { + if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() { + builder.add(glob); + } + } + builder.build().unwrap_or_default() } fn resolve_merge_base( @@ -82,8 +85,7 @@ fn resolve_merge_base( fn collect_changed_files( project_root: &Path, - exclude_re: Option<®ex::Regex>, - exclude_paths: &[String], + exclude: &GlobSet, base: &str, to: &str, ) -> Result> { @@ -103,14 +105,10 @@ fn collect_changed_files( names.insert(line); } - Ok(filter_names(project_root, exclude_re, exclude_paths, names)) + Ok(filter_names(project_root, exclude, names)) } -fn all_files( - project_root: &Path, - exclude_re: Option<®ex::Regex>, - exclude_paths: &[String], -) -> Result { +fn all_files(project_root: &Path, exclude: &GlobSet) -> Result { let out = Command::new("git") .args(["ls-files"]) .current_dir(project_root) @@ -123,7 +121,7 @@ fn all_files( .collect(); Ok(FileList { - files: filter_names(project_root, exclude_re, exclude_paths, names), + files: filter_names(project_root, exclude, names), merge_base: None, full: true, }) @@ -145,15 +143,13 @@ fn git_diff_names(project_root: &Path, extra_args: &[&str]) -> Result, - exclude_paths: &[String], + exclude: &GlobSet, names: std::collections::BTreeSet, ) -> Vec { names .into_iter() .filter(|name| !BUILTIN_EXCLUDES.contains(&name.as_str())) - .filter(|name| exclude_re.is_none_or(|re| !re.is_match(name))) - .filter(|name| !exclude_paths.iter().any(|p| name.starts_with(p.as_str()))) + .filter(|name| !exclude.is_match(name)) .map(|name| project_root.join(name)) .collect() } diff --git a/tests/cases/general/env-var-exclude/test.toml b/tests/cases/general/env-var-exclude/test.toml index fed794b..d647a1e 100644 --- a/tests/cases/general/env-var-exclude/test.toml +++ b/tests/cases/general/env-var-exclude/test.toml @@ -4,4 +4,4 @@ exit = 0 [env] -FLINT_EXCLUDE = "bad\\.sh" +FLINT_EXCLUDE = '["bad.sh"]' diff --git a/tests/cases/general/exclude-paths/files/flint.toml b/tests/cases/general/exclude-paths/files/flint.toml index 970075d..0dc9a0d 100644 --- a/tests/cases/general/exclude-paths/files/flint.toml +++ b/tests/cases/general/exclude-paths/files/flint.toml @@ -1,2 +1,2 @@ [settings] -exclude_paths = ["excluded/"] +exclude = ["excluded/**"] From 7840e3eff15c613b5e89c33ab5cd29583f5acd7c Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 12:43:11 +0000 Subject: [PATCH 102/141] feat(hook): add `flint hook install` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writes .git/hooks/pre-commit (runs `flint run --fix --fast-only`) directly from the CLI. Removes the `setup:pre-commit-hook` mise task from generated config — flint hook install replaces it. flint init still offers to install the hook interactively and now calls the same logic internally. --- AGENTS.md | 2 +- src/hook.rs | 37 +++++++++++++++++++++++ src/init/generation.rs | 48 ++++++------------------------ src/init/mod.rs | 32 +------------------- src/main.rs | 18 +++++++++++ tests/cases/general/list/test.toml | 48 +++++++++++++++--------------- 6 files changed, 90 insertions(+), 95 deletions(-) create mode 100644 src/hook.rs diff --git a/AGENTS.md b/AGENTS.md index 4bdbbdb..ae28cfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ mise run lint:fix mise run lint # Install git pre-commit hook (one-time, opt-in) -mise run setup:pre-commit-hook +flint hook install ``` ## Commit Messages diff --git a/src/hook.rs b/src/hook.rs new file mode 100644 index 0000000..b14f11a --- /dev/null +++ b/src/hook.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use std::path::Path; + +const HOOK_CONTENT: &str = "#!/bin/sh\n\ +# Installed by flint — run `flint hook install` to reinstall\n\ +flint run --fix --fast-only\n"; + +/// Writes `.git/hooks/pre-commit`. Skips silently if the hook already exists. +pub fn install(project_root: &Path) -> Result<()> { + let git_dir = project_root.join(".git"); + if !git_dir.exists() { + anyhow::bail!("not a git repository (no .git directory found)"); + } + let hooks_dir = git_dir.join("hooks"); + std::fs::create_dir_all(&hooks_dir)?; + let hook_path = hooks_dir.join("pre-commit"); + if hook_path.exists() { + println!("pre-commit hook already installed"); + return Ok(()); + } + std::fs::write(&hook_path, HOOK_CONTENT)?; + set_executable(&hook_path)?; + println!("installed pre-commit hook (.git/hooks/pre-commit)"); + Ok(()) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/src/init/generation.rs b/src/init/generation.rs index 509c32f..5229e53 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -538,8 +538,8 @@ fn task_has_removed_dep(tasks: &toml_edit::Table, name: &str, removed: &[String] }) } -/// Adds `[env] FLINT_CONFIG_DIR` and the standard `lint*` / `setup:pre-commit-hook` -/// tasks to `mise.toml`, skipping any that are already present. +/// Adds `[env] FLINT_CONFIG_DIR` and the standard `lint*` tasks to `mise.toml`, +/// skipping any that are already present. /// /// When `removed_v1_tasks` is non-empty, standard tasks whose `depends` reference /// any of those removed tasks are replaced (they became stale after v1 removal). @@ -569,7 +569,7 @@ pub(super) fn apply_env_and_tasks( } } - // [tasks] — add lint / lint:fix / (lint:pre-commit) / setup:pre-commit-hook + // [tasks] — add lint / lint:fix / (lint:pre-commit) { if !doc.contains_key("tasks") { doc.insert("tasks", toml_edit::Item::Table(toml_edit::Table::new())); @@ -597,24 +597,6 @@ pub(super) fn apply_env_and_tasks( "flint run --fix --fast-only", ); } - let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; - // Also replace setup:pre-commit-hook when the lint task was stale — the old hook - // was pointing at a v1-era task name and needs to be updated. - if lint_stale { - write_task( - tasks, - "setup:pre-commit-hook", - "Install git pre-commit hook", - &format!("mise generate git-pre-commit --write --task={hook_task}"), - ); - } else { - changed |= add_task_if_absent( - tasks, - "setup:pre-commit-hook", - "Install git pre-commit hook", - &format!("mise generate git-pre-commit --write --task={hook_task}"), - ); - } } if changed { @@ -623,9 +605,9 @@ pub(super) fn apply_env_and_tasks( Ok(changed) } -/// Installs the git pre-commit hook by running `mise generate git-pre-commit`. +/// Offers to install the git pre-commit hook via `flint hook install`. /// Prompts the user unless `yes` is true. Silently skips if the hook is already installed. -pub(super) fn maybe_install_hook(project_root: &Path, hook_task: &str, yes: bool) -> Result<()> { +pub(super) fn maybe_install_hook(project_root: &Path, yes: bool) -> Result<()> { let hook_path = project_root.join(".git/hooks/pre-commit"); if hook_path.exists() { return Ok(()); @@ -634,7 +616,9 @@ pub(super) fn maybe_install_hook(project_root: &Path, hook_task: &str, yes: bool let install = if yes { true } else { - print!("Install pre-commit hook (runs `mise run {hook_task}` before each commit)? [Y/n] "); + print!( + "Install pre-commit hook (runs `flint run --fix --fast-only` before each commit)? [Y/n] " + ); io::stdout().flush()?; let mut input = String::new(); io::stdin().lock().read_line(&mut input)?; @@ -642,21 +626,7 @@ pub(super) fn maybe_install_hook(project_root: &Path, hook_task: &str, yes: bool }; if install { - let status = Command::new("mise") - .args([ - "generate", - "git-pre-commit", - "--write", - &format!("--task={hook_task}"), - ]) - .current_dir(project_root) - .status(); - match status { - Ok(s) if s.success() => println!(" installed pre-commit hook"), - _ => println!( - " warning: could not install pre-commit hook — run `mise run setup:pre-commit-hook` later" - ), - } + crate::hook::install(project_root)?; } Ok(()) } diff --git a/src/init/mod.rs b/src/init/mod.rs index 55a7186..faf7176 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -302,8 +302,7 @@ Add and stage your source files before running init so the detection is accurate return Ok(()); } - let hook_task = if has_slow { "lint:pre-commit" } else { "lint" }; - maybe_install_hook(project_root, hook_task, yes)?; + maybe_install_hook(project_root, yes)?; println!("Done. Run `mise install` to install the new tools."); Ok(()) @@ -700,7 +699,6 @@ rust = { version = "1.0", components = "clippy" } assert!(content.contains("flint run")); assert!(content.contains("flint run --fix")); assert!(!content.contains("--fast-only")); // no slow linters - assert!(content.contains("setup:pre-commit-hook")); } #[test] @@ -711,8 +709,6 @@ rust = { version = "1.0", components = "clippy" } let content = std::fs::read_to_string(tmp.path()).unwrap(); assert!(content.contains("--fast-only")); assert!(content.contains("lint:pre-commit")); - // Hook task should point to lint:pre-commit - assert!(content.contains("--task=lint:pre-commit")); } #[test] @@ -748,30 +744,4 @@ depends = ["lint:fast", "lint:renovate-deps"] "old depends array removed: {result}" ); } - - #[test] - fn apply_env_and_tasks_replaces_stale_hook_task() { - let content = r#" -[tasks."lint"] -description = "Run all lints" -depends = ["lint:renovate-deps"] - -[tasks."setup:pre-commit-hook"] -description = "Install pre-commit hook" -run = "mise generate git-pre-commit --write --task=pre-commit" -"#; - let tmp = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(tmp.path(), content).unwrap(); - let removed = vec!["lint:renovate-deps".to_string()]; - apply_env_and_tasks(tmp.path(), ".github/config", false, &removed).unwrap(); - let result = std::fs::read_to_string(tmp.path()).unwrap(); - assert!( - result.contains("--task=lint"), - "hook task updated to lint: {result}" - ); - assert!( - !result.contains("--task=pre-commit"), - "old hook task reference removed: {result}" - ); - } } diff --git a/src/main.rs b/src/main.rs index 2de8114..b8e96ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod files; +mod hook; mod init; mod linters; mod registry; @@ -27,10 +28,24 @@ enum SubCommand { Linters(LintersArgs), /// Set up linters in mise.toml for this project. Init(InitArgs), + /// Manage git hooks. + Hook(HookArgs), /// Display the flint version. Version, } +#[derive(Args, Debug)] +struct HookArgs { + #[command(subcommand)] + command: HookCommand, +} + +#[derive(Subcommand, Debug)] +enum HookCommand { + /// Install a pre-commit hook that runs `flint run --fix --fast-only`. + Install, +} + #[derive(Args, Debug)] struct LintersArgs { /// Output as JSON instead of the human-readable table. @@ -121,6 +136,9 @@ async fn main() -> Result<()> { SubCommand::Init(args) => { init::run(&project_root, args.profile, args.yes)?; } + SubCommand::Hook(args) => match args.command { + HookCommand::Install => hook::install(&project_root)?, + }, SubCommand::Run(args) => { run(args, &project_root, &config_dir, ®istry).await?; } diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index cb89cd9..80847a8 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -2,30 +2,30 @@ args = "linters" exit = 0 stdout = ''' -NAME BINARY STATUS SPEED PATTERNS -------------------------------------------------------------------------- -shellcheck shellcheck active fast *.sh *.bash *.bats -shfmt shfmt active fast *.sh *.bash -markdownlint-cli2 markdownlint-cli2 active fast *.md -prettier prettier active fast *.md *.yml *.yaml -actionlint actionlint active fast .github/workflows/*.yml .github/workflows/*.yaml -hadolint hadolint missing fast Dockerfile Dockerfile.* *.dockerfile -codespell codespell active fast * -editorconfig-checker ec active fast * -golangci-lint golangci-lint missing fast *.go -ruff ruff active fast *.py -ruff-format ruff active fast *.py -biome biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx -biome-format biome active fast *.json *.jsonc *.js *.ts *.jsx *.tsx -cargo-clippy cargo-clippy active fast *.rs -cargo-fmt rustfmt active fast *.rs -gofmt gofmt missing fast *.go -google-java-format google-java-format missing fast *.java -ktlint ktlint missing fast *.kt *.kts -dotnet-format dotnet missing fast *.cs -lychee lychee active fast -renovate-deps renovate active fast renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5 -license-header license-header not configured fast +NAME BINARY STATUS SPEED FIX DESCRIPTION PATTERNS +--------------------------------------------------------------------------------------------------------------------------------------------------- +shellcheck shellcheck active fast no Lint shell scripts for common mistakes *.sh *.bash *.bats +shfmt shfmt active fast yes Format shell scripts *.sh *.bash +markdownlint-cli2 markdownlint-cli2 active fast yes Lint Markdown files for style and consistency *.md +prettier prettier active fast yes Format Markdown and YAML files *.md *.yml *.yaml +actionlint actionlint active fast no Lint GitHub Actions workflow files .github/workflows/*.yml .github/workflows/*.yaml +hadolint hadolint missing fast no Lint Dockerfiles Dockerfile Dockerfile.* *.dockerfile +codespell codespell active fast yes Check for common spelling mistakes * +editorconfig-checker ec active fast no Check files comply with EditorConfig settings * +golangci-lint golangci-lint missing fast no Lint Go code; uses --new-from-rev to scope analysis to changed code *.go +ruff ruff active fast yes Lint Python code *.py +ruff-format ruff active fast yes Format Python code *.py +biome biome active fast yes Lint JS/TS/JSON files *.json *.jsonc *.js *.ts *.jsx *.tsx +biome-format biome active fast yes Format JS/TS/JSON files *.json *.jsonc *.js *.ts *.jsx *.tsx +cargo-clippy cargo-clippy active fast yes Lint Rust code; runs on all .rs files, not just changed *.rs +cargo-fmt rustfmt active fast yes Format Rust code; runs on all .rs files, not just changed *.rs +gofmt gofmt missing fast yes Format Go code *.go +google-java-format google-java-format missing fast yes Format Java code *.java +ktlint ktlint missing fast yes Lint and format Kotlin code *.kt *.kts +dotnet-format dotnet missing fast yes Format C# code *.cs +lychee lychee active fast no Check for broken links +renovate-deps renovate active fast yes Verify Renovate dependency snapshot is up to date renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5 +license-header license-header not configured fast no Check source files have the required license header ''' [fake_bins] actionlint = ''' From f15ab8a8ac0dfdc36b0719339e67c482f507c222 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 13:18:26 +0000 Subject: [PATCH 103/141] feat(xmllint): add xmllint linter via cargo:xmloxide Validates XML well-formedness using xmllint --noout. Binary provided by cargo:xmloxide (pure-Rust libxml2 reimplementation, drop-in binary name). Activated by cargo:xmloxide in mise.toml. --- .github/renovate-tracked-deps.json | 1 + README.md | 11 +++++++++++ mise.toml | 1 + src/registry.rs | 4 ++++ tests/cases/general/list/test.toml | 1 + tests/cases/xmllint/clean/files/mise.toml | 2 ++ tests/cases/xmllint/clean/files/pom.xml | 4 ++++ tests/cases/xmllint/clean/test.toml | 3 +++ tests/cases/xmllint/failure/files/mise.toml | 2 ++ tests/cases/xmllint/failure/files/pom.xml | 2 ++ tests/cases/xmllint/failure/test.toml | 10 ++++++++++ 11 files changed, 41 insertions(+) create mode 100644 tests/cases/xmllint/clean/files/mise.toml create mode 100644 tests/cases/xmllint/clean/files/pom.xml create mode 100644 tests/cases/xmllint/clean/test.toml create mode 100644 tests/cases/xmllint/failure/files/mise.toml create mode 100644 tests/cases/xmllint/failure/files/pom.xml create mode 100644 tests/cases/xmllint/failure/test.toml diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 9f1464c..97e6393 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -7,6 +7,7 @@ "mise.toml": { "mise": [ "actionlint", + "cargo:xmloxide", "dotnet", "editorconfig-checker", "github:pinterest/ktlint", diff --git a/README.md b/README.md index 61fae57..a6b30be 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ being linted and cannot be redirected via a flag. | `prettier` | Format Markdown and YAML files | yes | | `actionlint` | Lint GitHub Actions workflow files | — | | `hadolint` | Lint Dockerfiles | — | +| `xmllint` | Validate XML files are well-formed | — | | `codespell` | Check for common spelling mistakes | yes | | `editorconfig-checker` | Check files comply with EditorConfig settings | — | | `golangci-lint` | Lint Go code; uses --new-from-rev to scope analysis to changed code | — | @@ -321,6 +322,16 @@ being linted and cannot be redirected via a flag. | Patterns | `Dockerfile Dockerfile.* *.dockerfile` | | Config | `.hadolint.yaml` | +#### `xmllint` + +| | | +| ----------- | ---------------------------------- | +| Description | Validate XML files are well-formed | +| Fix | no | +| Binary | `xmllint` | +| Scope | [files](#scopes) | +| Patterns | `*.xml` | + #### `codespell` | | | diff --git a/mise.toml b/mise.toml index a2442b3..1da7c45 100644 --- a/mise.toml +++ b/mise.toml @@ -18,6 +18,7 @@ rust = { version = "1.94.1", components = "clippy,rustfmt" } hadolint = "2.14.0" "github:pinterest/ktlint" = "1.8.0" dotnet = "10.0.201" +"cargo:xmloxide" = "0.4.1" [tasks."setup:update-super-linter-versions"] description = "Generate super-linter version mapping from the super-linter repo" diff --git a/src/registry.rs b/src/registry.rs index e5fefcb..80f27f8 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -385,6 +385,10 @@ pub fn builtin() -> Vec { .linter_config(".hadolint.yaml", "--config") .desc("Lint Dockerfiles") .style(), + Check::files("xmllint", "xmllint --noout {FILES}", &["*.xml"]) + .mise_tool("cargo:xmloxide") + .install_key("cargo:xmloxide") + .desc("Validate XML files are well-formed"), Check::files("codespell", "codespell {FILES}", &["*"]) .fix("codespell --write-changes {FILES}") .linter_config(".codespellrc", "--config") diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index 80847a8..e149c88 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -10,6 +10,7 @@ markdownlint-cli2 markdownlint-cli2 active fast yes Lint Markdow prettier prettier active fast yes Format Markdown and YAML files *.md *.yml *.yaml actionlint actionlint active fast no Lint GitHub Actions workflow files .github/workflows/*.yml .github/workflows/*.yaml hadolint hadolint missing fast no Lint Dockerfiles Dockerfile Dockerfile.* *.dockerfile +xmllint xmllint missing fast no Validate XML files are well-formed *.xml codespell codespell active fast yes Check for common spelling mistakes * editorconfig-checker ec active fast no Check files comply with EditorConfig settings * golangci-lint golangci-lint missing fast no Lint Go code; uses --new-from-rev to scope analysis to changed code *.go diff --git a/tests/cases/xmllint/clean/files/mise.toml b/tests/cases/xmllint/clean/files/mise.toml new file mode 100644 index 0000000..9c883ed --- /dev/null +++ b/tests/cases/xmllint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"cargo:xmloxide" = "0.4.1" diff --git a/tests/cases/xmllint/clean/files/pom.xml b/tests/cases/xmllint/clean/files/pom.xml new file mode 100644 index 0000000..3c5b346 --- /dev/null +++ b/tests/cases/xmllint/clean/files/pom.xml @@ -0,0 +1,4 @@ + + io.prometheus + client_java + diff --git a/tests/cases/xmllint/clean/test.toml b/tests/cases/xmllint/clean/test.toml new file mode 100644 index 0000000..8cc15a8 --- /dev/null +++ b/tests/cases/xmllint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full xmllint" +exit = 0 diff --git a/tests/cases/xmllint/failure/files/mise.toml b/tests/cases/xmllint/failure/files/mise.toml new file mode 100644 index 0000000..9c883ed --- /dev/null +++ b/tests/cases/xmllint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"cargo:xmloxide" = "0.4.1" diff --git a/tests/cases/xmllint/failure/files/pom.xml b/tests/cases/xmllint/failure/files/pom.xml new file mode 100644 index 0000000..8221d67 --- /dev/null +++ b/tests/cases/xmllint/failure/files/pom.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/cases/xmllint/failure/test.toml b/tests/cases/xmllint/failure/test.toml new file mode 100644 index 0000000..c0630f4 --- /dev/null +++ b/tests/cases/xmllint/failure/test.toml @@ -0,0 +1,10 @@ +[expected] +args = "run --full xmllint" +exit = 1 +stderr = ''' +[xmllint] +/pom.xml: parse error at 3:1: unexpected end of input in element content + +flint: 1 check failed (xmllint) +šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file From 7be70390f42f037797aeb4b2c5a4b9e810cd2d69 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 14:45:44 +0000 Subject: [PATCH 104/141] fix: address Copilot review comments on robustness and correctness - lychee: pass project_root to run_lychee_cmd and set current_dir explicitly so relative config paths work regardless of cwd - files: check git ls-files and git diff --name-only exit status, returning errors with stderr context on failure instead of silently producing empty file lists - files: emit eprintln! for invalid exclude glob patterns instead of silently skipping them - main: error when explicitly-requested linter is not active (binary missing or not declared in mise.toml) rather than running nothing - tests/e2e: assert git command success in all git helper invocations with informative failure messages including stderr --- src/files.rs | 24 ++++++++++++++++++++++-- src/linters/lychee.rs | 6 ++++++ src/main.rs | 22 +++++++++++++++++----- tests/e2e.rs | 37 +++++++++++++++++++++++++++++-------- 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/files.rs b/src/files.rs index 8beac20..9d786fe 100644 --- a/src/files.rs +++ b/src/files.rs @@ -52,8 +52,13 @@ pub fn changed( fn build_exclude_set(cfg: &Config) -> GlobSet { let mut builder = GlobSetBuilder::new(); for pattern in &cfg.settings.exclude { - if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() { - builder.add(glob); + match GlobBuilder::new(pattern).literal_separator(true).build() { + Ok(glob) => { + builder.add(glob); + } + Err(e) => { + eprintln!("flint: invalid exclude pattern {pattern:?}: {e}"); + } } } builder.build().unwrap_or_default() @@ -115,6 +120,11 @@ fn all_files(project_root: &Path, exclude: &GlobSet) -> Result { .output() .context("git ls-files")?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + anyhow::bail!("git ls-files failed ({}): {}", out.status, stderr.trim()); + } + let names: std::collections::BTreeSet = String::from_utf8_lossy(&out.stdout) .lines() .map(str::to_string) @@ -135,6 +145,16 @@ fn git_diff_names(project_root: &Path, extra_args: &[&str]) -> Result LinterOutput { let mut argv: Vec = vec![ "lychee".to_string(), @@ -139,6 +144,7 @@ async fn run_lychee_cmd( let result = Command::new(&argv[0]) .args(&argv[1..]) + .current_dir(project_root) .stdin(Stdio::null()) .output() .await; diff --git a/src/main.rs b/src/main.rs index b8e96ed..08bc7b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,11 +178,23 @@ async fn run( // --fast-only filter (skipped when linters are named explicitly). // mise guarantees declared tools are on PATH, so no PATH check needed. let mise_tools = registry::read_mise_tools(project_root); - let active: Vec<®istry::Check> = checks - .into_iter() - .filter(|c| registry::check_active(c, &mise_tools)) - .filter(|c| explicit || !args.fast_only || c.category != registry::Category::Slow) - .collect(); + let active: Vec<®istry::Check> = { + let mut out = vec![]; + for c in checks { + if registry::check_active(c, &mise_tools) { + if explicit || !args.fast_only || c.category != registry::Category::Slow { + out.push(c); + } + } else if explicit { + eprintln!( + "flint: linter {name} is not active (binary not installed or not declared in mise.toml)", + name = c.name + ); + std::process::exit(1); + } + } + out + }; if args.verbose { let names: Vec<&str> = active.iter().map(|c| c.name).collect(); diff --git a/tests/e2e.rs b/tests/e2e.rs index 3d93b85..271ce31 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -30,11 +30,17 @@ fn git_repo() -> TempDir { vec!["config", "user.email", "test@test.com"], vec!["config", "user.name", "Test"], ] { - Command::new("git") + let out = Command::new("git") .args(&args) .current_dir(dir.path()) .output() - .expect("git failed"); + .expect("failed to spawn git"); + assert!( + out.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr) + ); } dir } @@ -188,16 +194,26 @@ fn run_case(case: &Path, name: &str, update: bool) { let files_dir = case.join("files"); copy_dir_into(&files_dir, repo.path()); - Command::new("git") + let out = Command::new("git") .args(["add", "-A"]) .current_dir(repo.path()) .output() - .expect("git add failed"); - Command::new("git") + .expect("failed to spawn git add"); + assert!( + out.status.success(), + "git add failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let out = Command::new("git") .args(["commit", "-q", "-m", "init"]) .current_dir(repo.path()) .output() - .expect("git commit failed"); + .expect("failed to spawn git commit"); + assert!( + out.status.success(), + "git commit failed: {}", + String::from_utf8_lossy(&out.stderr) + ); // If a `changes/` directory exists alongside `files/`, write those files // over the repo and stage them (but don't commit). This lets fixtures test @@ -205,11 +221,16 @@ fn run_case(case: &Path, name: &str, update: bool) { let changes_dir = case.join("changes"); if changes_dir.exists() { copy_dir_into(&changes_dir, repo.path()); - Command::new("git") + let out = Command::new("git") .args(["add", "-A"]) .current_dir(repo.path()) .output() - .expect("git add changes failed"); + .expect("failed to spawn git add"); + assert!( + out.status.success(), + "git add changes failed: {}", + String::from_utf8_lossy(&out.stderr) + ); } let env_vars: Vec<(String, String)> = cfg From 4e9f0f1a81cb56bff290943f580f886d1cf551ca Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 17:34:30 +0000 Subject: [PATCH 105/141] fix(review): address PR #139 review comments - hook: use `mise exec --` so pre-commit hook inherits FLINT_CONFIG_DIR - files: check exit status on git ls-files and git diff --name-only - files: warn on invalid exclude glob patterns instead of silently ignoring - lychee: set current_dir(project_root) on all lychee invocations - main: error when explicitly-requested linter is not active in mise.toml - e2e: assert git setup commands succeed with clear error messages - registry: sort README linter table A-Z - ci: name rust-cache step, use env vars instead of interpolation in release.yml - docs: use GitHub callout syntax ([!NOTE], [!WARNING], [!TIP]) in READMEs - docs: align flint.toml config comments, clarify build-from-source wording --- .github/workflows/lint.yml | 3 +- .github/workflows/release.yml | 24 ++- README-V1.md | 8 +- README.md | 307 +++++++++++++++++----------------- src/hook.rs | 2 +- src/registry.rs | 10 +- 6 files changed, 184 insertions(+), 170 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f8c54bb..101e60e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,7 +32,8 @@ jobs: - name: Install Rust lint components run: rustup component add clippy rustfmt - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Restore cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Lint env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcb244b..113f32d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,21 +36,28 @@ jobs: - name: Add target if: "!matrix.use_cross" - run: rustup target add ${{ matrix.target }} + env: + TARGET: ${{ matrix.target }} + run: rustup target add "$TARGET" - name: Build + env: + USE_CROSS: ${{ matrix.use_cross }} + TARGET: ${{ matrix.target }} run: | - if [ "${{ matrix.use_cross }}" = "true" ]; then - cross build --release --target ${{ matrix.target }} + if [ "${USE_CROSS}" = "true" ]; then + cross build --release --target "$TARGET" else - cargo build --release --target ${{ matrix.target }} + cargo build --release --target "$TARGET" fi - name: Package + env: + TARGET: ${{ matrix.target }} run: | - cd target/${{ matrix.target }}/release - tar czf flint-${{ matrix.target }}.tar.gz flint - mv flint-${{ matrix.target }}.tar.gz "$GITHUB_WORKSPACE/" + cd "target/${TARGET}/release" + tar czf "flint-${TARGET}.tar.gz" flint + mv "flint-${TARGET}.tar.gz" "$GITHUB_WORKSPACE/" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -65,7 +72,8 @@ jobs: contents: write steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Download artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: merge-multiple: true diff --git a/README-V1.md b/README-V1.md index 79b2423..7f88929 100644 --- a/README-V1.md +++ b/README-V1.md @@ -1,5 +1,6 @@ # flint v1 (legacy) +> [!NOTE] > **This is the legacy v1 documentation** (bash task scripts consumed as > mise HTTP remote tasks). The current version is [flint v2](README.md) — > a single Rust binary. @@ -55,9 +56,10 @@ radar. ## Usage -āš ļø **Important**: Always pin to a specific version, never use `main`. -The main branch may contain breaking changes. -See [CHANGELOG.md](CHANGELOG.md) for version history. +> [!WARNING] +> Always pin to a specific version, never use `main`. +> The main branch may contain breaking changes. +> See [CHANGELOG.md](CHANGELOG.md) for version history. Add whichever tasks you need as HTTP remote tasks in your `mise.toml`, pinned to the commit SHA of a release tag with a version comment: diff --git a/README.md b/README.md index a6b30be..88dd3b4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Linter runner built for speed and consistency: See [Why / Principles](#why) for background. +> [!TIP] > **Legacy v1** (bash task scripts): see [README-V1.md](README-V1.md). --- @@ -38,7 +39,7 @@ Add `flint` to your repo's `mise.toml` (once published): flint = "0.x.y" ``` -Until the first release, build from source: +Until the first published release, build from source: ```bash git clone https://github.com/grafana/flint @@ -194,15 +195,15 @@ Optional. Place in the repo root (or in `FLINT_CONFIG_DIR` — see below). All s ```toml [settings] -base_branch = "main" # branch to diff against -exclude = ["CHANGELOG.md", "vendor/**"] # glob patterns — exclude matching files +base_branch = "main" # branch to diff against +exclude = ["CHANGELOG.md", "vendor/**"] # glob patterns — exclude matching files [checks.links] -config = ".github/config/lychee.toml" # lychee config path -check_all_local = true # second pass: local links in all files +config = ".github/config/lychee.toml" # lychee config path +check_all_local = true # second pass: local links in all files [checks.renovate-deps] -exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers +exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers ``` ### `FLINT_CONFIG_DIR` @@ -233,72 +234,29 @@ being linted and cannot be redirected via a flag. | Name | Description | Fix | | ---------------------- | ------------------------------------------------------------------- | --- | -| `shellcheck` | Lint shell scripts for common mistakes | — | -| `shfmt` | Format shell scripts | yes | -| `markdownlint-cli2` | Lint Markdown files for style and consistency | yes | -| `prettier` | Format Markdown and YAML files | yes | | `actionlint` | Lint GitHub Actions workflow files | — | -| `hadolint` | Lint Dockerfiles | — | -| `xmllint` | Validate XML files are well-formed | — | -| `codespell` | Check for common spelling mistakes | yes | -| `editorconfig-checker` | Check files comply with EditorConfig settings | — | -| `golangci-lint` | Lint Go code; uses --new-from-rev to scope analysis to changed code | — | -| `ruff` | Lint Python code | yes | -| `ruff-format` | Format Python code | yes | | `biome` | Lint JS/TS/JSON files | yes | | `biome-format` | Format JS/TS/JSON files | yes | | `cargo-clippy` | Lint Rust code; runs on all .rs files, not just changed | yes | | `cargo-fmt` | Format Rust code; runs on all .rs files, not just changed | yes | +| `codespell` | Check for common spelling mistakes | yes | +| `dotnet-format` | Format C# code | yes | +| `editorconfig-checker` | Check files comply with EditorConfig settings | — | | `gofmt` | Format Go code | yes | +| `golangci-lint` | Lint Go code; uses --new-from-rev to scope analysis to changed code | — | | `google-java-format` | Format Java code | yes | +| `hadolint` | Lint Dockerfiles | — | | `ktlint` | Lint and format Kotlin code | yes | -| `dotnet-format` | Format C# code | yes | +| `license-header` | Check source files have the required license header | — | | `lychee` | Check for broken links | — | +| `markdownlint-cli2` | Lint Markdown files for style and consistency | yes | +| `prettier` | Format Markdown and YAML files | yes | | `renovate-deps` | Verify Renovate dependency snapshot is up to date | yes | -| `license-header` | Check source files have the required license header | — | - -#### `shellcheck` - -| | | -| ----------- | -------------------------------------- | -| Description | Lint shell scripts for common mistakes | -| Fix | no | -| Binary | `shellcheck` | -| Scope | [file](#scopes) | -| Patterns | `*.sh *.bash *.bats` | -| Config | `.shellcheckrc` | - -#### `shfmt` - -| | | -| ----------- | -------------------- | -| Description | Format shell scripts | -| Fix | yes | -| Binary | `shfmt` | -| Scope | [file](#scopes) | -| Patterns | `*.sh *.bash` | - -#### `markdownlint-cli2` - -| | | -| ----------- | --------------------------------------------- | -| Description | Lint Markdown files for style and consistency | -| Fix | yes | -| Binary | `markdownlint-cli2` | -| Scope | [file](#scopes) | -| Patterns | `*.md` | -| Config | `.markdownlint.jsonc` | - -#### `prettier` - -| | | -| ----------- | ------------------------------ | -| Description | Format Markdown and YAML files | -| Fix | yes | -| Binary | `prettier` | -| Scope | [files](#scopes) | -| Patterns | `*.md *.yml *.yaml` | -| Config | `.prettierrc` | +| `ruff` | Lint Python code | yes | +| `ruff-format` | Format Python code | yes | +| `shellcheck` | Lint shell scripts for common mistakes | — | +| `shfmt` | Format shell scripts | yes | +| `xmllint` | Validate XML files are well-formed | — | #### `actionlint` @@ -311,82 +269,6 @@ being linted and cannot be redirected via a flag. | Patterns | `.github/workflows/*.yml .github/workflows/*.yaml` | | Config | `actionlint.yml` | -#### `hadolint` - -| | | -| ----------- | -------------------------------------- | -| Description | Lint Dockerfiles | -| Fix | no | -| Binary | `hadolint` | -| Scope | [file](#scopes) | -| Patterns | `Dockerfile Dockerfile.* *.dockerfile` | -| Config | `.hadolint.yaml` | - -#### `xmllint` - -| | | -| ----------- | ---------------------------------- | -| Description | Validate XML files are well-formed | -| Fix | no | -| Binary | `xmllint` | -| Scope | [files](#scopes) | -| Patterns | `*.xml` | - -#### `codespell` - -| | | -| ----------- | ---------------------------------- | -| Description | Check for common spelling mistakes | -| Fix | yes | -| Binary | `codespell` | -| Scope | [files](#scopes) | -| Patterns | `*` | -| Config | `.codespellrc` | - -#### `editorconfig-checker` - -| | | -| ----------- | --------------------------------------------- | -| Description | Check files comply with EditorConfig settings | -| Fix | no | -| Binary | `ec` | -| Scope | [files](#scopes) | -| Patterns | `*` | -| Config | `.editorconfig-checker.json` | - -#### `golangci-lint` - -| | | -| ----------- | ------------------------------------------------------------------- | -| Description | Lint Go code; uses --new-from-rev to scope analysis to changed code | -| Fix | no | -| Binary | `golangci-lint` | -| Scope | [project](#scopes) | -| Patterns | `*.go` | -| Config | `.golangci.yml` | - -#### `ruff` - -| | | -| ----------- | ---------------- | -| Description | Lint Python code | -| Fix | yes | -| Binary | `ruff` | -| Scope | [file](#scopes) | -| Patterns | `*.py` | -| Config | `ruff.toml` | - -#### `ruff-format` - -| | | -| ----------- | ------------------ | -| Description | Format Python code | -| Fix | yes | -| Binary | `ruff` | -| Scope | [file](#scopes) | -| Patterns | `*.py` | -| Config | `ruff.toml` | - #### `biome` | | | @@ -427,6 +309,38 @@ being linted and cannot be redirected via a flag. | Scope | [project](#scopes) | | Patterns | `*.rs` | +#### `codespell` + +| | | +| ----------- | ---------------------------------- | +| Description | Check for common spelling mistakes | +| Fix | yes | +| Binary | `codespell` | +| Scope | [files](#scopes) | +| Patterns | `*` | +| Config | `.codespellrc` | + +#### `dotnet-format` + +| | | +| ----------- | ---------------- | +| Description | Format C# code | +| Fix | yes | +| Binary | `dotnet` | +| Scope | [files](#scopes) | +| Patterns | `*.cs` | + +#### `editorconfig-checker` + +| | | +| ----------- | --------------------------------------------- | +| Description | Check files comply with EditorConfig settings | +| Fix | no | +| Binary | `ec` | +| Scope | [files](#scopes) | +| Patterns | `*` | +| Config | `.editorconfig-checker.json` | + #### `gofmt` | | | @@ -437,6 +351,17 @@ being linted and cannot be redirected via a flag. | Scope | [file](#scopes) | | Patterns | `*.go` | +#### `golangci-lint` + +| | | +| ----------- | ------------------------------------------------------------------- | +| Description | Lint Go code; uses --new-from-rev to scope analysis to changed code | +| Fix | no | +| Binary | `golangci-lint` | +| Scope | [project](#scopes) | +| Patterns | `*.go` | +| Config | `.golangci.yml` | + #### `google-java-format` | | | @@ -447,6 +372,17 @@ being linted and cannot be redirected via a flag. | Scope | [files](#scopes) | | Patterns | `*.java` | +#### `hadolint` + +| | | +| ----------- | -------------------------------------- | +| Description | Lint Dockerfiles | +| Fix | no | +| Binary | `hadolint` | +| Scope | [file](#scopes) | +| Patterns | `Dockerfile Dockerfile.* *.dockerfile` | +| Config | `.hadolint.yaml` | + #### `ktlint` | | | @@ -457,15 +393,14 @@ being linted and cannot be redirected via a flag. | Scope | [files](#scopes) | | Patterns | `*.kt *.kts` | -#### `dotnet-format` +#### `license-header` -| | | -| ----------- | ---------------- | -| Description | Format C# code | -| Fix | yes | -| Binary | `dotnet` | -| Scope | [files](#scopes) | -| Patterns | `*.cs` | +| | | +| ----------- | --------------------------------------------------- | +| Description | Check source files have the required license header | +| Fix | no | +| Binary | (built-in) | +| Scope | [special](#scopes) | #### `lychee` @@ -489,6 +424,28 @@ config = ".github/config/lychee.toml" check_all_local = true ``` +#### `markdownlint-cli2` + +| | | +| ----------- | --------------------------------------------- | +| Description | Lint Markdown files for style and consistency | +| Fix | yes | +| Binary | `markdownlint-cli2` | +| Scope | [file](#scopes) | +| Patterns | `*.md` | +| Config | `.markdownlint.jsonc` | + +#### `prettier` + +| | | +| ----------- | ------------------------------ | +| Description | Format Markdown and YAML files | +| Fix | yes | +| Binary | `prettier` | +| Scope | [files](#scopes) | +| Patterns | `*.md *.yml *.yaml` | +| Config | `.prettierrc` | + #### `renovate-deps` | | | @@ -510,14 +467,58 @@ Configure via `flint.toml`: exclude_managers = ["github-actions", "github-runners"] ``` -#### `license-header` +#### `ruff` -| | | -| ----------- | --------------------------------------------------- | -| Description | Check source files have the required license header | -| Fix | no | -| Binary | (built-in) | -| Scope | [special](#scopes) | +| | | +| ----------- | ---------------- | +| Description | Lint Python code | +| Fix | yes | +| Binary | `ruff` | +| Scope | [file](#scopes) | +| Patterns | `*.py` | +| Config | `ruff.toml` | + +#### `ruff-format` + +| | | +| ----------- | ------------------ | +| Description | Format Python code | +| Fix | yes | +| Binary | `ruff` | +| Scope | [file](#scopes) | +| Patterns | `*.py` | +| Config | `ruff.toml` | + +#### `shellcheck` + +| | | +| ----------- | -------------------------------------- | +| Description | Lint shell scripts for common mistakes | +| Fix | no | +| Binary | `shellcheck` | +| Scope | [file](#scopes) | +| Patterns | `*.sh *.bash *.bats` | +| Config | `.shellcheckrc` | + +#### `shfmt` + +| | | +| ----------- | -------------------- | +| Description | Format shell scripts | +| Fix | yes | +| Binary | `shfmt` | +| Scope | [file](#scopes) | +| Patterns | `*.sh *.bash` | + +#### `xmllint` + +| | | +| ----------- | ---------------------------------- | +| Description | Validate XML files are well-formed | +| Fix | no | +| Binary | `xmllint` | +| Scope | [files](#scopes) | +| Patterns | `*.xml` | diff --git a/src/hook.rs b/src/hook.rs index b14f11a..749afd1 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -3,7 +3,7 @@ use std::path::Path; const HOOK_CONTENT: &str = "#!/bin/sh\n\ # Installed by flint — run `flint hook install` to reinstall\n\ -flint run --fix --fast-only\n"; +mise exec -- flint run --fix --fast-only\n"; /// Writes `.git/hooks/pre-commit`. Skips silently if the hook already exists. pub fn install(project_root: &Path) -> Result<()> { diff --git a/src/registry.rs b/src/registry.rs index 80f27f8..3a2221f 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -791,9 +791,11 @@ mod tests { fn generate_readme_table(registry: &[Check]) -> String { let generated_comment = ""; - // Summary table: Name | Description | Fix + // Summary table: Name | Description | Fix — sorted alphabetically. let headers = ["Name", "Description", "Fix"]; - let rows: Vec<[String; 3]> = registry.iter().map(summary_row).collect(); + let mut sorted: Vec<&Check> = registry.iter().collect(); + sorted.sort_by_key(|c| c.name); + let rows: Vec<[String; 3]> = sorted.iter().map(|c| summary_row(c)).collect(); let mut widths = headers.map(|h| h.len()); for row in &rows { @@ -823,8 +825,8 @@ mod tests { lines.push(fmt_row(&strs)); } - // Per-linter detail sections - for check in registry { + // Per-linter detail sections (alphabetical) + for check in &sorted { lines.push(format!("#### `{}`", check.name)); lines.push(detail_table(check)); } From 19461dbd3ba9633aecc4dcc7c8bb0c96c21dfe86 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 17:42:25 +0000 Subject: [PATCH 106/141] ci: add Windows build and cross-platform unit test workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add x86_64-pc-windows-msvc to release matrix with .zip packaging - Add shell: bash to steps using bash syntax (works on Windows via Git Bash) - Add test.yml running cargo test --bin flint on Linux, macOS, and Windows - Gate e2e cases test with #[cfg(unix)] — fake binaries are Unix shell scripts --- .github/workflows/release.yml | 19 ++++++++++++++++--- .github/workflows/test.yml | 32 ++++++++++++++++++++++++++++++++ tests/e2e.rs | 3 +++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 113f32d..1691f85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,8 @@ jobs: runner: macos-15-intel - target: aarch64-apple-darwin runner: macos-latest + - target: x86_64-pc-windows-msvc + runner: windows-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -36,11 +38,13 @@ jobs: - name: Add target if: "!matrix.use_cross" + shell: bash env: TARGET: ${{ matrix.target }} run: rustup target add "$TARGET" - name: Build + shell: bash env: USE_CROSS: ${{ matrix.use_cross }} TARGET: ${{ matrix.target }} @@ -51,7 +55,8 @@ jobs: cargo build --release --target "$TARGET" fi - - name: Package + - name: Package (Unix) + if: runner.os != 'Windows' env: TARGET: ${{ matrix.target }} run: | @@ -59,10 +64,18 @@ jobs: tar czf "flint-${TARGET}.tar.gz" flint mv "flint-${TARGET}.tar.gz" "$GITHUB_WORKSPACE/" + - name: Package (Windows) + if: runner.os == 'Windows' + env: + TARGET: ${{ matrix.target }} + shell: pwsh + run: | + Compress-Archive -Path "target\$env:TARGET\release\flint.exe" -DestinationPath "flint-$env:TARGET.zip" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: flint-${{ matrix.target }} - path: flint-${{ matrix.target }}.tar.gz + path: flint-${{ matrix.target }}.* release: name: Publish release @@ -91,4 +104,4 @@ jobs: --title "$TAG" \ --generate-notes \ "${extra_flags[@]}" \ - flint-*.tar.gz + flint-*.tar.gz flint-*.zip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0b7b3a8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +--- +name: Test + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-24.04, macos-latest, windows-latest] + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Restore cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + - name: Test + run: cargo test --bin flint diff --git a/tests/e2e.rs b/tests/e2e.rs index 271ce31..fb61f3b 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -72,6 +72,9 @@ fn git_repo() -> TempDir { /// Set UPDATE_SNAPSHOTS=1 to regenerate golden output in test.toml. /// Set FLINT_CASES= to run only cases under that directory (e.g. FLINT_CASES=shellcheck /// or FLINT_CASES=shellcheck/clean). Top-level groups run in parallel. +/// +/// Fake binaries are Unix shell scripts — this test is skipped on non-Unix platforms. +#[cfg(unix)] #[test] fn cases() { let cases_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); From 7e4a43f5751ea02a16291cc1cf4af70e24bb6a4b Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 17:47:56 +0000 Subject: [PATCH 107/141] fix(review): martincostello follow-up comments - release: add use_cross: false to non-cross matrix rows so matrix.use_cross == false / == true comparisons work without null - renovate: add custom managers to track GitHub Action SHA pins and mise version embedded in the generated lint.yml template (generation.rs) - lychee: support GitHub Enterprise via GITHUB_SERVER_URL env var; extract github_server_url() / raw_content_base() helpers and thread server URL through parse_github_repo, build_global_github_args, build_branch_remap_args so GHE repos get correct remap patterns --- .github/renovate.json5 | 16 +++++ .github/workflows/release.yml | 8 ++- src/linters/lychee.rs | 109 ++++++++++++++++++++++++++-------- 3 files changed, 106 insertions(+), 27 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c10920b..5fb5946 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -38,6 +38,22 @@ depNameTemplate: "mise", matchStrings: ["jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?"], }, + { + customType: "regex", + description: "Update GitHub Action SHA pins embedded in the generated workflow template (src/init/generation.rs)", + managerFilePatterns: ["/^src/init/generation\\.rs$/"], + matchStrings: ["uses: (?[^@\\\\]+)@(?[a-f0-9]{40})\\s*#\\s*(?v[^\\s'\"\\\\]+)"], + datasourceTemplate: "github-tags", + }, + { + customType: "regex", + description: "Update mise version embedded in the generated workflow template (src/init/generation.rs)", + managerFilePatterns: ["/^src/init/generation\\.rs$/"], + datasourceTemplate: "github-release-attachments", + packageNameTemplate: "jdx/mise", + depNameTemplate: "mise", + matchStrings: ["jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?"], + }, ], packageRules: [ { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1691f85..93208ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,15 +17,19 @@ jobs: include: - target: x86_64-unknown-linux-gnu runner: ubuntu-24.04 + use_cross: false - target: aarch64-unknown-linux-gnu runner: ubuntu-24.04 use_cross: true - target: x86_64-apple-darwin runner: macos-15-intel + use_cross: false - target: aarch64-apple-darwin runner: macos-latest + use_cross: false - target: x86_64-pc-windows-msvc runner: windows-latest + use_cross: false steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -33,11 +37,11 @@ jobs: persist-credentials: false - name: Install cross - if: matrix.use_cross + if: matrix.use_cross == true run: cargo install cross --locked - name: Add target - if: "!matrix.use_cross" + if: matrix.use_cross == false shell: bash env: TARGET: ${{ matrix.target }} diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 377a727..5ae1d42 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -166,38 +166,66 @@ async fn run_lychee_cmd( } } +/// Returns the GitHub server URL from `GITHUB_SERVER_URL`, defaulting to `https://github.com`. +fn github_server_url() -> String { + std::env::var("GITHUB_SERVER_URL") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "https://github.com".to_string()) +} + +/// Returns the base URL for raw file content. +/// GitHub.com uses a separate subdomain; GitHub Enterprise serves raw content at `{server}/raw`. +fn raw_content_base(server_url: &str) -> String { + if server_url == "https://github.com" { + "https://raw.githubusercontent.com".to_string() + } else { + format!("{server_url}/raw") + } +} + async fn build_remap_args(project_root: &Path) -> Vec { if std::env::var("LYCHEE_SKIP_GITHUB_REMAPS").as_deref() == Ok("true") { return vec![]; } - let mut args = build_global_github_args(); - args.extend(build_branch_remap_args(project_root).await); + let server = github_server_url(); + let raw_base = raw_content_base(&server); + let mut args = build_global_github_args(&server, &raw_base); + args.extend(build_branch_remap_args(project_root, &server, &raw_base).await); args } -fn build_global_github_args() -> Vec { +fn build_global_github_args(server: &str, raw_base: &str) -> Vec { let mut args = Vec::new(); push_remap( &mut args, - r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#L[0-9]+.*$ https://raw.githubusercontent.com/$1/$2/$3", + format!(r"^{server}/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#L[0-9]+.*$ {raw_base}/$1/$2/$3"), ); push_remap( &mut args, - r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#:~:text=.*$ https://raw.githubusercontent.com/$1/$2/$3", + format!( + r"^{server}/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#:~:text=.*$ {raw_base}/$1/$2/$3" + ), ); push_remap( &mut args, - r"^https://github.com/([^/]+/[^/]+)/blob/([^/]+)/(.*)$ https://raw.githubusercontent.com/$1/$2/$3", + format!(r"^{server}/([^/]+/[^/]+)/blob/([^/]+)/(.*)$ {raw_base}/$1/$2/$3"), ); push_remap( &mut args, - r"^https://github.com/([^/]+/[^/]+)/(issues|pull)/([0-9]+)#issuecomment-.*$ https://github.com/$1/$2/$3", + format!( + r"^{server}/([^/]+/[^/]+)/(issues|pull)/([0-9]+)#issuecomment-.*$ {server}/$1/$2/$3" + ), ); args } -async fn build_branch_remap_args(project_root: &Path) -> Vec { - let Some(repo) = resolve_repo(project_root).await else { +async fn build_branch_remap_args( + project_root: &Path, + server: &str, + raw_base: &str, +) -> Vec { + let Some(repo) = resolve_repo(project_root, server).await else { return vec![]; }; let base_ref = resolve_base_ref(project_root).await; @@ -210,7 +238,7 @@ async fn build_branch_remap_args(project_root: &Path) -> Vec { } let head_repo = std::env::var("PR_HEAD_REPO").unwrap_or_else(|_| repo.clone()); - let base_url = format!("https://github.com/{repo}"); + let base_url = format!("{server}/{repo}"); let mut args = Vec::new(); if head_repo == repo { @@ -236,8 +264,8 @@ async fn build_branch_remap_args(project_root: &Path) -> Vec { format!("^{base_url}/tree/{base_ref}/(.*)$ file://{pwd}/$1"), ); } else { - let raw_head = format!("https://raw.githubusercontent.com/{head_repo}/{head_ref}"); - let head_url = format!("https://github.com/{head_repo}"); + let raw_head = format!("{raw_base}/{head_repo}/{head_ref}"); + let head_url = format!("{server}/{head_repo}"); push_remap( &mut args, format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ {raw_head}/$1"), @@ -283,7 +311,7 @@ async fn run_git_output(project_root: &Path, args: &[&str]) -> Option { if s.is_empty() { None } else { Some(s) } } -async fn resolve_repo(project_root: &Path) -> Option { +async fn resolve_repo(project_root: &Path, server: &str) -> Option { if let Ok(repo) = std::env::var("GITHUB_REPOSITORY") && !repo.is_empty() { @@ -291,7 +319,7 @@ async fn resolve_repo(project_root: &Path) -> Option { } run_git_output(project_root, &["config", "--get", "remote.origin.url"]) .await - .and_then(|url| parse_github_repo(&url)) + .and_then(|url| parse_github_repo(&url, server)) } async fn resolve_base_ref(project_root: &Path) -> String { @@ -317,16 +345,19 @@ async fn resolve_head_ref(project_root: &Path) -> Option { run_git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]).await } -fn parse_github_repo(url: &str) -> Option { - // HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo - if let Some(rest) = url.strip_prefix("https://github.com/") { +fn parse_github_repo(url: &str, server: &str) -> Option { + // HTTPS: https:///owner/repo.git or https:///owner/repo + let https_prefix = format!("{server}/"); + if let Some(rest) = url.strip_prefix(https_prefix.as_str()) { let repo = rest.trim_end_matches(".git"); if !repo.is_empty() { return Some(repo.to_string()); } } - // SSH: git@github.com:owner/repo.git or git@github.com:owner/repo - if let Some(rest) = url.strip_prefix("git@github.com:") { + // SSH: git@:owner/repo.git or git@:owner/repo + let hostname = server.strip_prefix("https://").unwrap_or(server); + let ssh_prefix = format!("git@{hostname}:"); + if let Some(rest) = url.strip_prefix(ssh_prefix.as_str()) { let repo = rest.trim_end_matches(".git"); if !repo.is_empty() { return Some(repo.to_string()); @@ -362,7 +393,7 @@ mod tests { #[test] fn parse_github_repo_https() { assert_eq!( - parse_github_repo("https://github.com/owner/repo"), + parse_github_repo("https://github.com/owner/repo", "https://github.com"), Some("owner/repo".to_string()) ); } @@ -370,7 +401,7 @@ mod tests { #[test] fn parse_github_repo_https_dotgit() { assert_eq!( - parse_github_repo("https://github.com/owner/repo.git"), + parse_github_repo("https://github.com/owner/repo.git", "https://github.com"), Some("owner/repo".to_string()) ); } @@ -378,7 +409,7 @@ mod tests { #[test] fn parse_github_repo_ssh() { assert_eq!( - parse_github_repo("git@github.com:owner/repo"), + parse_github_repo("git@github.com:owner/repo", "https://github.com"), Some("owner/repo".to_string()) ); } @@ -386,19 +417,47 @@ mod tests { #[test] fn parse_github_repo_ssh_dotgit() { assert_eq!( - parse_github_repo("git@github.com:owner/repo.git"), + parse_github_repo("git@github.com:owner/repo.git", "https://github.com"), Some("owner/repo".to_string()) ); } #[test] fn parse_github_repo_non_github() { - assert_eq!(parse_github_repo("https://gitlab.com/owner/repo"), None); + assert_eq!( + parse_github_repo("https://gitlab.com/owner/repo", "https://github.com"), + None + ); } #[test] fn parse_github_repo_empty_path() { - assert_eq!(parse_github_repo("https://github.com/"), None); + assert_eq!( + parse_github_repo("https://github.com/", "https://github.com"), + None + ); + } + + #[test] + fn parse_github_repo_ghe_https() { + assert_eq!( + parse_github_repo( + "https://github.mycompany.com/owner/repo", + "https://github.mycompany.com" + ), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_ghe_ssh() { + assert_eq!( + parse_github_repo( + "git@github.mycompany.com:owner/repo.git", + "https://github.mycompany.com" + ), + Some("owner/repo".to_string()) + ); } #[test] From 2d6e467ffba44da5cb3bef684f107c805fd0b5e9 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 17:49:29 +0000 Subject: [PATCH 108/141] chore: apply cargo-fmt and update renovate-tracked-deps --- .github/renovate-tracked-deps.json | 7 +++++++ src/linters/lychee.rs | 10 ++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 97e6393..7f9075e 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -24,5 +24,12 @@ "shellcheck", "shfmt" ] + }, + "src/init/generation.rs": { + "regex": [ + "actions/checkout", + "jdx/mise-action", + "mise" + ] } } diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 5ae1d42..4a5a942 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -203,9 +203,7 @@ fn build_global_github_args(server: &str, raw_base: &str) -> Vec { ); push_remap( &mut args, - format!( - r"^{server}/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#:~:text=.*$ {raw_base}/$1/$2/$3" - ), + format!(r"^{server}/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#:~:text=.*$ {raw_base}/$1/$2/$3"), ); push_remap( &mut args, @@ -220,11 +218,7 @@ fn build_global_github_args(server: &str, raw_base: &str) -> Vec { args } -async fn build_branch_remap_args( - project_root: &Path, - server: &str, - raw_base: &str, -) -> Vec { +async fn build_branch_remap_args(project_root: &Path, server: &str, raw_base: &str) -> Vec { let Some(repo) = resolve_repo(project_root, server).await else { return vec![]; }; From 70f2d540d0f3ad674ae753c57bb8eea98d485b31 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 17:54:18 +0000 Subject: [PATCH 109/141] ci: simplify release workflow using taiki-e/upload-rust-binary-action Replaces manual cross-compilation setup, platform-specific packaging, artifact upload/download, and release creation steps with: - taiki-e/upload-rust-binary-action (builds, packages, uploads directly) - A single create-release job before the matrix builds Eliminates: Install cross, Add target, Build, Package (Unix/Windows), upload-artifact, download-artifact steps. -39 lines. --- .github/workflows/release.yml | 115 +++++++++++----------------------- 1 file changed, 38 insertions(+), 77 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93208ce..9d8d637 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,103 +9,64 @@ on: permissions: {} jobs: + release: + name: Create release + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + extra_flags=() + if echo "$TAG" | grep -qE '\-(alpha|beta|rc)'; then + extra_flags+=("--prerelease") + fi + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --generate-notes \ + "${extra_flags[@]}" + build: name: Build ${{ matrix.target }} + needs: release runs-on: ${{ matrix.runner }} + permissions: + contents: write + strategy: matrix: include: - target: x86_64-unknown-linux-gnu runner: ubuntu-24.04 - use_cross: false - target: aarch64-unknown-linux-gnu runner: ubuntu-24.04 - use_cross: true + build-tool: cross - target: x86_64-apple-darwin runner: macos-15-intel - use_cross: false - target: aarch64-apple-darwin runner: macos-latest - use_cross: false - target: x86_64-pc-windows-msvc runner: windows-latest - use_cross: false steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Install cross - if: matrix.use_cross == true - run: cargo install cross --locked - - - name: Add target - if: matrix.use_cross == false - shell: bash - env: - TARGET: ${{ matrix.target }} - run: rustup target add "$TARGET" - - - name: Build - shell: bash - env: - USE_CROSS: ${{ matrix.use_cross }} - TARGET: ${{ matrix.target }} - run: | - if [ "${USE_CROSS}" = "true" ]; then - cross build --release --target "$TARGET" - else - cargo build --release --target "$TARGET" - fi - - - name: Package (Unix) - if: runner.os != 'Windows' - env: - TARGET: ${{ matrix.target }} - run: | - cd "target/${TARGET}/release" - tar czf "flint-${TARGET}.tar.gz" flint - mv "flint-${TARGET}.tar.gz" "$GITHUB_WORKSPACE/" - - - name: Package (Windows) - if: runner.os == 'Windows' - env: - TARGET: ${{ matrix.target }} - shell: pwsh - run: | - Compress-Archive -Path "target\$env:TARGET\release\flint.exe" -DestinationPath "flint-$env:TARGET.zip" - - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: flint-${{ matrix.target }} - path: flint-${{ matrix.target }}.* - - release: - name: Publish release - needs: build - runs-on: ubuntu-24.04 - permissions: - contents: write - - steps: - - name: Download artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - name: Build and upload + uses: taiki-e/upload-rust-binary-action@10c1cf6a3da113ad4e60018e386570529aa5f1d3 # v1.30.0 with: - merge-multiple: true - - - name: Create release + bin: flint + target: ${{ matrix.target }} + archive: flint-$target + build-tool: ${{ matrix.build-tool }} + tar: unix + zip: windows env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: | - extra_flags=() - if echo "$TAG" | grep -qE '\-(alpha|beta|rc)'; then - extra_flags+=("--prerelease") - fi - gh release create "$TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$TAG" \ - --generate-notes \ - "${extra_flags[@]}" \ - flint-*.tar.gz flint-*.zip + GITHUB_TOKEN: ${{ github.token }} From dfc60c71bf621abb7427202a9069496e95206d48 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 17:58:25 +0000 Subject: [PATCH 110/141] =?UTF-8?q?ci:=20drop=20manual=20release=20creatio?= =?UTF-8?q?n=20=E2=80=94=20release-please=20handles=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-please creates the GitHub release when the release PR merges, so the release already exists by the time the tag push triggers this workflow. The separate release job was redundant. --- .github/workflows/release.yml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d8d637..3026fa7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,31 +9,8 @@ on: permissions: {} jobs: - release: - name: Create release - runs-on: ubuntu-24.04 - permissions: - contents: write - - steps: - - name: Create release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: | - extra_flags=() - if echo "$TAG" | grep -qE '\-(alpha|beta|rc)'; then - extra_flags+=("--prerelease") - fi - gh release create "$TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$TAG" \ - --generate-notes \ - "${extra_flags[@]}" - build: name: Build ${{ matrix.target }} - needs: release runs-on: ${{ matrix.runner }} permissions: contents: write From 83043849fed23b631ad9d4838197687e99ac26c6 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 07:47:39 +0000 Subject: [PATCH 111/141] ci: add sha256 checksums to release assets --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3026fa7..4066dc3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,5 +45,6 @@ jobs: build-tool: ${{ matrix.build-tool }} tar: unix zip: windows + checksum: sha256 env: GITHUB_TOKEN: ${{ github.token }} From 1c17c8053a4e7eb65309611f44a3c2c17e7f4afe Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 07:50:04 +0000 Subject: [PATCH 112/141] ci: add GitHub artifact attestations for github: backend compatibility mise's github: backend verifies artifact attestations by default. Add actions/attest-build-provenance after each binary upload so mise can verify authenticity when installing via github:grafana/flint. --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4066dc3..5494fda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: write + id-token: write + attestations: write strategy: matrix: @@ -48,3 +50,8 @@ jobs: checksum: sha256 env: GITHUB_TOKEN: ${{ github.token }} + + - name: Attest build provenance + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: flint-${{ matrix.target }}.* From 0e0caafa37db01c342ec72b199bc12a8d7b3a770 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 08:30:34 +0000 Subject: [PATCH 113/141] refactor(registry): extract each linter to a named function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the 213-line builtin() vec into 23 private check_*() functions, one per linter. builtin() becomes a short list of function calls. Also adds #[ignore] to all_registry_binaries_found — it's a local dev helper, not a CI assertion; fails anywhere tools aren't fully installed. test(e2e): remove fake_bins from biome and lychee; run on all platforms - biome/biome-format: use real biome binary (already in mise.toml); remove shell-script fake_bins from 4 cases; regenerate snapshots - lychee: redesign fixtures to use local relative file links instead of HTTP URLs — no network access needed; remove fake_bins from 2 cases - normalize_timing: also strip biome's "Checked N file(s) in Xµs." line - e2e.rs: remove #[cfg(unix)] from cases test; add per-case skip for fake_bins cases on non-Unix (general/ cases still use shell scripts) ci(test): install mise in test workflow; run full cargo test on all 3 platforms fake_bins cases auto-skip on Windows; everything else (including renovate-deps) runs on Linux, macOS, and Windows. --- .github/renovate-tracked-deps.json | 5 + .github/workflows/test.yml | 12 +- src/registry.rs | 508 +++++++++++------- tests/cases/biome-format/auto-fix/test.toml | 16 +- tests/cases/biome-format/failure/test.toml | 23 +- tests/cases/biome/clean/test.toml | 6 - tests/cases/biome/failure/test.toml | 29 +- .../cases/lychee/broken-link/files/README.md | 2 +- tests/cases/lychee/broken-link/test.toml | 14 +- tests/cases/lychee/clean/files/README.md | 2 +- tests/cases/lychee/clean/files/existing.md | 3 + tests/cases/lychee/clean/test.toml | 6 - tests/e2e.rs | 29 +- 13 files changed, 383 insertions(+), 272 deletions(-) create mode 100644 tests/cases/lychee/clean/files/existing.md diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 7f9075e..3dd889f 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -4,6 +4,11 @@ "mise" ] }, + ".github/workflows/test.yml": { + "regex": [ + "mise" + ] + }, "mise.toml": { "mise": [ "actionlint", diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b7b3a8..33bc32b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,9 +24,19 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + fetch-depth: 0 # needed for merge-base in e2e tests + + - name: Setup mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + version: v2026.4.1 + sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + + - name: Install Rust lint components + run: rustup component add clippy rustfmt - name: Restore cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Test - run: cargo test --bin flint + run: cargo test diff --git a/src/registry.rs b/src/registry.rs index 3a2221f..86cede4 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -338,218 +338,310 @@ impl Check { /// Exception: when the mise tool key is a language toolchain shared across multiple /// binaries (e.g. `rust`, `go`, `dotnet`), use the binary name instead — the toolchain /// name would be ambiguous (`rust` can't name both `cargo-fmt` and `cargo-clippy`). -pub fn builtin() -> Vec { - vec![ - Check::file( - "shellcheck", - "shellcheck {FILE}", - &["*.sh", "*.bash", "*.bats"], - ) - .linter_config(".shellcheckrc", "--rcfile") - .desc("Lint shell scripts for common mistakes") - .style(), - Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) - .fix("shfmt -w {FILE}") - .formatter() - .desc("Format shell scripts") - .style(), - Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) - .fix("markdownlint-cli2 --fix {FILE}") - .linter_config(".markdownlint.jsonc", "--config") - .desc("Lint Markdown files for style and consistency") - .install_key("npm:markdownlint-cli2"), - Check::files( - "prettier", - "prettier --check {FILES}", - &["*.md", "*.yml", "*.yaml"], - ) - .fix("prettier --write {FILES}") - .full_cmd("prettier --check {ROOT}", "prettier --write {ROOT}") - .linter_config(".prettierrc", "--config") +fn check_shellcheck() -> Check { + Check::file( + "shellcheck", + "shellcheck {FILE}", + &["*.sh", "*.bash", "*.bats"], + ) + .linter_config(".shellcheckrc", "--rcfile") + .desc("Lint shell scripts for common mistakes") + .style() +} + +fn check_shfmt() -> Check { + Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) + .fix("shfmt -w {FILE}") .formatter() - .desc("Format Markdown and YAML files") - .install_key("npm:prettier"), - Check::file( - "actionlint", - "actionlint {FILE}", - &[".github/workflows/*.yml", ".github/workflows/*.yaml"], - ) - .linter_config("actionlint.yml", "-config-file") - .desc("Lint GitHub Actions workflow files") - .style(), - Check::file( - "hadolint", - "hadolint {FILE}", - &["Dockerfile", "Dockerfile.*", "*.dockerfile"], - ) - .linter_config(".hadolint.yaml", "--config") - .desc("Lint Dockerfiles") - .style(), - Check::files("xmllint", "xmllint --noout {FILES}", &["*.xml"]) - .mise_tool("cargo:xmloxide") - .install_key("cargo:xmloxide") - .desc("Validate XML files are well-formed"), - Check::files("codespell", "codespell {FILES}", &["*"]) - .fix("codespell --write-changes {FILES}") - .linter_config(".codespellrc", "--config") - .desc("Check for common spelling mistakes") - .install_key("pipx:codespell"), - // Defer to formatters that enforce line length — those are the ones - // that conflict with ec's max_line_length editorconfig check. - // Note: ec's -config flag controls ec's own JSON config, not .editorconfig itself. - Check::files("editorconfig-checker", "ec {FILES}", &["*"]) - .bin("ec") - .mise_tool("editorconfig-checker") - .defer_to_formatters() - .linter_config(".editorconfig-checker.json", "-config") - .desc("Check files comply with EditorConfig settings"), - Check::project( - "golangci-lint", - "golangci-lint run --new-from-rev={MERGE_BASE}", - &["*.go"], - ) - .linter_config(".golangci.yml", "--config") - .desc("Lint Go code; uses --new-from-rev to scope analysis to changed code") - .lang(), - Check::file("ruff", "ruff check {FILE}", &["*.py"]) - .fix("ruff check --fix {FILE}") - .linter_config("ruff.toml", "--config") - .desc("Lint Python code") - .install_key("pipx:ruff") - .lang(), - Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]) - .bin("ruff") - .fix("ruff format {FILE}") - .linter_config("ruff.toml", "--config") - .formatter() - .desc("Format Python code") - .install_key("pipx:ruff") - .lang(), - Check::file( - "biome", - "biome check {FILE}", - &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], - ) - .fix("biome check --fix {FILE}") - .desc("Lint JS/TS/JSON files") - .install_key("npm:@biomejs/biome") - .lang(), - Check::file( - "biome-format", - "biome format {FILE}", - &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], - ) - .bin("biome") - .fix("biome format --write {FILE}") + .desc("Format shell scripts") + .style() +} + +fn check_markdownlint_cli2() -> Check { + Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) + .fix("markdownlint-cli2 --fix {FILE}") + .linter_config(".markdownlint.jsonc", "--config") + .desc("Lint Markdown files for style and consistency") + .install_key("npm:markdownlint-cli2") +} + +fn check_prettier() -> Check { + Check::files( + "prettier", + "prettier --check {FILES}", + &["*.md", "*.yml", "*.yaml"], + ) + .fix("prettier --write {FILES}") + .full_cmd("prettier --check {ROOT}", "prettier --write {ROOT}") + .linter_config(".prettierrc", "--config") + .formatter() + .desc("Format Markdown and YAML files") + .install_key("npm:prettier") +} + +fn check_actionlint() -> Check { + Check::file( + "actionlint", + "actionlint {FILE}", + &[".github/workflows/*.yml", ".github/workflows/*.yaml"], + ) + .linter_config("actionlint.yml", "-config-file") + .desc("Lint GitHub Actions workflow files") + .style() +} + +fn check_hadolint() -> Check { + Check::file( + "hadolint", + "hadolint {FILE}", + &["Dockerfile", "Dockerfile.*", "*.dockerfile"], + ) + .linter_config(".hadolint.yaml", "--config") + .desc("Lint Dockerfiles") + .style() +} + +fn check_xmllint() -> Check { + Check::files("xmllint", "xmllint --noout {FILES}", &["*.xml"]) + .mise_tool("cargo:xmloxide") + .install_key("cargo:xmloxide") + .desc("Validate XML files are well-formed") +} + +fn check_codespell() -> Check { + Check::files("codespell", "codespell {FILES}", &["*"]) + .fix("codespell --write-changes {FILES}") + .linter_config(".codespellrc", "--config") + .desc("Check for common spelling mistakes") + .install_key("pipx:codespell") +} + +fn check_editorconfig_checker() -> Check { + // Defer to formatters that enforce line length — those are the ones + // that conflict with ec's max_line_length editorconfig check. + // Note: ec's -config flag controls ec's own JSON config, not .editorconfig itself. + Check::files("editorconfig-checker", "ec {FILES}", &["*"]) + .bin("ec") + .mise_tool("editorconfig-checker") + .defer_to_formatters() + .linter_config(".editorconfig-checker.json", "-config") + .desc("Check files comply with EditorConfig settings") +} + +fn check_golangci_lint() -> Check { + Check::project( + "golangci-lint", + "golangci-lint run --new-from-rev={MERGE_BASE}", + &["*.go"], + ) + .linter_config(".golangci.yml", "--config") + .desc("Lint Go code; uses --new-from-rev to scope analysis to changed code") + .lang() +} + +fn check_ruff() -> Check { + Check::file("ruff", "ruff check {FILE}", &["*.py"]) + .fix("ruff check --fix {FILE}") + .linter_config("ruff.toml", "--config") + .desc("Lint Python code") + .install_key("pipx:ruff") + .lang() +} + +fn check_ruff_format() -> Check { + Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]) + .bin("ruff") + .fix("ruff format {FILE}") + .linter_config("ruff.toml", "--config") .formatter() - .desc("Format JS/TS/JSON files") - .install_key("npm:@biomejs/biome") - .lang(), - Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) - .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") - .mise_tool("rust") - .install_components("clippy") - .desc("Lint Rust code; runs on all .rs files, not just changed") - .lang(), - Check::project("cargo-fmt", "cargo fmt -- --check", &["*.rs"]) - .fix("cargo fmt") - .bin("rustfmt") - .mise_tool("rust") - .install_components("rustfmt") - .formatter() - .desc("Format Rust code; runs on all .rs files, not just changed") - .lang(), - Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) - .fix("gofmt -w {FILE}") - .mise_tool("go") - .formatter() - .desc("Format Go code") - .lang(), - Check::files( - "google-java-format", - "google-java-format --dry-run --set-exit-if-changed {FILES}", - &["*.java"], - ) - .fix("google-java-format -i {FILES}") - .mise_tool("github:google/google-java-format") + .desc("Format Python code") + .install_key("pipx:ruff") + .lang() +} + +fn check_biome() -> Check { + Check::file( + "biome", + "biome check {FILE}", + &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], + ) + .fix("biome check --fix {FILE}") + .desc("Lint JS/TS/JSON files") + .install_key("npm:@biomejs/biome") + .lang() +} + +fn check_biome_format() -> Check { + Check::file( + "biome-format", + "biome format {FILE}", + &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], + ) + .bin("biome") + .fix("biome format --write {FILE}") + .formatter() + .desc("Format JS/TS/JSON files") + .install_key("npm:@biomejs/biome") + .lang() +} + +fn check_cargo_clippy() -> Check { + Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) + .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") + .mise_tool("rust") + .install_components("clippy") + .desc("Lint Rust code; runs on all .rs files, not just changed") + .lang() +} + +fn check_cargo_fmt() -> Check { + Check::project("cargo-fmt", "cargo fmt -- --check", &["*.rs"]) + .fix("cargo fmt") + .bin("rustfmt") + .mise_tool("rust") + .install_components("rustfmt") .formatter() - .desc("Format Java code") - .lang(), - Check::files( - "ktlint", - "ktlint --log-level=error {FILES}", - &["*.kt", "*.kts"], - ) - .fix("ktlint --format --log-level=error {FILES}") - .full_cmd( - "ktlint --log-level=error {ROOT}", - "ktlint --format --log-level=error {ROOT}", - ) - .mise_tool("github:pinterest/ktlint") - .bin(if cfg!(windows) { - "ktlint.bat" - } else { - "ktlint" - }) + .desc("Format Rust code; runs on all .rs files, not just changed") + .lang() +} + +fn check_gofmt() -> Check { + Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) + .fix("gofmt -w {FILE}") + .mise_tool("go") .formatter() - .desc("Lint and format Kotlin code") - .lang(), - Check::files( - "dotnet-format", - "dotnet format --verify-no-changes --include {RELFILES}", - &["*.cs"], + .desc("Format Go code") + .lang() +} + +fn check_google_java_format() -> Check { + Check::files( + "google-java-format", + "google-java-format --dry-run --set-exit-if-changed {FILES}", + &["*.java"], + ) + .fix("google-java-format -i {FILES}") + .mise_tool("github:google/google-java-format") + .formatter() + .desc("Format Java code") + .lang() +} + +fn check_ktlint() -> Check { + Check::files( + "ktlint", + "ktlint --log-level=error {FILES}", + &["*.kt", "*.kts"], + ) + .fix("ktlint --format --log-level=error {FILES}") + .full_cmd( + "ktlint --log-level=error {ROOT}", + "ktlint --format --log-level=error {ROOT}", + ) + .mise_tool("github:pinterest/ktlint") + .bin(if cfg!(windows) { + "ktlint.bat" + } else { + "ktlint" + }) + .formatter() + .desc("Lint and format Kotlin code") + .lang() +} + +fn check_dotnet_format() -> Check { + Check::files( + "dotnet-format", + "dotnet format --verify-no-changes --include {RELFILES}", + &["*.cs"], + ) + .fix("dotnet format --include {RELFILES}") + .full_cmd("dotnet format --verify-no-changes", "dotnet format") + .bin("dotnet") + .mise_tool("dotnet") + .formatter() + .desc("Format C# code") + .lang() +} + +fn check_lychee() -> Check { + Check::special("lychee", "lychee", SpecialKind::Links) + .desc("Check for broken links") + .docs( + "Orchestrates [lychee](https://lychee.cli.rs/) for link checking. \ + Requires `lychee` in `[tools]`.\n\ + \n\ + Default behavior: checks all links in changed files. \ + When `check_all_local = true` in `flint.toml`, adds a second pass \ + over local links in all files — useful when broken internal links \ + from unchanged files also matter.\n\ + \n\ + Configure via `flint.toml`:\n\ + \n\ + ```toml\n\ + [checks.links]\n\ + config = \".github/config/lychee.toml\"\n\ + check_all_local = true\n\ + ```", ) - .fix("dotnet format --include {RELFILES}") - .full_cmd("dotnet format --verify-no-changes", "dotnet format") - .bin("dotnet") - .mise_tool("dotnet") - .formatter() - .desc("Format C# code") - .lang(), - Check::special("lychee", "lychee", SpecialKind::Links) - .desc("Check for broken links") - .docs( - "Orchestrates [lychee](https://lychee.cli.rs/) for link checking. \ - Requires `lychee` in `[tools]`.\n\ - \n\ - Default behavior: checks all links in changed files. \ - When `check_all_local = true` in `flint.toml`, adds a second pass \ - over local links in all files — useful when broken internal links \ - from unchanged files also matter.\n\ - \n\ - Configure via `flint.toml`:\n\ - \n\ - ```toml\n\ - [checks.links]\n\ - config = \".github/config/lychee.toml\"\n\ - check_all_local = true\n\ - ```", - ), - Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) - .mise_tool("npm:renovate") - .patterns(RENOVATE_CONFIG_PATTERNS) - .desc("Verify Renovate dependency snapshot is up to date") - .docs( - "Verifies `.github/renovate-tracked-deps.json` is up to date by running \ - Renovate locally and comparing its output against the committed snapshot. \ - Requires `renovate` in `[tools]`.\n\ - \n\ - With `--fix`, automatically regenerates and commits the snapshot.\n\ - \n\ - Configure via `flint.toml`:\n\ - \n\ - ```toml\n\ - [checks.renovate-deps]\n\ - exclude_managers = [\"github-actions\", \"github-runners\"]\n\ - ```", - ), - Check::special( - "license-header", - "license-header", - SpecialKind::LicenseHeader, +} + +fn check_renovate_deps() -> Check { + Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) + .mise_tool("npm:renovate") + .patterns(RENOVATE_CONFIG_PATTERNS) + .desc("Verify Renovate dependency snapshot is up to date") + .docs( + "Verifies `.github/renovate-tracked-deps.json` is up to date by running \ + Renovate locally and comparing its output against the committed snapshot. \ + Requires `renovate` in `[tools]`.\n\ + \n\ + With `--fix`, automatically regenerates and commits the snapshot.\n\ + \n\ + Configure via `flint.toml`:\n\ + \n\ + ```toml\n\ + [checks.renovate-deps]\n\ + exclude_managers = [\"github-actions\", \"github-runners\"]\n\ + ```", ) - .activate_unconditionally() - .desc("Check source files have the required license header"), +} + +fn check_license_header() -> Check { + Check::special( + "license-header", + "license-header", + SpecialKind::LicenseHeader, + ) + .activate_unconditionally() + .desc("Check source files have the required license header") +} + +pub fn builtin() -> Vec { + vec![ + check_shellcheck(), + check_shfmt(), + check_markdownlint_cli2(), + check_prettier(), + check_actionlint(), + check_hadolint(), + check_xmllint(), + check_codespell(), + check_editorconfig_checker(), + check_golangci_lint(), + check_ruff(), + check_ruff_format(), + check_biome(), + check_biome_format(), + check_cargo_clippy(), + check_cargo_fmt(), + check_gofmt(), + check_google_java_format(), + check_ktlint(), + check_dotnet_format(), + check_lychee(), + check_renovate_deps(), + check_license_header(), ] } @@ -695,8 +787,10 @@ mod tests { /// even if they are not declared in this repo's mise.toml. /// /// This test will fail on machines where not all linter tools are installed, - /// which is intentional: it identifies what is missing. + /// which is intentional: it identifies what is missing. Run with + /// `cargo test -- --ignored all_registry_binaries_found` to check locally. #[test] + #[ignore] fn all_registry_binaries_found() { let registry = builtin(); diff --git a/tests/cases/biome-format/auto-fix/test.toml b/tests/cases/biome-format/auto-fix/test.toml index 78a4f43..b88f8ab 100644 --- a/tests/cases/biome-format/auto-fix/test.toml +++ b/tests/cases/biome-format/auto-fix/test.toml @@ -8,18 +8,4 @@ flint: fixed: biome-format — commit before pushing [expected.files] "data.json" = """ { "foo": "bar", "baz": 1 } -""" -[fake_bins] -biome = ''' -#!/bin/sh -case "$*" in - *--write*) - for last; do :; done - printf '{ "foo": "bar", "baz": 1 }\n' > "$last" - ;; - *) - printf 'data.json: Formatter would have printed different content\n' >&2 - exit 1 - ;; -esac -''' +""" \ No newline at end of file diff --git a/tests/cases/biome-format/failure/test.toml b/tests/cases/biome-format/failure/test.toml index 421ab78..173ac4d 100644 --- a/tests/cases/biome-format/failure/test.toml +++ b/tests/cases/biome-format/failure/test.toml @@ -3,14 +3,21 @@ args = "run --full biome-format" exit = 1 stderr = ''' [biome-format] -data.json: Formatter would have printed different content +Checked N file(s) in Xµs. No fixes applied. +Found 1 error. +data.json format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Ɨ Formatter would have printed the following content: + + 1 │ {Ā·"foo":Ā·"bar",Ā·"baz":Ā·1Ā·} + │ + + + + + + +format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Ɨ Some errors were emitted while running checks. + + flint: 1 check failed (biome-format) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -''' -[fake_bins] -biome = ''' -#!/bin/sh -printf 'data.json: Formatter would have printed different content\n' >&2 -exit 1 -''' +''' \ No newline at end of file diff --git a/tests/cases/biome/clean/test.toml b/tests/cases/biome/clean/test.toml index 4c94c58..29db2e1 100644 --- a/tests/cases/biome/clean/test.toml +++ b/tests/cases/biome/clean/test.toml @@ -1,9 +1,3 @@ [expected] args = "run --full biome" exit = 0 - -[fake_bins] -biome = ''' -#!/bin/sh -exit 0 -''' diff --git a/tests/cases/biome/failure/test.toml b/tests/cases/biome/failure/test.toml index 0a216e1..8857113 100644 --- a/tests/cases/biome/failure/test.toml +++ b/tests/cases/biome/failure/test.toml @@ -3,17 +3,28 @@ args = "run --full biome" exit = 1 stderr = ''' [biome] -main.js:1:1 lint/suspicious/noDebugger +Checked N file(s) in Xµs. No fixes applied. +Found 1 error. +main.js:1:1 lint/suspicious/noDebugger FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - x This is an unexpected use of the debugger statement. + Ɨ This is an unexpected use of the debugger statement. + + > 1 │ debugger; + │ ^^^^^^^^^ + 2 │ console.log("hello"); + 3 │ + + i Unsafe fix: Remove debugger statement + + 1 │ debugger; + │ --------- + +check ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Ɨ Some errors were emitted while running checks. + flint: 1 check failed (biome) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. -''' -[fake_bins] -biome = ''' -#!/bin/sh -printf 'main.js:1:1 lint/suspicious/noDebugger\n\n x This is an unexpected use of the debugger statement.\n\n' >&2 -exit 1 -''' +''' \ No newline at end of file diff --git a/tests/cases/lychee/broken-link/files/README.md b/tests/cases/lychee/broken-link/files/README.md index c7960d7..7871ffd 100644 --- a/tests/cases/lychee/broken-link/files/README.md +++ b/tests/cases/lychee/broken-link/files/README.md @@ -1,3 +1,3 @@ # Test -[broken link](https://example.com/does-not-exist) +[broken link](./nonexistent.md) diff --git a/tests/cases/lychee/broken-link/test.toml b/tests/cases/lychee/broken-link/test.toml index 2591ea2..3f605e3 100644 --- a/tests/cases/lychee/broken-link/test.toml +++ b/tests/cases/lychee/broken-link/test.toml @@ -4,7 +4,12 @@ exit = 1 stderr = ''' [lychee] ==> Checking all links in all files -[404] https://example.com/does-not-exist +Issues found in 1 input. Find details below. + +[./README.md]: +[ERROR] file:///nonexistent.md | Cannot find file: File not found. Check if file exists and path is correct + +šŸ” 1 Total (in 0s) āœ… 0 OK 🚫 1 Error flint: 1 check failed (lychee) šŸ’” Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. @@ -12,10 +17,3 @@ flint: 1 check failed (lychee) [env] LYCHEE_SKIP_GITHUB_REMAPS = "true" - -[fake_bins] -lychee = ''' -#!/bin/sh -echo "[404] https://example.com/does-not-exist" -exit 1 -''' diff --git a/tests/cases/lychee/clean/files/README.md b/tests/cases/lychee/clean/files/README.md index 1d1f60f..59ece3c 100644 --- a/tests/cases/lychee/clean/files/README.md +++ b/tests/cases/lychee/clean/files/README.md @@ -1,3 +1,3 @@ # Test -[valid link](https://example.com) +[valid link](./existing.md) diff --git a/tests/cases/lychee/clean/files/existing.md b/tests/cases/lychee/clean/files/existing.md new file mode 100644 index 0000000..d7fee48 --- /dev/null +++ b/tests/cases/lychee/clean/files/existing.md @@ -0,0 +1,3 @@ +# Existing file + +This file exists so the link in README.md resolves successfully. diff --git a/tests/cases/lychee/clean/test.toml b/tests/cases/lychee/clean/test.toml index 8254f27..0355a9f 100644 --- a/tests/cases/lychee/clean/test.toml +++ b/tests/cases/lychee/clean/test.toml @@ -5,9 +5,3 @@ exit = 0 [env] LYCHEE_SKIP_GITHUB_REMAPS = "true" - -[fake_bins] -lychee = ''' -#!/bin/sh -exit 0 -''' diff --git a/tests/e2e.rs b/tests/e2e.rs index fb61f3b..83ae78b 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -4,11 +4,6 @@ use std::process::{Command, Output}; use std::sync::{Arc, Mutex}; use tempfile::TempDir; -/// Runs the flint binary in the given directory with the given args. -fn flint(args: &[&str], cwd: &Path) -> Output { - flint_with_env(args, cwd, &[]) -} - /// Runs the flint binary with additional environment variables. fn flint_with_env(args: &[&str], cwd: &Path, env: &[(&str, &str)]) -> Output { let mut cmd = Command::new(env!("CARGO_BIN_EXE_flint")); @@ -73,8 +68,8 @@ fn git_repo() -> TempDir { /// Set FLINT_CASES= to run only cases under that directory (e.g. FLINT_CASES=shellcheck /// or FLINT_CASES=shellcheck/clean). Top-level groups run in parallel. /// -/// Fake binaries are Unix shell scripts — this test is skipped on non-Unix platforms. -#[cfg(unix)] +/// Cases that declare `[fake_bins]` are skipped on non-Unix platforms because the +/// fake binaries are shell scripts. All other cases run on every platform. #[test] fn cases() { let cases_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); @@ -193,6 +188,17 @@ fn run_case(case: &Path, name: &str, update: bool) { .and_then(|v| v.as_integer()) .unwrap_or(0) as i32; + // Skip cases that use shell-script fake binaries on non-Unix platforms. + #[cfg(not(unix))] + if cfg + .get("fake_bins") + .and_then(|v| v.as_table()) + .is_some_and(|t| !t.is_empty()) + { + eprintln!("{name}: skipped (fake_bins requires Unix)"); + return; + } + let repo = git_repo(); let files_dir = case.join("files"); @@ -373,10 +379,13 @@ fn toml_escape(s: &str) -> String { /// `[name] 123ms` and `[name] 1.2s` both become `[name] Xms`. fn normalize_timing(s: &str) -> String { use regex::Regex; - // Match the timing suffix at the end of a check header line. - // Header lines start with "[" and end with " ms" or " .s". + // Flint check header lines: "[name] 123ms" or "[name] 1.2s" let re = Regex::new(r"(?m)^(\[[^\]]+\]) \d+(?:\.\d+)?(?:ms|s)$").unwrap(); - re.replace_all(s, "$1 Xms").into_owned() + let s = re.replace_all(s, "$1 Xms"); + // Biome summary line: "Checked N file(s) in 1234µs. No fixes applied." + let re2 = Regex::new(r"Checked \d+ files? in \d+(?:\.\d+)?(?:µs|ms|s)\.").unwrap(); + re2.replace_all(&s, "Checked N file(s) in Xµs.") + .into_owned() } /// Strips ANSI/VT escape sequences (colour codes, character-set switches, etc.). From 8e92383817b092034d770744b757d7e53bfd36f7 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 08:31:32 +0000 Subject: [PATCH 114/141] =?UTF-8?q?test:=20un-ignore=20all=5Fregistry=5Fbi?= =?UTF-8?q?naries=5Ffound=20=E2=80=94=20CI=20has=20mise=20installed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/registry.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 86cede4..e27004b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -787,10 +787,8 @@ mod tests { /// even if they are not declared in this repo's mise.toml. /// /// This test will fail on machines where not all linter tools are installed, - /// which is intentional: it identifies what is missing. Run with - /// `cargo test -- --ignored all_registry_binaries_found` to check locally. + /// which is intentional: it identifies what is missing. #[test] - #[ignore] fn all_registry_binaries_found() { let registry = builtin(); From 038cb076640cd5ad49f0ea71b3b00562c5a546aa Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 10:09:28 +0000 Subject: [PATCH 115/141] ci: fix per-platform mise sha256 in test matrix The linux-x64 sha256 was used for all platforms, which only worked while macOS had a warm cache. Switch to a per-platform matrix with version and sha256 fields so each platform verifies its own binary. Windows exe sha256 is not published upstream yet (jdx/mise#8997). Add a Renovate custom manager to keep the matrix hashes updated alongside the existing lint.yml and generation.rs managers. --- .github/renovate.json5 | 11 +++++++++++ .github/workflows/test.yml | 15 ++++++++++++--- README.md | 4 ++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 5fb5946..2ba5ba0 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -45,6 +45,17 @@ matchStrings: ["uses: (?[^@\\\\]+)@(?[a-f0-9]{40})\\s*#\\s*(?v[^\\s'\"\\\\]+)"], datasourceTemplate: "github-tags", }, + { + customType: "regex", + description: "Update mise per-platform sha256 hashes in test.yml matrix", + managerFilePatterns: ["/.github/workflows/test.yml/"], + datasourceTemplate: "github-release-attachments", + packageNameTemplate: "jdx/mise", + depNameTemplate: "mise", + matchStrings: [ + "mise_version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*mise_sha256: [\"']?(?[a-f0-9]{64})[\"']?", + ], + }, { customType: "regex", description: "Update mise version embedded in the generated workflow template (src/init/generation.rs)", diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33bc32b..a83eede 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,16 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-24.04, macos-latest, windows-latest] + include: + - os: ubuntu-24.04 + mise_version: v2026.4.1 + mise_sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + - os: macos-latest + mise_version: v2026.4.1 + mise_sha256: c85b387148d478dec754ded31d01798e2f4e4e9448f75682dcc6bb7c16c9a4f5 + - os: windows-latest + mise_version: v2026.4.1 + mise_sha256: "" # not published for .exe — https://github.com/jdx/mise/pull/8997 permissions: contents: read @@ -29,8 +38,8 @@ jobs: - name: Setup mise uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 with: - version: v2026.4.1 - sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + version: ${{ matrix.mise_version }} + sha256: ${{ matrix.mise_sha256 }} - name: Install Rust lint components run: rustup component add clippy rustfmt diff --git a/README.md b/README.md index 88dd3b4..36c4d0c 100644 --- a/README.md +++ b/README.md @@ -200,10 +200,10 @@ exclude = ["CHANGELOG.md", "vendor/**"] # glob patterns — exclude matc [checks.links] config = ".github/config/lychee.toml" # lychee config path -check_all_local = true # second pass: local links in all files +check_all_local = true # second pass: local links in all files [checks.renovate-deps] -exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers +exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers ``` ### `FLINT_CONFIG_DIR` From a235c1ac1c240953ca81652e74a1777c5b22ca76 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 10:33:09 +0000 Subject: [PATCH 116/141] test: add missing e2e tools to mise.toml golangci-lint, google-java-format, and go (for gofmt) were missing from mise.toml, causing all_registry_binaries_found to fail in CI. Move tools the repo doesn't actually lint with into a clearly labelled block. --- .github/renovate-tracked-deps.json | 3 +++ mise.toml | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 3dd889f..5c87d8b 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -15,7 +15,10 @@ "cargo:xmloxide", "dotnet", "editorconfig-checker", + "github:google/google-java-format", "github:pinterest/ktlint", + "go", + "golangci-lint", "hadolint", "lychee", "node", diff --git a/mise.toml b/mise.toml index 1da7c45..8896588 100644 --- a/mise.toml +++ b/mise.toml @@ -15,10 +15,15 @@ editorconfig-checker = "v3.6.1" "pipx:ruff" = "0.15.0" "pipx:codespell" = "2.4.1" rust = { version = "1.94.1", components = "clippy,rustfmt" } + +# Tools not used to lint this repo, but required for e2e tests +go = "1.26.2" hadolint = "2.14.0" "github:pinterest/ktlint" = "1.8.0" dotnet = "10.0.201" "cargo:xmloxide" = "0.4.1" +golangci-lint = "2.11.4" +"github:google/google-java-format" = "1.35.0" [tasks."setup:update-super-linter-versions"] description = "Generate super-linter version mapping from the super-linter repo" From 23273420498900c775fc5220560ed15dfb8f0d4a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 10:41:06 +0000 Subject: [PATCH 117/141] fix(e2e): resolve macOS /private symlink path mismatch On macOS, tempdir paths like /var/folders/... are symlinks to /private/var/folders/.... Tools run by flint see the canonical path as their CWD, causing mismatches in path-based output (e.g. prettier outputs deep relative paths, cargo-fmt outputs /private). Fix by canonicalizing project_root on startup so all tool invocations use a consistent path. Also canonicalize the repo path in e2e normalization to handle any remaining non-canonical forms in test output. --- src/main.rs | 1 + tests/e2e.rs | 27 +++++++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 08bc7b8..d2c2678 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,6 +111,7 @@ async fn main() -> Result<()> { let project_root = std::env::var("MISE_PROJECT_ROOT") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::current_dir().expect("cannot determine working directory")); + let project_root = project_root.canonicalize().unwrap_or(project_root); let config_dir = std::env::var("FLINT_CONFIG_DIR") .map(std::path::PathBuf::from) diff --git a/tests/e2e.rs b/tests/e2e.rs index 83ae78b..21a9721 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -268,12 +268,27 @@ fn run_case(case: &Path, name: &str, update: bool) { let out = flint_with_env(&args, repo.path(), &env_refs); let repo_str = repo.path().to_string_lossy(); - let stderr = normalize_timing(&strip_ansi( - &String::from_utf8_lossy(&out.stderr).replace(repo_str.as_ref(), ""), - )); - let stdout = normalize_timing(&strip_ansi( - &String::from_utf8_lossy(&out.stdout).replace(repo_str.as_ref(), ""), - )); + let repo_canonical_str = repo + .path() + .canonicalize() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let normalize = |s: String| -> String { + // Replace canonical path first (e.g. /private/var/... on macOS), then the + // non-canonical one, so both forms are collapsed to . + let s = if repo_canonical_str != repo_str.as_ref() { + s.replace(&repo_canonical_str, "") + } else { + s + }; + s.replace(repo_str.as_ref(), "") + }; + let stderr = normalize_timing(&strip_ansi(&normalize( + String::from_utf8_lossy(&out.stderr).into_owned(), + ))); + let stdout = normalize_timing(&strip_ansi(&normalize( + String::from_utf8_lossy(&out.stdout).into_owned(), + ))); if update { write_test_toml( From 9793df41c626cf97ea91bd24349823ca339e0ce0 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 12:06:03 +0000 Subject: [PATCH 118/141] fix(ci): support shellcheck and shfmt on Windows via github backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shellcheck and shfmt have no Windows support in the aqua registry. Switch to the github: backend which uses ubi to download release assets directly (shellcheck-vX.Y.Z.zip → shellcheck.exe, shfmt_vX_windows_amd64.exe). Add a permanent sh→shfmt alias in read_mise_tools so check_active correctly detects shfmt when repos use "github:mvdan/sh" as the tool key (the binary is shfmt but the repo is mvdan/sh, so the auto-alias produces "sh"). Also drop the ktlint.bat Windows override — which was speculative — letting which::which("ktlint") find whatever the mise install provides. Add .gitattributes to enforce LF line endings on Windows checkout, fixing the readme_linter_table_in_sync test which compared LF-generated content against CRLF file content. --- .gitattributes | 1 + .github/renovate-tracked-deps.json | 6 +++--- README.md | 16 ++++++++-------- mise.toml | 4 ++-- src/registry.rs | 11 ++++++----- 5 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 5c87d8b..c579bc9 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -16,6 +16,8 @@ "dotnet", "editorconfig-checker", "github:google/google-java-format", + "github:koalaman/shellcheck", + "github:mvdan/sh", "github:pinterest/ktlint", "go", "golangci-lint", @@ -28,9 +30,7 @@ "npm:renovate", "pipx:codespell", "pipx:ruff", - "rust", - "shellcheck", - "shfmt" + "rust" ] }, "src/init/generation.rs": { diff --git a/README.md b/README.md index 36c4d0c..259f1b6 100644 --- a/README.md +++ b/README.md @@ -63,15 +63,15 @@ Add the linting tools your project needs alongside the `flint` binary itself: flint = "0.x.y" # Add whichever linters apply to your repo: -shellcheck = "v0.11.0" -shfmt = "v3.12.0" -actionlint = "1.7.10" +shellcheck = "v0.11.0" +shfmt = "v3.12.0" +actionlint = "1.7.10" "npm:markdownlint-cli2" = "0.47.0" -"npm:prettier" = "3.5.0" -rust = "1.87.0" # activates cargo-fmt + cargo-clippy -go = "1.24.0" # activates gofmt -lychee = "0.18.0" # activates links check -"npm:renovate" = "39.0.0" # activates renovate-deps check (slow) +"npm:prettier" = "3.5.0" +rust = "1.87.0" # activates cargo-fmt + cargo-clippy +go = "1.24.0" # activates gofmt +lychee = "0.18.0" # activates links check +"npm:renovate" = "39.0.0" # activates renovate-deps check (slow) ``` Then wire up lint tasks: diff --git a/mise.toml b/mise.toml index 8896588..8742528 100644 --- a/mise.toml +++ b/mise.toml @@ -5,8 +5,8 @@ FLINT_CONFIG_DIR = ".github/config" lychee = "0.22.0" node = "24.14.1" "npm:renovate" = "43.92.1" -shellcheck = "v0.11.0" -shfmt = "v3.12.0" +"github:koalaman/shellcheck" = "v0.11.0" +"github:mvdan/sh" = "v3.12.0" actionlint = "1.7.10" editorconfig-checker = "v3.6.1" "npm:markdownlint-cli2" = "0.17.2" diff --git a/src/registry.rs b/src/registry.rs index e27004b..86674e3 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -539,11 +539,6 @@ fn check_ktlint() -> Check { "ktlint --format --log-level=error {ROOT}", ) .mise_tool("github:pinterest/ktlint") - .bin(if cfg!(windows) { - "ktlint.bat" - } else { - "ktlint" - }) .formatter() .desc("Lint and format Kotlin code") .lang() @@ -703,6 +698,12 @@ pub fn read_mise_tools(project_root: &Path) -> HashMap { for (alias, version) in aliases { tools.entry(alias).or_insert(version); } + // Hard-coded aliases for repos where the binary name differs from the repo name. + // "github:mvdan/sh" → last component is "sh", but the binary is "shfmt". + // Permanent: consuming repos may use either key and check_active must find shfmt. + if let Some(v) = tools.get("sh").cloned() { + tools.entry("shfmt".to_string()).or_insert(v); + } tools } From e89247812cba4d60a0403d9531613af091b40cbb Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 12:57:35 +0000 Subject: [PATCH 119/141] fix(ci): resolve shfmt binary name when installed via github:mvdan/sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The github: backend installs shfmt as shfmt_v{version} (ubi keeps the versioned filename). Add AltInstall to Check so the registry can declare alternative mise keys and their binary name format, resolved from the installed version in mise.toml at runtime. - check_active accepts alt_install.key as a second lookup path - resolve_bin_name derives the actual binary name (e.g. shfmt_v3.12.0) - runner substitutes the resolved name in command templates - all_registry_binaries_found uses resolve_bin_name for PATH checks Drop the hand-coded sh→shfmt alias in read_mise_tools — check_active now handles github:mvdan/sh directly via alt_install. --- src/main.rs | 3 +++ src/registry.rs | 58 ++++++++++++++++++++++++++++++++++++++++++------- src/runner.rs | 46 ++++++++++++++++++++++++++++++++++----- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index d2c2678..94735fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -230,6 +230,7 @@ async fn run( project_root, &cfg, config_dir, + &mise_tools, ) .await?; @@ -259,6 +260,7 @@ async fn run( project_root, &cfg, config_dir, + &mise_tools, ) .await?; for r in fix_results { @@ -319,6 +321,7 @@ async fn run( project_root, &cfg, config_dir, + &mise_tools, ) .await?; diff --git a/src/registry.rs b/src/registry.rs index 86674e3..0d2034f 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -51,6 +51,18 @@ pub enum CheckKind { Special(SpecialKind), } +/// Alternative installation path for a check: a different mise tool key that provides +/// the same binary under a different name (e.g. `"github:mvdan/sh"` installs shfmt as +/// `shfmt_v3.12.0` rather than `shfmt`). +#[derive(Debug, Clone)] +pub struct AltInstall { + /// The alternative mise.toml tool key (e.g. `"github:mvdan/sh"`). + pub key: &'static str, + /// Binary name format string; `{version}` is replaced with the version from mise.toml. + /// E.g. `"shfmt_{version}"` with version `"v3.12.0"` → `"shfmt_v3.12.0"`. + pub bin_fmt: &'static str, +} + #[derive(Debug, Clone)] pub struct Check { pub name: &'static str, @@ -90,6 +102,11 @@ pub struct Check { /// entry: `rust = { version = "latest", components = "clippy,rustfmt" }`. pub mise_install_components: Option<&'static str>, pub kind: CheckKind, + /// Alternative installation: a different mise tool key that also provides this check, + /// with a binary name format that may differ from `bin_name`. The `bin_fmt` field + /// uses `{version}` as a placeholder for the installed version from mise.toml + /// (e.g. `"shfmt_{version}"` with version `"v3.12.0"` → `"shfmt_v3.12.0"`). + pub alt_install: Option, /// Plain-text description of what the check does — shown in `flint linters` and the README table. pub desc: &'static str, /// Extended markdown documentation shown in the README detail section (behaviour, config examples). @@ -167,6 +184,7 @@ impl Check { full_fix_cmd: "", scope, }, + alt_install: None, desc: "", docs: "", } @@ -189,6 +207,7 @@ impl Check { mise_install_key: None, mise_install_components: None, kind: CheckKind::Special(kind), + alt_install: None, desc: "", docs: "", } @@ -269,6 +288,13 @@ impl Check { self } + /// Register an alternative mise tool key for this check, with a versioned binary name. + /// Used when a backend installs the binary under a versioned name (e.g. `shfmt_v3.12.0`). + pub fn alt_install(mut self, key: &'static str, bin_fmt: &'static str) -> Self { + self.alt_install = Some(AltInstall { key, bin_fmt }); + self + } + /// Set the plain-text description shown in `flint linters` and the README table. pub fn desc(mut self, desc: &'static str) -> Self { self.desc = desc; @@ -353,6 +379,7 @@ fn check_shfmt() -> Check { Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) .fix("shfmt -w {FILE}") .formatter() + .alt_install("github:mvdan/sh", "shfmt_{version}") .desc("Format shell scripts") .style() } @@ -698,12 +725,6 @@ pub fn read_mise_tools(project_root: &Path) -> HashMap { for (alias, version) in aliases { tools.entry(alias).or_insert(version); } - // Hard-coded aliases for repos where the binary name differs from the repo name. - // "github:mvdan/sh" → last component is "sh", but the binary is "shfmt". - // Permanent: consuming repos may use either key and check_active must find shfmt. - if let Some(v) = tools.get("sh").cloned() { - tools.entry("shfmt".to_string()).or_insert(v); - } tools } @@ -713,8 +734,15 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool if check.activate_unconditionally { return true; } + // Check primary key first, then alt_install key. let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); - let Some(declared) = mise_tools.get(lookup_key) else { + let declared = mise_tools.get(lookup_key).or_else(|| { + check + .alt_install + .as_ref() + .and_then(|a| mise_tools.get(a.key)) + }); + let Some(declared) = declared else { return false; }; let Some(range_str) = check.version_range else { @@ -726,6 +754,19 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool coerce_version(declared).is_some_and(|v| req.matches(&v)) } +/// Returns the binary name to use for this check given the active mise tools. +/// When the check is installed via its `alt_install` key, the versioned binary +/// name is derived from `bin_fmt` + the installed version (e.g. `"shfmt_v3.12.0"`). +/// Falls back to `check.bin_name` for standard installations. +pub fn resolve_bin_name(check: &Check, mise_tools: &HashMap) -> String { + if let Some(alt) = &check.alt_install + && let Some(version) = mise_tools.get(alt.key) + { + return alt.bin_fmt.replace("{version}", version); + } + check.bin_name.to_string() +} + /// Returns true if `bin_name` exists as a file in any directory in `path_var` /// (a `:`-separated PATH string). Accepts the PATH string as a parameter so /// callers can substitute a test-controlled path without mutating env vars. @@ -792,11 +833,12 @@ mod tests { #[test] fn all_registry_binaries_found() { let registry = builtin(); + let mise_tools = read_mise_tools(Path::new(env!("CARGO_MANIFEST_DIR"))); let not_found: Vec<&str> = registry .iter() .filter(|c| c.uses_binary()) - .filter(|c| !binary_on_path(c.bin_name)) + .filter(|c| !binary_on_path(&resolve_bin_name(c, &mise_tools))) .map(|c| c.name) .collect(); diff --git a/src/runner.rs b/src/runner.rs index 79481fd..ee1d53b 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -8,7 +8,7 @@ use tokio::task::JoinSet; use crate::config::{Config, LicenseHeaderConfig, LycheeConfig, RenovateDepsConfig}; use crate::files::FileList; use crate::linters::{LinterOutput, license_header, lychee, renovate_deps}; -use crate::registry::{Check, CheckKind, Scope, SpecialKind}; +use crate::registry::{self, Check, CheckKind, Scope, SpecialKind}; pub struct RunOptions { pub fix: bool, @@ -94,6 +94,7 @@ pub async fn run( project_root: &Path, cfg: &Config, config_dir: &Path, + mise_tools: &std::collections::HashMap, ) -> Result> { let RunOptions { fix, @@ -103,7 +104,18 @@ pub async fn run( } = opts; let prepared: Vec = checks .iter() - .filter_map(|&check| prepare(check, file_list, fix, project_root, checks, cfg, config_dir)) + .filter_map(|&check| { + prepare( + check, + file_list, + fix, + project_root, + checks, + cfg, + config_dir, + mise_tools, + ) + }) .collect(); if fix { @@ -155,6 +167,7 @@ fn prepare( active_checks: &[&Check], cfg: &Config, config_dir: &Path, + mise_tools: &std::collections::HashMap, ) -> Option { let name = check.name.to_string(); match &check.kind { @@ -166,6 +179,7 @@ fn prepare( project_root, active_checks, config_dir, + mise_tools, ); if argv_list.is_empty() { return None; @@ -217,7 +231,9 @@ fn build_invocations( project_root: &Path, active_checks: &[&Check], config_dir: &Path, + mise_tools: &std::collections::HashMap, ) -> Vec> { + let resolved_bin = registry::resolve_bin_name(check, mise_tools); let CheckKind::Template { check_cmd, fix_cmd, @@ -229,10 +245,28 @@ fn build_invocations( return vec![]; }; - let cmd_template = if fix && check.has_fix() { - fix_cmd + // Substitute resolved binary name at the start of each command template. + // When installed via alt_install (e.g. "github:mvdan/sh" → "shfmt_v3.12.0"), + // the template's leading binary name must be replaced before execution. + let sub_bin = |t: &str| -> String { + if resolved_bin == check.bin_name { + return t.to_string(); + } + match t.strip_prefix(check.bin_name) { + Some(rest) if rest.is_empty() || rest.starts_with(' ') => { + format!("{}{}", resolved_bin, rest) + } + _ => t.to_string(), + } + }; + + let cmd_template_buf; + let cmd_template: &str = if fix && check.has_fix() { + cmd_template_buf = sub_bin(fix_cmd); + &cmd_template_buf } else { - check_cmd + cmd_template_buf = sub_bin(check_cmd); + &cmd_template_buf }; // Collect patterns from checks that are active and listed in excludes_if_active. @@ -290,7 +324,7 @@ fn build_invocations( None }; if let Some(cmd) = effective { - let cmd = cmd.replace("{ROOT}", "e_path(project_root)); + let cmd = sub_bin(cmd).replace("{ROOT}", "e_path(project_root)); return vec![inject_config(shell_words(cmd), &config_args)]; } } From d6b5c1fa1b6306edcd06a87028f534321651ddfb Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 13:25:08 +0000 Subject: [PATCH 120/141] =?UTF-8?q?refactor(registry):=20simplify=20tool?= =?UTF-8?q?=20key=20fields=20=E2=80=94=20drop=20install=5Fkey,=20add=20ver?= =?UTF-8?q?sioned=5Fbin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove AltInstall struct and alt_install field. The alt_install concept was mixing two concerns; the existing mise_tool field handles key lookup. - Remove mise_install_key and the .install_key() builder. flint init now writes mise_tool_name (falling back to bin_name), same key used for detection — no reason to have separate concepts. - Add versioned_bin_fmt / .versioned_bin() for tools installed with a version suffix in the binary name (shfmt_v3.12.0 via github:mvdan/sh). - Convert all former .install_key() callsites to .mise_tool(). --- src/init/mod.rs | 7 +--- src/registry.rs | 90 ++++++++++++++++--------------------------------- src/runner.rs | 6 +++- 3 files changed, 35 insertions(+), 68 deletions(-) diff --git a/src/init/mod.rs b/src/init/mod.rs index faf7176..c098338 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -324,12 +324,7 @@ pub fn install_key(check: &Check) -> Option<&'static str> { if !check.uses_binary() || check.activate_unconditionally { return None; } - Some( - check - .mise_install_key - .or(check.mise_tool_name) - .unwrap_or(check.bin_name), - ) + Some(check.mise_tool_name.unwrap_or(check.bin_name)) } /// Compute the map of `tool_key → optional_components` for the given category set, diff --git a/src/registry.rs b/src/registry.rs index 0d2034f..89a6b20 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -51,18 +51,6 @@ pub enum CheckKind { Special(SpecialKind), } -/// Alternative installation path for a check: a different mise tool key that provides -/// the same binary under a different name (e.g. `"github:mvdan/sh"` installs shfmt as -/// `shfmt_v3.12.0` rather than `shfmt`). -#[derive(Debug, Clone)] -pub struct AltInstall { - /// The alternative mise.toml tool key (e.g. `"github:mvdan/sh"`). - pub key: &'static str, - /// Binary name format string; `{version}` is replaced with the version from mise.toml. - /// E.g. `"shfmt_{version}"` with version `"v3.12.0"` → `"shfmt_v3.12.0"`. - pub bin_fmt: &'static str, -} - #[derive(Debug, Clone)] pub struct Check { pub name: &'static str, @@ -93,20 +81,15 @@ pub struct Check { /// Always considered active regardless of mise.toml (used for config-activated checks). pub activate_unconditionally: bool, /// Canonical mise tool key to write when setting up a new project (e.g. `npm:prettier`). - /// When `None`, falls back to `mise_tool_name` then `bin_name`. - /// Distinct from `mise_tool_name` (lookup key) because existing repos may declare the - /// same tool under a bare name (e.g. `ruff = "latest"`) which must still be detected. - pub mise_install_key: Option<&'static str>, /// Optional mise toolchain components to request when installing via `flint init` /// (e.g. `"clippy,rustfmt"` for the `rust` toolchain). Produces an inline-table /// entry: `rust = { version = "latest", components = "clippy,rustfmt" }`. pub mise_install_components: Option<&'static str>, pub kind: CheckKind, - /// Alternative installation: a different mise tool key that also provides this check, - /// with a binary name format that may differ from `bin_name`. The `bin_fmt` field - /// uses `{version}` as a placeholder for the installed version from mise.toml - /// (e.g. `"shfmt_{version}"` with version `"v3.12.0"` → `"shfmt_v3.12.0"`). - pub alt_install: Option, + /// Binary name format when the backend installs with a versioned name (e.g. `"shfmt_{version}"` + /// → `"shfmt_v3.12.0"`). `{version}` is replaced with the version declared in mise.toml. + /// Paired with `mise_tool_name` when the backend names binaries with a version suffix. + pub versioned_bin_fmt: Option<&'static str>, /// Plain-text description of what the check does — shown in `flint linters` and the README table. pub desc: &'static str, /// Extended markdown documentation shown in the README detail section (behaviour, config examples). @@ -175,7 +158,6 @@ impl Check { defers_to_formatters: false, activate_unconditionally: false, category: Category::Default, - mise_install_key: None, mise_install_components: None, kind: CheckKind::Template { check_cmd, @@ -184,7 +166,7 @@ impl Check { full_fix_cmd: "", scope, }, - alt_install: None, + versioned_bin_fmt: None, desc: "", docs: "", } @@ -204,10 +186,9 @@ impl Check { defers_to_formatters: false, activate_unconditionally: false, category: Category::Default, - mise_install_key: None, mise_install_components: None, kind: CheckKind::Special(kind), - alt_install: None, + versioned_bin_fmt: None, desc: "", docs: "", } @@ -288,10 +269,11 @@ impl Check { self } - /// Register an alternative mise tool key for this check, with a versioned binary name. - /// Used when a backend installs the binary under a versioned name (e.g. `shfmt_v3.12.0`). - pub fn alt_install(mut self, key: &'static str, bin_fmt: &'static str) -> Self { - self.alt_install = Some(AltInstall { key, bin_fmt }); + /// Set a versioned binary name format for tools where the backend installs with a + /// version suffix (e.g. `"shfmt_{version}"` → `"shfmt_v3.12.0"`). Paired with + /// `.mise_tool()` to identify which key provides the version. + pub fn versioned_bin(mut self, fmt: &'static str) -> Self { + self.versioned_bin_fmt = Some(fmt); self } @@ -326,16 +308,6 @@ impl Check { self } - /// Set the canonical mise tool key used when installing this linter via `flint init` - /// (e.g. `npm:prettier`, `pipx:ruff`). Distinct from `mise_tool_name`, which is the - /// lookup key used to detect the tool in an existing mise.toml — existing repos may - /// declare the tool under a bare name (`ruff = "latest"`) which is still detectable - /// via the alias map but must not be overwritten with the prefixed key. - pub fn install_key(mut self, key: &'static str) -> Self { - self.mise_install_key = Some(key); - self - } - /// Set toolchain components required when installing via `flint init` /// (e.g. `"clippy,rustfmt"` for the `rust` toolchain). pub fn install_components(mut self, components: &'static str) -> Self { @@ -379,7 +351,8 @@ fn check_shfmt() -> Check { Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) .fix("shfmt -w {FILE}") .formatter() - .alt_install("github:mvdan/sh", "shfmt_{version}") + .mise_tool("github:mvdan/sh") + .versioned_bin("shfmt_{version}") .desc("Format shell scripts") .style() } @@ -389,7 +362,7 @@ fn check_markdownlint_cli2() -> Check { .fix("markdownlint-cli2 --fix {FILE}") .linter_config(".markdownlint.jsonc", "--config") .desc("Lint Markdown files for style and consistency") - .install_key("npm:markdownlint-cli2") + .mise_tool("npm:markdownlint-cli2") } fn check_prettier() -> Check { @@ -403,7 +376,7 @@ fn check_prettier() -> Check { .linter_config(".prettierrc", "--config") .formatter() .desc("Format Markdown and YAML files") - .install_key("npm:prettier") + .mise_tool("npm:prettier") } fn check_actionlint() -> Check { @@ -431,7 +404,7 @@ fn check_hadolint() -> Check { fn check_xmllint() -> Check { Check::files("xmllint", "xmllint --noout {FILES}", &["*.xml"]) .mise_tool("cargo:xmloxide") - .install_key("cargo:xmloxide") + .mise_tool("cargo:xmloxide") .desc("Validate XML files are well-formed") } @@ -440,7 +413,7 @@ fn check_codespell() -> Check { .fix("codespell --write-changes {FILES}") .linter_config(".codespellrc", "--config") .desc("Check for common spelling mistakes") - .install_key("pipx:codespell") + .mise_tool("pipx:codespell") } fn check_editorconfig_checker() -> Check { @@ -471,7 +444,7 @@ fn check_ruff() -> Check { .fix("ruff check --fix {FILE}") .linter_config("ruff.toml", "--config") .desc("Lint Python code") - .install_key("pipx:ruff") + .mise_tool("pipx:ruff") .lang() } @@ -482,7 +455,7 @@ fn check_ruff_format() -> Check { .linter_config("ruff.toml", "--config") .formatter() .desc("Format Python code") - .install_key("pipx:ruff") + .mise_tool("pipx:ruff") .lang() } @@ -494,7 +467,7 @@ fn check_biome() -> Check { ) .fix("biome check --fix {FILE}") .desc("Lint JS/TS/JSON files") - .install_key("npm:@biomejs/biome") + .mise_tool("npm:@biomejs/biome") .lang() } @@ -508,7 +481,7 @@ fn check_biome_format() -> Check { .fix("biome format --write {FILE}") .formatter() .desc("Format JS/TS/JSON files") - .install_key("npm:@biomejs/biome") + .mise_tool("npm:@biomejs/biome") .lang() } @@ -734,14 +707,8 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool if check.activate_unconditionally { return true; } - // Check primary key first, then alt_install key. let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); - let declared = mise_tools.get(lookup_key).or_else(|| { - check - .alt_install - .as_ref() - .and_then(|a| mise_tools.get(a.key)) - }); + let declared = mise_tools.get(lookup_key); let Some(declared) = declared else { return false; }; @@ -755,14 +722,15 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool } /// Returns the binary name to use for this check given the active mise tools. -/// When the check is installed via its `alt_install` key, the versioned binary -/// name is derived from `bin_fmt` + the installed version (e.g. `"shfmt_v3.12.0"`). +/// When `versioned_bin_fmt` is set, the version from mise.toml is substituted +/// into the format string (e.g. `"shfmt_{version}"` + `"v3.12.0"` → `"shfmt_v3.12.0"`). /// Falls back to `check.bin_name` for standard installations. pub fn resolve_bin_name(check: &Check, mise_tools: &HashMap) -> String { - if let Some(alt) = &check.alt_install - && let Some(version) = mise_tools.get(alt.key) - { - return alt.bin_fmt.replace("{version}", version); + if let Some(fmt) = check.versioned_bin_fmt { + let key = check.mise_tool_name.unwrap_or(check.bin_name); + if let Some(version) = mise_tools.get(key) { + return fmt.replace("{version}", version); + } } check.bin_name.to_string() } diff --git a/src/runner.rs b/src/runner.rs index ee1d53b..19365de 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -684,6 +684,7 @@ mod tests { full_fix_cmd: "", scope: Scope::Project, }, + versioned_bin_fmt: None, desc: "", docs: "", } @@ -711,7 +712,8 @@ mod tests { false, Path::new("/repo"), &[], - Path::new("/repo") + Path::new("/repo"), + &Default::default(), ) .is_empty() ); @@ -728,6 +730,7 @@ mod tests { Path::new("/repo"), &[], Path::new("/repo"), + &Default::default(), ); assert_eq!(inv, vec![vec!["run-it".to_string()]]); } @@ -743,6 +746,7 @@ mod tests { Path::new("/repo"), &[], Path::new("/repo"), + &Default::default(), ); assert_eq!(inv, vec![vec!["run-it".to_string()]]); } From b68c5670d350cc03fb90706f6db74dc8b10b5a0f Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 13:32:17 +0000 Subject: [PATCH 121/141] fix(ci): remove stale mise_install_key field from runner.rs test struct --- src/runner.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/runner.rs b/src/runner.rs index 19365de..5f0436c 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -675,7 +675,6 @@ mod tests { defers_to_formatters: false, activate_unconditionally: false, category: Category::Default, - mise_install_key: None, mise_install_components: None, kind: CheckKind::Template { check_cmd: "run-it", From 4d6be29d8b183275f67fb863ae4ff7fdb79b984d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 13:37:41 +0000 Subject: [PATCH 122/141] fix(registry): check_active also accepts bare bin_name when mise_tool differs After replacing install_key with mise_tool, check_active started looking for npm:/pipx:/github: prefixed keys only. Repos (and e2e fixtures) that declare the tool under the bare name (markdownlint-cli2, biome, ruff, etc.) stopped matching. Accept bin_name as a fallback when mise_tool_name is set. --- src/registry.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/registry.rs b/src/registry.rs index 89a6b20..67a5f53 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -708,7 +708,11 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool return true; } let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); - let declared = mise_tools.get(lookup_key); + // When mise_tool_name is set (e.g. "npm:markdownlint-cli2"), also accept + // the bare bin_name ("markdownlint-cli2") so repos using either form work. + let declared = mise_tools + .get(lookup_key) + .or_else(|| check.mise_tool_name.and(mise_tools.get(check.bin_name))); let Some(declared) = declared else { return false; }; From ea190f39ee8d1f46c5779464996b43b0bf5259a0 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 13:46:38 +0000 Subject: [PATCH 123/141] fix(ci): update shfmt e2e fixtures and check_active to use github:mvdan/sh Update shfmt e2e test fixtures to declare 'github:mvdan/sh' = 'v3.12.0' so resolve_bin_name returns the correct versioned binary name (shfmt_v3.12.0) that ubi installs. Also restore check_active fallback: when mise_tool_name differs from bin_name, also accept the bare bin_name key so repos declaring the tool under a bare name (markdownlint-cli2, biome, ruff etc.) continue to work. --- .../editorconfig-checker/formatter-exclusion/files/mise.toml | 2 +- tests/cases/shfmt/auto-fix/files/mise.toml | 2 +- tests/cases/shfmt/clean/files/mise.toml | 2 +- tests/cases/shfmt/failure/files/mise.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml b/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml index 3d1328f..c8a7cc2 100644 --- a/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml +++ b/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml @@ -1,3 +1,3 @@ [tools] editorconfig-checker = "latest" -shfmt = "latest" +"github:mvdan/sh" = "v3.12.0" diff --git a/tests/cases/shfmt/auto-fix/files/mise.toml b/tests/cases/shfmt/auto-fix/files/mise.toml index 682bf99..aa00ef2 100644 --- a/tests/cases/shfmt/auto-fix/files/mise.toml +++ b/tests/cases/shfmt/auto-fix/files/mise.toml @@ -1,2 +1,2 @@ [tools] -shfmt = "latest" +"github:mvdan/sh" = "v3.12.0" diff --git a/tests/cases/shfmt/clean/files/mise.toml b/tests/cases/shfmt/clean/files/mise.toml index 682bf99..aa00ef2 100644 --- a/tests/cases/shfmt/clean/files/mise.toml +++ b/tests/cases/shfmt/clean/files/mise.toml @@ -1,2 +1,2 @@ [tools] -shfmt = "latest" +"github:mvdan/sh" = "v3.12.0" diff --git a/tests/cases/shfmt/failure/files/mise.toml b/tests/cases/shfmt/failure/files/mise.toml index 682bf99..aa00ef2 100644 --- a/tests/cases/shfmt/failure/files/mise.toml +++ b/tests/cases/shfmt/failure/files/mise.toml @@ -1,2 +1,2 @@ [tools] -shfmt = "latest" +"github:mvdan/sh" = "v3.12.0" From af1dc83bcb8b11b6988660b12c905c094d0c5c79 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 13:55:11 +0000 Subject: [PATCH 124/141] fix(ci): suppress clippy::too_many_arguments on prepare() --- src/runner.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runner.rs b/src/runner.rs index 5f0436c..54f7e69 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -159,6 +159,7 @@ pub async fn run( Ok(collected) } +#[allow(clippy::too_many_arguments)] fn prepare( check: &Check, file_list: &FileList, From dcaeac6dba90a3ae947c847755ebc2b51cae7817 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 13:57:14 +0000 Subject: [PATCH 125/141] ci: disable fail-fast on test and release matrices Prevents one flaky platform (e.g. renovate-deps on macOS) from cancelling the other matrix legs before they complete. --- .github/workflows/release.yml | 1 + .github/workflows/test.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5494fda..a92bb35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: attestations: write strategy: + fail-fast: false matrix: include: - target: x86_64-unknown-linux-gnu diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a83eede..fda4997 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,7 @@ jobs: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: include: - os: ubuntu-24.04 From f6adda1bbce423de854bf1e9ea7d2fec3362ed9c Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 13:59:16 +0000 Subject: [PATCH 126/141] ci: pin macos-15 and windows-2025 for consistency with ubuntu-24.04 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fda4997..023cbba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,10 +19,10 @@ jobs: - os: ubuntu-24.04 mise_version: v2026.4.1 mise_sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd - - os: macos-latest + - os: macos-15 mise_version: v2026.4.1 mise_sha256: c85b387148d478dec754ded31d01798e2f4e4e9448f75682dcc6bb7c16c9a4f5 - - os: windows-latest + - os: windows-2025 mise_version: v2026.4.1 mise_sha256: "" # not published for .exe — https://github.com/jdx/mise/pull/8997 From 26e5b0c44522fa1ee5e2777ec37b337e446e723c Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 14:15:12 +0000 Subject: [PATCH 127/141] fix(windows): strip \\?\ UNC prefix after canonicalize() canonicalize() on Windows returns \\?\C:\... verbatim paths. Git and other tools don't handle UNC CWDs correctly, causing git ls-files to return nothing and all e2e checks to silently skip. Strip the prefix back to a regular drive path while keeping the macOS /private fix. --- src/main.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main.rs b/src/main.rs index 94735fd..b976ac0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,7 +111,19 @@ async fn main() -> Result<()> { let project_root = std::env::var("MISE_PROJECT_ROOT") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::current_dir().expect("cannot determine working directory")); + // Canonicalize to resolve symlinks (e.g. /private/... on macOS). + // On Windows, canonicalize() adds a \\?\ verbatim prefix that git and other + // tools don't handle; strip it back to a regular path. let project_root = project_root.canonicalize().unwrap_or(project_root); + #[cfg(windows)] + let project_root = { + let s = project_root.to_string_lossy(); + if let Some(stripped) = s.strip_prefix(r"\\?\") { + std::path::PathBuf::from(stripped) + } else { + project_root + } + }; let config_dir = std::env::var("FLINT_CONFIG_DIR") .map(std::path::PathBuf::from) From ae6d5d8c2b0e9b17d8b35d7ea343473e87543c98 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 14:35:49 +0000 Subject: [PATCH 128/141] ci: add Windows debug step to inspect mise install dirs --- .github/workflows/test.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 023cbba..0783b4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,5 +48,20 @@ jobs: - name: Restore cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Debug Windows install dirs + if: runner.os == 'Windows' + shell: pwsh + run: | + $base = "$env:LOCALAPPDATA\mise\installs" + foreach ($dir in @("github-mvdan-sh", "npm-prettier", "npm-biomejs-biome", "github-pinterest-ktlint")) { + $full = Get-ChildItem "$base\$dir" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($full) { + Write-Host "=== $dir\$($full.Name) ===" + Get-ChildItem "$base\$dir\$($full.Name)" -Recurse -File | Select-Object FullName, Length | Format-Table + } + } + Write-Host "=== shims ===" + Get-ChildItem "$env:LOCALAPPDATA\mise\shims" -File | Where-Object { $_.Name -match "shfmt|prettier|biome|ktlint" } | Select-Object Name, Length | Format-Table + - name: Test run: cargo test From 143a278972b01ba6dacadd80f12da148743173ab Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 14:47:02 +0000 Subject: [PATCH 129/141] fix(windows): route all commands through cmd.exe /C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, mise creates .cmd shim files that cannot be spawned directly via CreateProcessW — they require cmd.exe as the interpreter. Using cmd.exe /C handles both .cmd shims (npm tools: prettier, biome, etc.) and the versioned shfmt_v3.12.0.cmd shim. Applies only on Windows; Unix paths are unchanged. Also remove the temporary debug step from test.yml. --- src/runner.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/runner.rs b/src/runner.rs index 54f7e69..e8c9cb2 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -410,6 +410,18 @@ async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) - if argv.is_empty() { continue; } + // On Windows, mise creates tiny .cmd shim files that must be launched via + // cmd.exe — CreateProcessW cannot execute .cmd files directly. Using + // `cmd.exe /C ` works for both .cmd shims and .exe binaries. + #[cfg(windows)] + let result = Command::new("cmd.exe") + .arg("/C") + .args(argv) + .current_dir(root) + .stdin(Stdio::null()) + .output() + .await; + #[cfg(not(windows))] let result = Command::new(&argv[0]) .args(&argv[1..]) .current_dir(root) From 5f291b4e4792e6bca4674ed6e430548c184e103c Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 14:47:26 +0000 Subject: [PATCH 130/141] ci: remove debug step --- .github/workflows/test.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0783b4f..023cbba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,20 +48,5 @@ jobs: - name: Restore cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - name: Debug Windows install dirs - if: runner.os == 'Windows' - shell: pwsh - run: | - $base = "$env:LOCALAPPDATA\mise\installs" - foreach ($dir in @("github-mvdan-sh", "npm-prettier", "npm-biomejs-biome", "github-pinterest-ktlint")) { - $full = Get-ChildItem "$base\$dir" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($full) { - Write-Host "=== $dir\$($full.Name) ===" - Get-ChildItem "$base\$dir\$($full.Name)" -Recurse -File | Select-Object FullName, Length | Format-Table - } - } - Write-Host "=== shims ===" - Get-ChildItem "$env:LOCALAPPDATA\mise\shims" -File | Where-Object { $_.Name -match "shfmt|prettier|biome|ktlint" } | Select-Object Name, Length | Format-Table - - name: Test run: cargo test From d5b8a1d05f6b00da9489ced0a69886e57810430b Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 14:48:39 +0000 Subject: [PATCH 131/141] ci: add permanent cross-platform tool listing step --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 023cbba..2b489ff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,5 +48,8 @@ jobs: - name: Restore cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: List installed tools + run: mise ls --current + - name: Test run: cargo test From ab470a14b21fa0ffe3001ae0e97fb40036658880 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 14:54:24 +0000 Subject: [PATCH 132/141] ci: remove redundant mise ls step (already in mise-action output) --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b489ff..023cbba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,8 +48,5 @@ jobs: - name: Restore cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - name: List installed tools - run: mise ls --current - - name: Test run: cargo test From 89fae95aca51bca7fadc86bbb451a42bbdbbb89a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 14:59:51 +0000 Subject: [PATCH 133/141] fix(windows,ci): normalize paths in e2e output and retry renovate on empty result Windows e2e: strip \\?\ from canonical path before substitution (so tool output using long names matches when repo.path() has 8.3 short names), then normalize backslashes to forward slashes so Unix snapshots match Windows tool output. macOS flakiness: renovate occasionally produces empty packageFiles on first run due to transient network issues. Retry up to 3 times with a 3s delay before accepting an empty result. --- src/linters/renovate_deps.rs | 14 ++++++++++++-- tests/e2e.rs | 24 ++++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 3b077e7..426ecfd 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -38,8 +38,18 @@ async fn run_inner( let config_path = resolve_renovate_config_path(project_root)?; let committed_path = committed_path_for_config(&config_path); let committed_display = display_path(project_root, &committed_path); - let log_bytes = run_renovate(project_root, &config_path).await?; - let generated = extract_deps(&log_bytes, &cfg.exclude_managers)?; + + // Renovate occasionally produces empty packageFiles on the first run (transient + // network or registry issue). Retry up to 3 times with a short delay. + let mut generated = DepMap::default(); + for attempt in 1..=3u32 { + let log_bytes = run_renovate(project_root, &config_path).await?; + generated = extract_deps(&log_bytes, &cfg.exclude_managers)?; + if !generated.is_empty() || attempt == 3 { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } if !committed_path.exists() { if fix { diff --git a/tests/e2e.rs b/tests/e2e.rs index 21a9721..012eea9 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -271,17 +271,33 @@ fn run_case(case: &Path, name: &str, update: bool) { let repo_canonical_str = repo .path() .canonicalize() - .map(|p| p.to_string_lossy().into_owned()) + .map(|p| { + let s = p.to_string_lossy().into_owned(); + // Strip Windows verbatim prefix \\?\ — tools receive the stripped path + // (main.rs does the same), so the canonical form without \\?\ is what + // appears in tool output and must be matched here. + #[cfg(windows)] + if let Some(stripped) = s.strip_prefix(r"\\?\") { + return stripped.to_string(); + } + s + }) .unwrap_or_default(); let normalize = |s: String| -> String { - // Replace canonical path first (e.g. /private/var/... on macOS), then the - // non-canonical one, so both forms are collapsed to . + // Replace canonical path first (e.g. /private/var/... on macOS, long name + // vs 8.3 short name on Windows), then the non-canonical one, so both forms + // are collapsed to . let s = if repo_canonical_str != repo_str.as_ref() { s.replace(&repo_canonical_str, "") } else { s }; - s.replace(repo_str.as_ref(), "") + let s = s.replace(repo_str.as_ref(), ""); + // On Windows, normalize backslash path separators to forward slashes so + // snapshots written on Unix match tool output on Windows. + #[cfg(windows)] + let s = s.replace('\\', "/"); + s }; let stderr = normalize_timing(&strip_ansi(&normalize( String::from_utf8_lossy(&out.stderr).into_owned(), From e24fdd4fc5ec2d85e01e6065a488db7f63295d36 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 15:05:57 +0000 Subject: [PATCH 134/141] fix(windows): handle CRLF line endings and forward-slash paths in e2e normalize - Strip \r\n/\r so Windows tool output (CRLF) matches Unix snapshots - Normalize \ to / in both the output AND the repo path strings before substitution, so both shfmt (backslash paths) and lychee (file:// URIs with forward slashes) get substituted correctly --- tests/e2e.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/e2e.rs b/tests/e2e.rs index 012eea9..022847f 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -284,20 +284,35 @@ fn run_case(case: &Path, name: &str, update: bool) { }) .unwrap_or_default(); let normalize = |s: String| -> String { + // Normalize CRLF → LF so Windows tool output matches Unix snapshots. + let s = s.replace("\r\n", "\n").replace('\r', "\n"); + + // On Windows, normalize backslash separators to forward slashes in both + // the output and the repo path strings before substitution. This handles: + // - tool output using \ (e.g. shfmt diff headers) + // - file:// URIs already using / (e.g. lychee) — by also normalizing + // the repo path strings we use for matching + #[cfg(windows)] + let (s, repo_canonical_cmp, repo_str_cmp) = { + ( + s.replace('\\', "/"), + repo_canonical_str.replace('\\', "/"), + repo_str.as_ref().replace('\\', "/"), + ) + }; + #[cfg(not(windows))] + let (s, repo_canonical_cmp, repo_str_cmp) = + (s, repo_canonical_str.clone(), repo_str.as_ref().to_string()); + // Replace canonical path first (e.g. /private/var/... on macOS, long name // vs 8.3 short name on Windows), then the non-canonical one, so both forms // are collapsed to . - let s = if repo_canonical_str != repo_str.as_ref() { - s.replace(&repo_canonical_str, "") + let s = if repo_canonical_cmp != repo_str_cmp { + s.replace(&repo_canonical_cmp, "") } else { s }; - let s = s.replace(repo_str.as_ref(), ""); - // On Windows, normalize backslash path separators to forward slashes so - // snapshots written on Unix match tool output on Windows. - #[cfg(windows)] - let s = s.replace('\\', "/"); - s + s.replace(&repo_str_cmp, "") }; let stderr = normalize_timing(&strip_ansi(&normalize( String::from_utf8_lossy(&out.stderr).into_owned(), From c68e74d7798462cbafd163360159f09b1bb06416 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 15:13:30 +0000 Subject: [PATCH 135/141] fix(windows): apply cmd.exe wrapping to renovate and lychee, fix path normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd.exe /C wrapping for renovate and lychee (same fix as runner.rs) - Strip //?/ UNC prefix that leaks through after backslash normalization (e.g. cargo-fmt outputs Diff in //?/C:/... → strip to Diff in C:/...) - Collapse file:/// → file:// to match Unix snapshots --- src/linters/lychee.rs | 10 ++++++++++ src/linters/renovate_deps.rs | 13 +++++++++++++ tests/e2e.rs | 16 ++++++++++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 4a5a942..35ca52d 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -142,6 +142,16 @@ async fn run_lychee_cmd( let mut stdout = format!("==> {description}\n").into_bytes(); + // On Windows, mise shims are .cmd files that require cmd.exe to run. + #[cfg(windows)] + let result = Command::new("cmd.exe") + .arg("/C") + .args(&argv) + .current_dir(project_root) + .stdin(Stdio::null()) + .output() + .await; + #[cfg(not(windows))] let result = Command::new(&argv[0]) .args(&argv[1..]) .current_dir(project_root) diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 426ecfd..3a9628d 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -121,6 +121,19 @@ async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result env.push(("GITHUB_COM_TOKEN".into(), token)); } + // On Windows, mise shims are .cmd files that require cmd.exe to run. + #[cfg(windows)] + let out = Command::new("cmd.exe") + .args([ + "/C", + "renovate", + "--platform=local", + "--require-config=ignored", + "--dry-run=extract", + ]) + .current_dir(project_root) + .envs(env); + #[cfg(not(windows))] let out = Command::new("renovate") .args([ "--platform=local", diff --git a/tests/e2e.rs b/tests/e2e.rs index 022847f..a6a81df 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -294,8 +294,15 @@ fn run_case(case: &Path, name: &str, update: bool) { // the repo path strings we use for matching #[cfg(windows)] let (s, repo_canonical_cmp, repo_str_cmp) = { + let s = s.replace('\\', "/"); + // Strip the \\?\ verbatim UNC prefix that leaks through after \ normalization + // (e.g. cargo-fmt outputs "Diff in //?/C:/..." → strip //?/ → "Diff in C:/...") + let s = s.replace("//?/", ""); + // file:///C:/path → after substitution becomes file:////path + // but snapshots written on Unix have file:///path (no extra slash). + // Fix by collapsing the extra slash after substitution (done below). ( - s.replace('\\', "/"), + s, repo_canonical_str.replace('\\', "/"), repo_str.as_ref().replace('\\', "/"), ) @@ -312,7 +319,12 @@ fn run_case(case: &Path, name: &str, update: bool) { } else { s }; - s.replace(&repo_str_cmp, "") + let s = s.replace(&repo_str_cmp, ""); + // On Windows, lychee uses file:///C:/path URIs; after C:/... → + // substitution the triple slash remains. Collapse to match Unix snapshots. + #[cfg(windows)] + let s = s.replace("file:///", "file://"); + s }; let stderr = normalize_timing(&strip_ansi(&normalize( String::from_utf8_lossy(&out.stderr).into_owned(), From e975fec10838efda6519ca4b635c7172683f38e5 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 15:18:48 +0000 Subject: [PATCH 136/141] refactor(windows): centralize Windows spawn and path helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract spawn_command() into linters/mod.rs — single place for the cmd.exe /C wrapping; all three callers (runner, lychee, renovate_deps) now use it instead of duplicating the cfg(windows) block. - Add dunce crate; replace manual \\?\ stripping in main.rs and e2e.rs with dunce::canonicalize() which handles both macOS /private/ symlinks and Windows verbatim paths in one call. - Extract canonical_repo_path() and normalize_output() helpers in e2e.rs so all Windows path normalization (CRLF, \→/, //?/, file:///) is in one place. --- Cargo.lock | 7 +++ Cargo.toml | 1 + src/linters/lychee.rs | 13 +---- src/linters/mod.rs | 20 +++++++ src/linters/renovate_deps.rs | 40 +++++--------- src/main.rs | 15 +---- src/runner.rs | 16 +----- tests/e2e.rs | 104 ++++++++++++++++------------------- 8 files changed, 92 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00b3731..7c52da5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "equivalent" version = "1.0.2" @@ -224,6 +230,7 @@ dependencies = [ "anyhow", "clap", "crossterm", + "dunce", "figment", "globset", "regex", diff --git a/Cargo.toml b/Cargo.toml index 9d8d6f9..2b9f596 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ globset = "0.4" serde_json = "1" similar = "2" toml_edit = "0.22" +dunce = "1.0.5" [dev-dependencies] tempfile = "3" diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 35ca52d..f8afd4a 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -142,18 +142,7 @@ async fn run_lychee_cmd( let mut stdout = format!("==> {description}\n").into_bytes(); - // On Windows, mise shims are .cmd files that require cmd.exe to run. - #[cfg(windows)] - let result = Command::new("cmd.exe") - .arg("/C") - .args(&argv) - .current_dir(project_root) - .stdin(Stdio::null()) - .output() - .await; - #[cfg(not(windows))] - let result = Command::new(&argv[0]) - .args(&argv[1..]) + let result = super::spawn_command(&argv) .current_dir(project_root) .stdin(Stdio::null()) .output() diff --git a/src/linters/mod.rs b/src/linters/mod.rs index 6d01530..59eab01 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -2,6 +2,26 @@ pub mod license_header; pub mod lychee; pub mod renovate_deps; +/// Build a [`tokio::process::Command`] for the given argv. +/// +/// On Windows, mise shims are `.cmd` files that cannot be spawned directly +/// via `CreateProcessW`. Route everything through `cmd.exe /C` so both +/// `.cmd` shims and native `.exe` binaries are handled uniformly. +pub fn spawn_command(argv: &[String]) -> tokio::process::Command { + #[cfg(windows)] + { + let mut cmd = tokio::process::Command::new("cmd.exe"); + cmd.arg("/C").args(argv); + cmd + } + #[cfg(not(windows))] + { + let mut cmd = tokio::process::Command::new(&argv[0]); + cmd.args(&argv[1..]); + cmd + } +} + /// Output from a single linter run. pub struct LinterOutput { pub ok: bool, diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 3a9628d..b60e704 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -1,7 +1,6 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::{Path, PathBuf}; use std::process::Stdio; -use tokio::process::Command; use crate::config::RenovateDepsConfig; use crate::linters::LinterOutput; @@ -121,32 +120,19 @@ async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result env.push(("GITHUB_COM_TOKEN".into(), token)); } - // On Windows, mise shims are .cmd files that require cmd.exe to run. - #[cfg(windows)] - let out = Command::new("cmd.exe") - .args([ - "/C", - "renovate", - "--platform=local", - "--require-config=ignored", - "--dry-run=extract", - ]) - .current_dir(project_root) - .envs(env); - #[cfg(not(windows))] - let out = Command::new("renovate") - .args([ - "--platform=local", - "--require-config=ignored", - "--dry-run=extract", - ]) - .current_dir(project_root) - .envs(env) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await?; + let out = super::spawn_command(&[ + "renovate".to_string(), + "--platform=local".to_string(), + "--require-config=ignored".to_string(), + "--dry-run=extract".to_string(), + ]) + .current_dir(project_root) + .envs(env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; // Combine stdout+stderr: Renovate writes JSON log lines to stdout, but // some startup messages may appear on stderr. diff --git a/src/main.rs b/src/main.rs index b976ac0..340710b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -112,18 +112,9 @@ async fn main() -> Result<()> { .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::current_dir().expect("cannot determine working directory")); // Canonicalize to resolve symlinks (e.g. /private/... on macOS). - // On Windows, canonicalize() adds a \\?\ verbatim prefix that git and other - // tools don't handle; strip it back to a regular path. - let project_root = project_root.canonicalize().unwrap_or(project_root); - #[cfg(windows)] - let project_root = { - let s = project_root.to_string_lossy(); - if let Some(stripped) = s.strip_prefix(r"\\?\") { - std::path::PathBuf::from(stripped) - } else { - project_root - } - }; + // dunce::canonicalize strips the \\?\ verbatim prefix on Windows that + // git and other tools don't handle. + let project_root = dunce::canonicalize(&project_root).unwrap_or(project_root); let config_dir = std::env::var("FLINT_CONFIG_DIR") .map(std::path::PathBuf::from) diff --git a/src/runner.rs b/src/runner.rs index e8c9cb2..fc25fdd 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -2,7 +2,6 @@ use anyhow::Result; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; -use tokio::process::Command; use tokio::task::JoinSet; use crate::config::{Config, LicenseHeaderConfig, LycheeConfig, RenovateDepsConfig}; @@ -410,20 +409,7 @@ async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) - if argv.is_empty() { continue; } - // On Windows, mise creates tiny .cmd shim files that must be launched via - // cmd.exe — CreateProcessW cannot execute .cmd files directly. Using - // `cmd.exe /C ` works for both .cmd shims and .exe binaries. - #[cfg(windows)] - let result = Command::new("cmd.exe") - .arg("/C") - .args(argv) - .current_dir(root) - .stdin(Stdio::null()) - .output() - .await; - #[cfg(not(windows))] - let result = Command::new(&argv[0]) - .args(&argv[1..]) + let result = crate::linters::spawn_command(argv) .current_dir(root) .stdin(Stdio::null()) .output() diff --git a/tests/e2e.rs b/tests/e2e.rs index a6a81df..0ad0f4e 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -268,64 +268,9 @@ fn run_case(case: &Path, name: &str, update: bool) { let out = flint_with_env(&args, repo.path(), &env_refs); let repo_str = repo.path().to_string_lossy(); - let repo_canonical_str = repo - .path() - .canonicalize() - .map(|p| { - let s = p.to_string_lossy().into_owned(); - // Strip Windows verbatim prefix \\?\ — tools receive the stripped path - // (main.rs does the same), so the canonical form without \\?\ is what - // appears in tool output and must be matched here. - #[cfg(windows)] - if let Some(stripped) = s.strip_prefix(r"\\?\") { - return stripped.to_string(); - } - s - }) - .unwrap_or_default(); - let normalize = |s: String| -> String { - // Normalize CRLF → LF so Windows tool output matches Unix snapshots. - let s = s.replace("\r\n", "\n").replace('\r', "\n"); - - // On Windows, normalize backslash separators to forward slashes in both - // the output and the repo path strings before substitution. This handles: - // - tool output using \ (e.g. shfmt diff headers) - // - file:// URIs already using / (e.g. lychee) — by also normalizing - // the repo path strings we use for matching - #[cfg(windows)] - let (s, repo_canonical_cmp, repo_str_cmp) = { - let s = s.replace('\\', "/"); - // Strip the \\?\ verbatim UNC prefix that leaks through after \ normalization - // (e.g. cargo-fmt outputs "Diff in //?/C:/..." → strip //?/ → "Diff in C:/...") - let s = s.replace("//?/", ""); - // file:///C:/path → after substitution becomes file:////path - // but snapshots written on Unix have file:///path (no extra slash). - // Fix by collapsing the extra slash after substitution (done below). - ( - s, - repo_canonical_str.replace('\\', "/"), - repo_str.as_ref().replace('\\', "/"), - ) - }; - #[cfg(not(windows))] - let (s, repo_canonical_cmp, repo_str_cmp) = - (s, repo_canonical_str.clone(), repo_str.as_ref().to_string()); - - // Replace canonical path first (e.g. /private/var/... on macOS, long name - // vs 8.3 short name on Windows), then the non-canonical one, so both forms - // are collapsed to . - let s = if repo_canonical_cmp != repo_str_cmp { - s.replace(&repo_canonical_cmp, "") - } else { - s - }; - let s = s.replace(&repo_str_cmp, ""); - // On Windows, lychee uses file:///C:/path URIs; after C:/... → - // substitution the triple slash remains. Collapse to match Unix snapshots. - #[cfg(windows)] - let s = s.replace("file:///", "file://"); - s - }; + let repo_canonical_str = canonical_repo_path(repo.path()); + let normalize = + |s: String| -> String { normalize_output(s, repo_str.as_ref(), &repo_canonical_str) }; let stderr = normalize_timing(&strip_ansi(&normalize( String::from_utf8_lossy(&out.stderr).into_owned(), ))); @@ -525,3 +470,46 @@ fn copy_dir_into(src: &Path, dst: &Path) { } } } + +/// Returns the canonical form of the repo path with platform quirks stripped. +/// On macOS this resolves /private/... symlinks. On Windows it strips the \\?\ +/// verbatim prefix so the result matches what tools actually emit. +fn canonical_repo_path(path: &std::path::Path) -> String { + // dunce::canonicalize resolves symlinks (/private/... on macOS) and strips + // the \\?\ verbatim prefix on Windows that tools don't emit. + dunce::canonicalize(path) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// Normalises tool output for snapshot comparison: +/// - CRLF → LF +/// - Windows path separators → forward slashes (both output and repo paths) +/// - Strip residual //?/ UNC prefix (after \ normalisation) +/// - Replace canonical and non-canonical repo path forms with +/// - Collapse file:/// → file:// (lychee Windows URI form) +fn normalize_output(s: String, repo_str: &str, repo_canonical: &str) -> String { + let s = s.replace("\r\n", "\n").replace('\r', "\n"); + + #[cfg(windows)] + let (s, canonical_cmp, repo_cmp) = { + let s = s.replace('\\', "/").replace("//?/", ""); + ( + s, + repo_canonical.replace('\\', "/"), + repo_str.replace('\\', "/"), + ) + }; + #[cfg(not(windows))] + let (s, canonical_cmp, repo_cmp) = (s, repo_canonical.to_string(), repo_str.to_string()); + + let s = if canonical_cmp != repo_cmp { + s.replace(&canonical_cmp, "") + } else { + s + }; + let s = s.replace(&repo_cmp, ""); + #[cfg(windows)] + let s = s.replace("file:///", "file://"); + s +} From bbeb7b04e9c3dd21b3e98fb9b245a17e897e3012 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 15:26:39 +0000 Subject: [PATCH 137/141] fix(windows): detect PE binaries for direct execution, force LF in dotnet fixtures PE detection: some tools (ktlint) are native PE binaries without .exe extension. cmd.exe can't resolve them and the mise shim also fails. Detect MZ magic bytes and execute such binaries directly by full path, falling back to cmd.exe /C for .cmd shims. dotnet-format fixtures: add .editorconfig with end_of_line = lf so dotnet uses LF on Windows, matching the Unix test snapshots. --- src/linters/mod.rs | 39 ++++++++++++++++++- .../auto-fix/files/.editorconfig | 2 + .../dotnet-format/clean/files/.editorconfig | 2 + .../dotnet-format/failure/files/.editorconfig | 2 + 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/cases/dotnet-format/auto-fix/files/.editorconfig create mode 100644 tests/cases/dotnet-format/clean/files/.editorconfig create mode 100644 tests/cases/dotnet-format/failure/files/.editorconfig diff --git a/src/linters/mod.rs b/src/linters/mod.rs index 59eab01..83d161c 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -5,11 +5,19 @@ pub mod renovate_deps; /// Build a [`tokio::process::Command`] for the given argv. /// /// On Windows, mise shims are `.cmd` files that cannot be spawned directly -/// via `CreateProcessW`. Route everything through `cmd.exe /C` so both -/// `.cmd` shims and native `.exe` binaries are handled uniformly. +/// via `CreateProcessW`. However, some tools (e.g. ktlint) are native PE +/// binaries without a `.exe` extension that also cannot run via cmd.exe +/// (the shim fails). We check for a PE header (MZ magic) to distinguish: +/// - PE binary without extension → execute directly by full path +/// - Everything else → route through `cmd.exe /C` to handle `.cmd` shims pub fn spawn_command(argv: &[String]) -> tokio::process::Command { #[cfg(windows)] { + if let Some(full_path) = find_pe_binary(&argv[0]) { + let mut cmd = tokio::process::Command::new(full_path); + cmd.args(&argv[1..]); + return cmd; + } let mut cmd = tokio::process::Command::new("cmd.exe"); cmd.arg("/C").args(argv); cmd @@ -22,6 +30,33 @@ pub fn spawn_command(argv: &[String]) -> tokio::process::Command { } } +/// On Windows, look for `binary` (exact name, no extension) in each PATH +/// directory. If found and it starts with the PE magic bytes `MZ`, return +/// its full path so it can be executed directly via `CreateProcessW`. +#[cfg(windows)] +fn find_pe_binary(binary: &str) -> Option { + use std::io::Read; + let path_var = std::env::var("PATH").ok()?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(binary); + if !candidate.is_file() { + continue; + } + // Check for Windows PE magic bytes (MZ header) + let is_pe = std::fs::File::open(&candidate) + .and_then(|mut f| { + let mut buf = [0u8; 2]; + f.read_exact(&mut buf)?; + Ok(buf == [b'M', b'Z']) + }) + .unwrap_or(false); + if is_pe { + return Some(candidate); + } + } + None +} + /// Output from a single linter run. pub struct LinterOutput { pub ok: bool, diff --git a/tests/cases/dotnet-format/auto-fix/files/.editorconfig b/tests/cases/dotnet-format/auto-fix/files/.editorconfig new file mode 100644 index 0000000..d8c812c --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/files/.editorconfig @@ -0,0 +1,2 @@ +[root] +end_of_line = lf diff --git a/tests/cases/dotnet-format/clean/files/.editorconfig b/tests/cases/dotnet-format/clean/files/.editorconfig new file mode 100644 index 0000000..d8c812c --- /dev/null +++ b/tests/cases/dotnet-format/clean/files/.editorconfig @@ -0,0 +1,2 @@ +[root] +end_of_line = lf diff --git a/tests/cases/dotnet-format/failure/files/.editorconfig b/tests/cases/dotnet-format/failure/files/.editorconfig new file mode 100644 index 0000000..d8c812c --- /dev/null +++ b/tests/cases/dotnet-format/failure/files/.editorconfig @@ -0,0 +1,2 @@ +[root] +end_of_line = lf From 478410069ecd1fa862066e7dcd2ab27f4fe45f09 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 15:35:03 +0000 Subject: [PATCH 138/141] fix(windows): handle self-executing JARs (ktlint) and dotnet LF config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawn_command: detect self-executing JARs (#!/ magic + >1MB) and invoke via 'java -jar' instead of cmd.exe. Handles ktlint which ships as a Unix self-executing JAR — cmd.exe can't run it and the mise shim fails. dotnet-format fixtures: fix .editorconfig syntax (root=true, [*.cs] section) and add end_of_line=lf to prevent dotnet on Windows from suggesting CRLF-based whitespace fixes. --- src/linters/mod.rs | 61 +++++++++++++------ .../auto-fix/files/.editorconfig | 6 +- .../dotnet-format/clean/files/.editorconfig | 6 +- .../dotnet-format/failure/files/.editorconfig | 6 +- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/src/linters/mod.rs b/src/linters/mod.rs index 83d161c..af3330e 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -13,10 +13,18 @@ pub mod renovate_deps; pub fn spawn_command(argv: &[String]) -> tokio::process::Command { #[cfg(windows)] { - if let Some(full_path) = find_pe_binary(&argv[0]) { - let mut cmd = tokio::process::Command::new(full_path); - cmd.args(&argv[1..]); - return cmd; + match find_executable_in_path(&argv[0]) { + Some(WinBinary::Pe(path)) => { + let mut cmd = tokio::process::Command::new(path); + cmd.args(&argv[1..]); + return cmd; + } + Some(WinBinary::Jar(path)) => { + let mut cmd = tokio::process::Command::new("java"); + cmd.arg("-jar").arg(path).args(&argv[1..]); + return cmd; + } + None => {} } let mut cmd = tokio::process::Command::new("cmd.exe"); cmd.arg("/C").args(argv); @@ -30,11 +38,21 @@ pub fn spawn_command(argv: &[String]) -> tokio::process::Command { } } +/// What kind of executable was found in PATH on Windows. +#[cfg(windows)] +enum WinBinary { + /// Native PE binary (MZ magic) — execute directly. + Pe(std::path::PathBuf), + /// Self-executing JAR (starts with `#!` and is large) — run via `java -jar`. + Jar(std::path::PathBuf), +} + /// On Windows, look for `binary` (exact name, no extension) in each PATH -/// directory. If found and it starts with the PE magic bytes `MZ`, return -/// its full path so it can be executed directly via `CreateProcessW`. +/// directory and classify it: +/// - MZ magic → native PE, run directly +/// - `#!` magic + large file (>1 MB) → self-executing JAR (e.g. ktlint), run via `java -jar` #[cfg(windows)] -fn find_pe_binary(binary: &str) -> Option { +fn find_executable_in_path(binary: &str) -> Option { use std::io::Read; let path_var = std::env::var("PATH").ok()?; for dir in std::env::split_paths(&path_var) { @@ -42,16 +60,25 @@ fn find_pe_binary(binary: &str) -> Option { if !candidate.is_file() { continue; } - // Check for Windows PE magic bytes (MZ header) - let is_pe = std::fs::File::open(&candidate) - .and_then(|mut f| { - let mut buf = [0u8; 2]; - f.read_exact(&mut buf)?; - Ok(buf == [b'M', b'Z']) - }) - .unwrap_or(false); - if is_pe { - return Some(candidate); + let mut buf = [0u8; 2]; + let read = std::fs::File::open(&candidate) + .and_then(|mut f| f.read(&mut buf).map(|n| n)) + .unwrap_or(0); + if read < 2 { + continue; + } + if buf == [b'M', b'Z'] { + return Some(WinBinary::Pe(candidate)); + } + if buf == [b'#', b'!'] { + // Self-executing JAR: shell script header prepended to a JAR. + // A real script would be tiny; a self-executing JAR is many MB. + if std::fs::metadata(&candidate) + .map(|m| m.len() > 1_000_000) + .unwrap_or(false) + { + return Some(WinBinary::Jar(candidate)); + } } } None diff --git a/tests/cases/dotnet-format/auto-fix/files/.editorconfig b/tests/cases/dotnet-format/auto-fix/files/.editorconfig index d8c812c..a9955e6 100644 --- a/tests/cases/dotnet-format/auto-fix/files/.editorconfig +++ b/tests/cases/dotnet-format/auto-fix/files/.editorconfig @@ -1,2 +1,6 @@ -[root] +root = true + +[*.cs] end_of_line = lf +indent_style = space +indent_size = 4 diff --git a/tests/cases/dotnet-format/clean/files/.editorconfig b/tests/cases/dotnet-format/clean/files/.editorconfig index d8c812c..a9955e6 100644 --- a/tests/cases/dotnet-format/clean/files/.editorconfig +++ b/tests/cases/dotnet-format/clean/files/.editorconfig @@ -1,2 +1,6 @@ -[root] +root = true + +[*.cs] end_of_line = lf +indent_style = space +indent_size = 4 diff --git a/tests/cases/dotnet-format/failure/files/.editorconfig b/tests/cases/dotnet-format/failure/files/.editorconfig index d8c812c..a9955e6 100644 --- a/tests/cases/dotnet-format/failure/files/.editorconfig +++ b/tests/cases/dotnet-format/failure/files/.editorconfig @@ -1,2 +1,6 @@ -[root] +root = true + +[*.cs] end_of_line = lf +indent_style = space +indent_size = 4 From 7f065624a0e422425dd396774c0aa9007bc51e4d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 15:42:44 +0000 Subject: [PATCH 139/141] =?UTF-8?q?fix(windows):=20targeted=20path=20separ?= =?UTF-8?q?ator=20normalization=20=E2=80=94=20skip=20\s=20in=20dotnet=20ou?= =?UTF-8?q?tput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace global \→/ with a regex that only normalizes backslashes flanked by path-component characters (alphanumeric, ., /, >, -). This preserves dotnet's \s whitespace notation in diagnostic strings like Insert '\s\s\s\s' while still normalizing actual path separators like \script.sh.orig. --- tests/e2e.rs | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/tests/e2e.rs b/tests/e2e.rs index 0ad0f4e..8ad039d 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -493,23 +493,51 @@ fn normalize_output(s: String, repo_str: &str, repo_canonical: &str) -> String { #[cfg(windows)] let (s, canonical_cmp, repo_cmp) = { - let s = s.replace('\\', "/").replace("//?/", ""); - ( - s, - repo_canonical.replace('\\', "/"), - repo_str.replace('\\', "/"), - ) + // Strip the Windows verbatim UNC prefix \\?\ before substitution. + // Also strip //?/ which appears if the UNC prefix got mixed with forward slashes. + let s = s.replace(r"\\?\", "").replace("//?/", ""); + // Substitute using both backslash and forward-slash forms so paths from + // different tools (shfmt uses \, lychee uses /) are all collapsed. + (s, repo_canonical.to_string(), repo_str.to_string()) }; #[cfg(not(windows))] let (s, canonical_cmp, repo_cmp) = (s, repo_canonical.to_string(), repo_str.to_string()); + // Substitute using both the canonical form (e.g. long name on Windows, /private/... on + // macOS) and the raw form, in both backslash and forward-slash variants. + let sub = |s: String, pat: &str| -> String { + if pat.is_empty() { + return s; + } + #[cfg(windows)] + let s = s.replace(&pat.replace('\\', "/"), ""); + s.replace(pat, "") + }; let s = if canonical_cmp != repo_cmp { - s.replace(&canonical_cmp, "") + sub(s, &canonical_cmp) } else { s }; - let s = s.replace(&repo_cmp, ""); + let s = sub(s, &repo_cmp); + + // On Windows, normalize backslashes that are path separators — i.e. flanked by + // path-component characters — so snapshots written on Unix match Windows output. + // This intentionally skips backslashes inside quoted strings like dotnet's '\s\s\s\s'. #[cfg(windows)] - let s = s.replace("file:///", "file://"); + let s = { + use regex::Regex; + // Replace \ when preceded and followed by path-component chars (not ' or whitespace). + // Loop because a single pass only handles one \ per two-char window. + let re = Regex::new(r"([A-Za-z0-9_.>/\-])\\([A-Za-z0-9_.])").unwrap(); + let mut s = s; + loop { + let next = re.replace_all(&s, "$1/$2").into_owned(); + if next == s { + break; + } + s = next; + } + s.replace("file:///", "file://") + }; s } From ae147427f4f8e93457ce7f2e66434d72f7d10120 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 15:48:18 +0000 Subject: [PATCH 140/141] fix(windows): normalize \ only within -rooted paths Replaces the flanking-char regex with a simpler approach: match the entire path... sequence and replace all \ within it. This correctly handles multi-level paths (e.g. \src\lib.rs) while leaving dotnet's \s\s\s\s whitespace notation untouched since it never appears inside a -prefixed path. --- tests/e2e.rs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/e2e.rs b/tests/e2e.rs index 8ad039d..9d7d20c 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -520,23 +520,18 @@ fn normalize_output(s: String, repo_str: &str, repo_canonical: &str) -> String { }; let s = sub(s, &repo_cmp); - // On Windows, normalize backslashes that are path separators — i.e. flanked by - // path-component characters — so snapshots written on Unix match Windows output. - // This intentionally skips backslashes inside quoted strings like dotnet's '\s\s\s\s'. + // On Windows, normalize backslashes that are path separators within -rooted paths. + // Only touches backslashes inside sequences starting with , so tool-specific + // backslash notations (like dotnet's '\s\s\s\s') are left untouched. #[cfg(windows)] let s = { use regex::Regex; - // Replace \ when preceded and followed by path-component chars (not ' or whitespace). - // Loop because a single pass only handles one \ per two-char window. - let re = Regex::new(r"([A-Za-z0-9_.>/\-])\\([A-Za-z0-9_.])").unwrap(); - let mut s = s; - loop { - let next = re.replace_all(&s, "$1/$2").into_owned(); - if next == s { - break; - } - s = next; - } + // Match followed by path chars (alphanumeric, ., /, \, _, -) + // and replace all \ within that match with /. + let re = Regex::new(r"[A-Za-z0-9_./\\\-]+").unwrap(); + let s = re + .replace_all(&s, |caps: ®ex::Captures| caps[0].replace('\\', "/")) + .into_owned(); s.replace("file:///", "file://") }; s From e53c16f97994b6d3b97bf619356d1f1f3bdb2446 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 15:56:46 +0000 Subject: [PATCH 141/141] fix(windows): normalize all path backslashes, skip single-quoted content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace -scoped regex with a character-level walk that converts every \ to / except when inside single quotes. This handles: - Relative paths: .github\workflows → .github/workflows (actionlint, renovate) - Cargo/rustc paths: --> src\lib.rs → --> src/lib.rs - Lychee: [.\README.md] → [./README.md] And correctly preserves: - dotnet whitespace notation: Insert '\s\s\s\s' (inside single quotes) --- tests/e2e.rs | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/e2e.rs b/tests/e2e.rs index 9d7d20c..7eeba5e 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -520,19 +520,24 @@ fn normalize_output(s: String, repo_str: &str, repo_canonical: &str) -> String { }; let s = sub(s, &repo_cmp); - // On Windows, normalize backslashes that are path separators within -rooted paths. - // Only touches backslashes inside sequences starting with , so tool-specific - // backslash notations (like dotnet's '\s\s\s\s') are left untouched. + // On Windows, normalize backslash path separators to forward slashes. + // Skip content inside single quotes to preserve tool-specific notations + // like dotnet's whitespace descriptions: Insert '\s\s\s\s'. #[cfg(windows)] let s = { - use regex::Regex; - // Match followed by path chars (alphanumeric, ., /, \, _, -) - // and replace all \ within that match with /. - let re = Regex::new(r"[A-Za-z0-9_./\\\-]+").unwrap(); - let s = re - .replace_all(&s, |caps: ®ex::Captures| caps[0].replace('\\', "/")) - .into_owned(); - s.replace("file:///", "file://") + let mut out = String::with_capacity(s.len()); + let mut in_single_quote = false; + for ch in s.chars() { + match ch { + '\'' => { + in_single_quote = !in_single_quote; + out.push(ch); + } + '\\' if !in_single_quote => out.push('/'), + other => out.push(other), + } + } + out.replace("file:///", "file://") }; s }