Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
70257b5
chore: remove lint:pre-commit task — use FLINT_FAST_ONLY env var instead
zeitlinger Apr 10, 2026
2113c54
fix(windows): replace self-executing JAR heuristic with explicit regi…
zeitlinger Apr 10, 2026
43d69c2
fix: rename .java_jar() to .windows_java_jar(); fix missing field in …
zeitlinger Apr 10, 2026
7cc2343
feat: bail on obsolete mise.toml tool keys during flint run
zeitlinger Apr 10, 2026
b1051b0
feat: add flint update command to migrate obsolete mise.toml tool keys
zeitlinger Apr 10, 2026
473a5b1
style: apply cargo-fmt formatting
zeitlinger Apr 10, 2026
d8b2841
test: add e2e cases for version, hook install, and update commands
zeitlinger Apr 10, 2026
3ea05cd
docs: add 'Why not Husky?' section
zeitlinger Apr 10, 2026
8af68e7
fix(tests): add missing files/ dirs; fix TOML string escapes for newl…
zeitlinger Apr 10, 2026
f8360ad
chore: set version to 0.20.0, switch release-please to rust type
zeitlinger Apr 10, 2026
803f96e
chore: fix release-please manifest to 0.19.0 (next release is 0.20.0)
zeitlinger Apr 10, 2026
fed6ad2
ci: update mise to v2026.4.10, add Windows SHA256
zeitlinger Apr 13, 2026
d758de5
fix: correct mise SHA256 hashes, address Copilot review comments
zeitlinger Apr 13, 2026
2cfb54c
fix: gate versioned_bin substitution to Windows only
zeitlinger Apr 13, 2026
dc14d62
style: fix unused variable warning for _mise_tools on non-Windows
zeitlinger Apr 13, 2026
dea0bfd
fix: handle mise file-mode bash shims on Windows; bust stale tool cache
zeitlinger Apr 13, 2026
901fee8
fix: restore versioned_bin resolution on all platforms
zeitlinger Apr 13, 2026
66717bb
fix: detect .exe binaries in find_pe_binary on Windows
zeitlinger Apr 13, 2026
ac5952e
refactor: remove find_bash_shim; fix belongs in mise-action
zeitlinger Apr 13, 2026
ae5ff25
refactor: restore spawn_command to match main's structure
zeitlinger Apr 13, 2026
27fcd35
fix(registry): accurate github-backend comment, add ubi obsolete keys
zeitlinger Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/config/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.9.2"
".": "0.19.0"
}
2 changes: 1 addition & 1 deletion .github/config/release-please-config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "simple",
"release-type": "rust",
"pull-request-footer": "> [!IMPORTANT]\n> Close and reopen this PR to trigger CI checks.",
"packages": {
".": {
Expand Down
13 changes: 7 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ jobs:
matrix:
include:
- os: ubuntu-24.04
mise_version: v2026.4.1
mise_sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd
mise_version: v2026.4.10
mise_sha256: 84636e19a0e5001d7499f58ae5a868cec8f6ba4f52f9028680bb7cd802564229
- os: macos-15
mise_version: v2026.4.1
mise_sha256: c85b387148d478dec754ded31d01798e2f4e4e9448f75682dcc6bb7c16c9a4f5
mise_version: v2026.4.10
mise_sha256: e09f5ae83369d3c6d44572e9f2de0bf9454718e23ccb41a4138f8f88d28cbb31
- os: windows-2025
mise_version: v2026.4.1
mise_sha256: "" # not published for .exe — https://github.com/jdx/mise/pull/8997
mise_version: v2026.4.10
mise_sha256: 2df0ce5b1f42502a4895888a0fe7aae4cf6d1959d2dbb62f29204773cff3d457

permissions:
contents: read
Expand All @@ -41,6 +41,7 @@ jobs:
with:
version: ${{ matrix.mise_version }}
sha256: ${{ matrix.mise_sha256 }}
cache_key_prefix: mise-v2

- name: Install Rust lint components
run: rustup component add clippy rustfmt
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "flint"
version = "0.20.0-alpha.1"
version = "0.20.0"
edition = "2024"
Comment thread
zeitlinger marked this conversation as resolved.
description = "mise-native lint orchestrator"
license = "Apache-2.0"
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ run = "flint run --fix"

```text
flint run [OPTIONS] [LINTERS...]
flint update
flint linters
flint version
```
Expand Down Expand Up @@ -178,6 +179,15 @@ flint run shellcheck shfmt # run only shellcheck and shfmt
flint run --fix prettier # fix only prettier
```

`flint update` applies non-interactive migrations to `mise.toml` — replaces obsolete
tool keys with their modern equivalents, preserving the declared version. Run it when
`flint run` reports an obsolete key error:

```text
flint: obsolete tool key in mise.toml: "npm:markdownlint-cli" (replaced by "npm:markdownlint-cli2")
Run `flint update` to apply the migration automatically.
```

`flint linters` shows every check with its status:

```text
Expand Down Expand Up @@ -565,6 +575,13 @@ 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 Husky?

Husky manages git hooks for Node.js projects and requires `npm install` to activate.
Repos that aren't Node-first still need a `package.json` and a dev dependency just to
run hooks. `flint hook install` writes a single shell script directly to `.git/hooks/`
with no install step and no language runtime dependency.

### Why not Spotless (or other Maven formatter plugins)?

Spotless runs `google-java-format` as a Maven build phase, which means format
Expand Down
4 changes: 0 additions & 4 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@ run = "cargo run -q -- run"
description = "Auto-fix lint issues"
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 -- run --fix --fast-only"

[tasks.build]
description = "Build the project"
run = "cargo build"
Expand Down
79 changes: 79 additions & 0 deletions src/init/generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,38 @@ fn pin_tool_via_mise(project_root: &Path, key: &str) -> bool {
after != before && parse_tool_keys(&after).contains(key)
}

/// Replaces obsolete tool keys in mise.toml with their modern equivalents,
/// preserving the existing version value. Returns the list of replacements made
/// as `(old_key, new_key)` pairs. No-ops if the file doesn't exist or has no
/// obsolete keys.
pub fn replace_obsolete_keys(
project_root: &Path,
obsolete: &[(&str, &str)],
) -> Result<Vec<(String, String)>> {
let path = project_root.join("mise.toml");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())),
};
let mut doc: toml_edit::DocumentMut = content.parse().context("failed to parse mise.toml")?;

let mut replaced = vec![];
if let Some(tools) = doc.get_mut("tools").and_then(|t| t.as_table_mut()) {
for &(old_key, new_key) in obsolete {
if let Some(value) = tools.remove(old_key) {
tools.insert(new_key, value);
replaced.push((old_key.to_string(), new_key.to_string()));
}
}
}

if !replaced.is_empty() {
std::fs::write(&path, doc.to_string()).context("failed to write mise.toml")?;
}
Ok(replaced)
}

pub(super) fn apply_changes(
path: &Path,
current_content: &str,
Expand Down Expand Up @@ -631,6 +663,53 @@ pub(super) fn maybe_install_hook(project_root: &Path, yes: bool) -> Result<()> {
Ok(())
}

#[cfg(test)]
mod replace_obsolete_tests {
use super::replace_obsolete_keys;

#[test]
fn replaces_old_key_preserving_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mise.toml");
std::fs::write(&path, "[tools]\n\"npm:markdownlint-cli\" = \"0.39.0\"\n").unwrap();
let replaced = replace_obsolete_keys(
dir.path(),
&[("npm:markdownlint-cli", "npm:markdownlint-cli2")],
)
.unwrap();
assert_eq!(
replaced,
vec![(
"npm:markdownlint-cli".to_string(),
"npm:markdownlint-cli2".to_string()
)]
);
let result = std::fs::read_to_string(&path).unwrap();
assert!(
result.contains("npm:markdownlint-cli2"),
"new key written: {result}"
);
assert!(
!result.contains("\"npm:markdownlint-cli\""),
"old key removed: {result}"
);
assert!(result.contains("0.39.0"), "version preserved: {result}");
}

#[test]
fn noop_when_no_obsolete_keys() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mise.toml");
std::fs::write(&path, "[tools]\n\"npm:markdownlint-cli2\" = \"0.17.2\"\n").unwrap();
let replaced = replace_obsolete_keys(
dir.path(),
&[("npm:markdownlint-cli", "npm:markdownlint-cli2")],
)
.unwrap();
assert!(replaced.is_empty());
}
}

#[cfg(test)]
mod v1_removal_tests {
use super::remove_v1_tasks;
Expand Down
2 changes: 1 addition & 1 deletion src/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::path::Path;
use crate::registry::{Category, Check, builtin};

mod detection;
mod generation;
pub(crate) mod generation;
mod ui;

use detection::{
Expand Down
2 changes: 1 addition & 1 deletion src/linters/lychee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ async fn run_lychee_cmd(

let mut stdout = format!("==> {description}\n").into_bytes();

let result = super::spawn_command(&argv)
let result = super::spawn_command(&argv, false)
.current_dir(project_root)
.stdin(Stdio::null())
.output()
Expand Down
77 changes: 36 additions & 41 deletions src/linters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,80 +10,75 @@ pub mod renovate_deps;
/// (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 {
///
/// Self-executing JARs (e.g. ktlint) cannot run via cmd.exe at all.
/// When `windows_java_jar` is true the binary is resolved to its full path
/// and invoked as `java -jar <path>`.
pub fn spawn_command(argv: &[String], windows_java_jar: bool) -> tokio::process::Command {
#[cfg(windows)]
{
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)) => {
if windows_java_jar {
if let Some(path) = find_file_in_path(&argv[0]) {
let mut cmd = tokio::process::Command::new("java");
cmd.arg("-jar").arg(path).args(&argv[1..]);
return cmd;
}
None => {}
} else if let Some(path) = find_pe_binary(&argv[0]) {
let mut cmd = tokio::process::Command::new(path);
cmd.args(&argv[1..]);
return cmd;
}
let mut cmd = tokio::process::Command::new("cmd.exe");
cmd.arg("/C").args(argv);
cmd
}
#[cfg(not(windows))]
{
let _ = windows_java_jar;
let mut cmd = tokio::process::Command::new(&argv[0]);
cmd.args(&argv[1..]);
cmd
}
}

/// 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 and classify it:
/// - MZ magic → native PE, run directly
/// - `#!` magic + large file (>1 MB) → self-executing JAR (e.g. ktlint), run via `java -jar`
/// 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_executable_in_path(binary: &str) -> Option<WinBinary> {
fn find_pe_binary(binary: &str) -> Option<std::path::PathBuf> {
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;
}
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));
}
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
}

/// On Windows, return the full path of `binary` from PATH without inspecting
/// its contents. Used for self-executing JARs where the caller already knows
/// the invocation style (i.e. `windows_java_jar` is set in the registry).
#[cfg(windows)]
fn find_file_in_path(binary: &str) -> Option<std::path::PathBuf> {
let path_var = std::env::var("PATH").ok()?;
std::env::split_paths(&path_var).find_map(|dir| {
let candidate = dir.join(binary);
candidate.is_file().then_some(candidate)
})
}

/// Output from a single linter run.
pub struct LinterOutput {
pub ok: bool,
Expand Down
15 changes: 9 additions & 6 deletions src/linters/renovate_deps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,15 @@ async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result
env.push(("GITHUB_COM_TOKEN".into(), token));
}

let out = super::spawn_command(&[
"renovate".to_string(),
"--platform=local".to_string(),
"--require-config=ignored".to_string(),
"--dry-run=extract".to_string(),
])
let out = super::spawn_command(
&[
"renovate".to_string(),
"--platform=local".to_string(),
"--require-config=ignored".to_string(),
"--dry-run=extract".to_string(),
],
false,
)
.current_dir(project_root)
.envs(env)
.stdin(Stdio::null())
Expand Down
18 changes: 18 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ enum SubCommand {
Linters(LintersArgs),
/// Set up linters in mise.toml for this project.
Init(InitArgs),
/// Apply non-interactive migrations to mise.toml (replace obsolete tool keys).
Update,
/// Manage git hooks.
Hook(HookArgs),
/// Display the flint version.
Expand Down Expand Up @@ -140,6 +142,17 @@ async fn main() -> Result<()> {
SubCommand::Init(args) => {
init::run(&project_root, args.profile, args.yes)?;
}
SubCommand::Update => {
let replaced =
init::generation::replace_obsolete_keys(&project_root, registry::OBSOLETE_KEYS)?;
if replaced.is_empty() {
println!("flint: mise.toml is up to date");
} else {
for (old, new) in &replaced {
println!(" replaced {old:?} → {new:?}");
}
}
}
SubCommand::Hook(args) => match args.command {
HookCommand::Install => hook::install(&project_root)?,
},
Expand Down Expand Up @@ -182,6 +195,11 @@ 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);
if let Some((old, new)) = registry::find_obsolete_key(&mise_tools) {
eprintln!("flint: obsolete tool key in mise.toml: {old:?} (replaced by {new:?})");
eprintln!(" Run `flint update` to apply the migration automatically.");
std::process::exit(1);
}
let active: Vec<&registry::Check> = {
let mut out = vec![];
for c in checks {
Expand Down
Loading
Loading