diff --git a/.github/agents/knowledge/design.md b/.github/agents/knowledge/design.md index e5568ad..f4b180a 100644 --- a/.github/agents/knowledge/design.md +++ b/.github/agents/knowledge/design.md @@ -8,21 +8,15 @@ 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 + `ruff-format`, `biome-format`, `rumdl`, `yaml-lint`). Implemented 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 - 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 } - ``` +3. **Rust-native docs/config stack**: Markdown is owned by + `rumdl`, YAML by `yaml-lint`, and JS/TS/JSON by `biome`. + This keeps ownership boundaries explicit and avoids the + old markdownlint/prettier overlap on `*.md`. 4. **Fix mode runs serially**: `runner.rs` runs checks in parallel in check mode, but serially in fix mode to @@ -43,6 +37,6 @@ `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, + generated file that should never be linted by `rumdl`, 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 index ae139fb..ff90b75 100644 --- a/.github/agents/knowledge/linters.md +++ b/.github/agents/knowledge/linters.md @@ -31,7 +31,8 @@ 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-only` | +| `.slow()` | Mark as comprehensive-only and skipped by `--fast-only` | +| `.adaptive()` | Mark as comprehensive-only and relevance-gated in `--fast-only` | | `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | ## Config File Injection (`.linter_config`) @@ -43,11 +44,11 @@ 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 +// Example: rumdl accepts --config +Check::file("rumdl", "rumdl check {FILE}", &["*.md"]) + .fix("rumdl check --fix {FILE}") + .linter_config(".rumdl.toml", "--config"), +// → rumdl --config /repo/.github/config/.rumdl.toml check ``` **When NOT to use it:** diff --git a/.github/config/super-linter.env b/.github/config/super-linter.env deleted file mode 100644 index fcaff22..0000000 --- a/.github/config/super-linter.env +++ /dev/null @@ -1,32 +0,0 @@ -FILTER_REGEX_EXCLUDE=(.*renovate-tracked-deps\.json|CHANGELOG\.md) -IGNORE_GITIGNORED_FILES=true -LOG_LEVEL=ERROR - -# Allow-list: only enable linters relevant for this repository -VALIDATE_BASH=true -VALIDATE_BIOME_FORMAT=true -VALIDATE_EDITORCONFIG=true -VALIDATE_ENV=true -VALIDATE_GITHUB_ACTIONS=true -VALIDATE_JSONC=true -VALIDATE_MARKDOWN=true -VALIDATE_MARKDOWN_PRETTIER=true -VALIDATE_NATURAL_LANGUAGE=true -VALIDATE_PYTHON_RUFF=true -VALIDATE_PYTHON_RUFF_FORMAT=true -VALIDATE_SHELL_SHFMT=true -VALIDATE_SPELL_CODESPELL=true -VALIDATE_YAML_PRETTIER=true - -# Enable auto-fix for relevant linters -FIX_BIOME_FORMAT=true -FIX_ENV=true -FIX_JSONC=true -FIX_MARKDOWN=true -FIX_MARKDOWN_PRETTIER=true -FIX_NATURAL_LANGUAGE=true -FIX_PYTHON_RUFF=true -FIX_PYTHON_RUFF_FORMAT=true -FIX_SHELL_SHFMT=true -FIX_SPELL_CODESPELL=true -FIX_YAML_PRETTIER=true diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 40be658..dfeb993 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -22,7 +22,9 @@ "mise.toml": { "mise": [ "actionlint", + "biome", "cargo:xmloxide", + "cargo:yaml-lint", "dotnet", "editorconfig-checker", "github:google/google-java-format", @@ -33,12 +35,10 @@ "hadolint", "lychee", "node", - "npm:@biomejs/biome", - "npm:markdownlint-cli2", - "npm:prettier", "npm:renovate", "pipx:codespell", "pipx:ruff", + "rumdl", "rust", "shfmt" ] diff --git a/.gitignore b/.gitignore index 549fd40..17b3560 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea .mise.super-linter-*.toml /target +.cache/ \ No newline at end of file diff --git a/README.md b/README.md index a56f92f..5b7dc54 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ Add the linting tools your project needs alongside the `flint` binary itself: shellcheck = "v0.11.0" "github:mvdan/sh" = "v3.13.1" # activates shfmt actionlint = "1.7.10" -"npm:markdownlint-cli2" = "0.47.0" -"npm:prettier" = "3.5.0" +rumdl = "0.1.78" +"cargo:yaml-lint" = "0.1.0" +biome = "2.4.12" rust = "1.87.0" # activates cargo-fmt + cargo-clippy go = "1.24.0" # activates gofmt lychee = "0.18.0" # activates links check @@ -176,14 +177,14 @@ Click a name in the table below for details. See the | [`ktlint`](docs/linters.md#ktlint) | Lint and format Kotlin code | yes | | [`license-header`](docs/linters.md#license-header) | Check source files have the required license header | — | | [`lychee`](docs/linters.md#lychee) | Check for broken links | — | -| [`markdownlint-cli2`](docs/linters.md#markdownlint-cli2) | Lint Markdown files for style and consistency | yes | -| [`prettier`](docs/linters.md#prettier) | Format Markdown and YAML files | yes | | [`renovate-deps`](docs/linters.md#renovate-deps) | Verify Renovate dependency snapshot is up to date | yes | | [`ruff`](docs/linters.md#ruff) | Lint Python code | yes | | [`ruff-format`](docs/linters.md#ruff-format) | Format Python code | yes | +| [`rumdl`](docs/linters.md#rumdl) | Lint Markdown files for style and consistency | yes | | [`shellcheck`](docs/linters.md#shellcheck) | Lint shell scripts for common mistakes | — | | [`shfmt`](docs/linters.md#shfmt) | Format shell scripts | yes | | [`xmllint`](docs/linters.md#xmllint) | Validate XML files are well-formed | — | +| [`yaml-lint`](docs/linters.md#yaml-lint) | Lint YAML files for style and consistency | yes | diff --git a/docs/cli.md b/docs/cli.md index 2564c75..a447063 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -39,7 +39,7 @@ Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_FAST_ONL expressed as the exact command to run: ```text -flint: 2 checks failed — flint run --fix prettier cargo-fmt | review: shellcheck +flint: 2 checks failed — flint run --fix rumdl cargo-fmt | review: shellcheck ``` **`--fix` output** — fixes what's fixable, then prints the full output of @@ -61,7 +61,7 @@ Pass one or more linter names to run only those: ```bash flint run shellcheck shfmt # run only shellcheck and shfmt -flint run --fix prettier # fix only prettier +flint run --fix rumdl # fix only Markdown issues ``` ## `flint update` @@ -71,7 +71,7 @@ tool keys with their modern equivalents, preserving the declared version. Run it `flint run` reports an obsolete key error: ```text -flint: obsolete tool key in mise.toml: "npm:markdownlint-cli" (replaced by "npm:markdownlint-cli2") +flint: obsolete tool key in mise.toml: "github:mvdan/sh" (replaced by "shfmt") Run `flint update` to apply the migration automatically. ``` diff --git a/docs/linters.md b/docs/linters.md index 4df3a77..598d699 100644 --- a/docs/linters.md +++ b/docs/linters.md @@ -7,9 +7,9 @@ Every supported check, its config file (when applicable), and its scope. The config injection for `biome` and `biome-format` is not yet implemented. + - ## `actionlint` | | | @@ -176,28 +176,6 @@ 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` | | | @@ -207,6 +185,7 @@ check_all_local = true | Binary | `renovate` | | Scope | [special](#scopes) | | Patterns | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | +| Run policy | adaptive — runs in `--fast-only` only when relevant | 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]`. @@ -241,6 +220,17 @@ exclude_managers = ["github-actions", "github-runners"] | Patterns | `*.py` | | Config | `ruff.toml` | +## `rumdl` + +| | | +| ----------- | --------------------------------------------- | +| Description | Lint Markdown files for style and consistency | +| Fix | yes | +| Binary | `rumdl` | +| Scope | [file](#scopes) | +| Patterns | `*.md` | +| Config | `.rumdl.toml` | + ## `shellcheck` | | | @@ -272,26 +262,47 @@ exclude_managers = ["github-actions", "github-runners"] | Scope | [files](#scopes) | | Patterns | `*.xml` | +## `yaml-lint` + +| | | +| ----------- | ----------------------------------------- | +| Description | Lint YAML files for style and consistency | +| Fix | yes | +| Binary | `yaml-lint` | +| Scope | [files](#scopes) | +| Patterns | `*.yml *.yaml` | +| Config | `.yamllint.yml` | + + ## Scopes - `file` — invoked once per matched file -- `files` — invoked once with all matched files as args; only changed files are passed +- `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, but runs on - the whole project when it does run. `golangci-lint` is the exception — it uses + (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. -Checks tagged slow in the registry are skipped by `--fast-only`. Use -`--fast-only` for local/pre-push feedback and the full set in CI. (No -builtin is currently marked slow, but the mechanism is preserved.) +Checks use one of three run policies: + +- `fast` — always runs, including in `--fast-only` +- `slow` — skipped by `--fast-only` +- `adaptive` — runs in `--fast-only` only when the changed files are relevant + +Use `--fast-only` for local/pre-push feedback and the full set in CI. -**`editorconfig-checker` defers to formatters**: `editorconfig-checker` runs on all files, but +**`editorconfig-checker` defers to formatters**: `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 `editorconfig-checker` — those formatters +formatter. When `cargo-fmt`, `ruff-format`, `biome-format`, `rumdl`, or +`yaml-lint` +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, `editorconfig-checker` checks those files itself. diff --git a/docs/why.md b/docs/why.md index 05fd6ce..dc8b4ed 100644 --- a/docs/why.md +++ b/docs/why.md @@ -72,4 +72,4 @@ use everywhere" promise of mise. Container startup also adds latency to every ru 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`). + Pass specific linter names to limit which fixers run (`flint run --fix rumdl shfmt`). diff --git a/mise.toml b/mise.toml index 0f4f2ba..0782226 100644 --- a/mise.toml +++ b/mise.toml @@ -2,16 +2,15 @@ FLINT_CONFIG_DIR = ".github/config" [tools] +biome = "2.4.12" lychee = "0.22.0" -node = "24.15.0" "npm:renovate" = "43.129.0" "github:koalaman/shellcheck" = "v0.11.0" shfmt = "v3.13.1" actionlint = "1.7.10" editorconfig-checker = "v3.6.1" -"npm:markdownlint-cli2" = "0.22.0" -"npm:prettier" = "3.8.3" -"npm:@biomejs/biome" = "2.4.12" +"cargo:yaml-lint" = "0.1.0" +"rumdl" = "0.1.78" "pipx:ruff" = "0.15.11" "pipx:codespell" = "2.4.2" rust = { version = "1.95.0", components = "clippy,rustfmt" } @@ -24,6 +23,7 @@ dotnet = "10.0.201" "cargo:xmloxide" = "0.4.1" golangci-lint = "2.11.4" "github:google/google-java-format" = "1.35.0" +node = "24.15.0" [tasks."setup:update-super-linter-versions"] description = "Generate super-linter version mapping from the super-linter repo" diff --git a/src/files.rs b/src/files.rs index 9d786fe..bf22980 100644 --- a/src/files.rs +++ b/src/files.rs @@ -171,5 +171,24 @@ fn filter_names( .filter(|name| !BUILTIN_EXCLUDES.contains(&name.as_str())) .filter(|name| !exclude.is_match(name)) .map(|name| project_root.join(name)) + .filter(|path| path.exists()) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter_names_skips_deleted_worktree_paths() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("present.md"), "ok\n").unwrap(); + let names = ["missing.md".to_string(), "present.md".to_string()] + .into_iter() + .collect(); + + let files = filter_names(tmp.path(), &GlobSetBuilder::new().build().unwrap(), names); + + assert_eq!(files, vec![tmp.path().join("present.md")]); + } +} diff --git a/src/init/generation.rs b/src/init/generation.rs index 464877c..77d8b87 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -601,25 +601,21 @@ pub(super) fn generate_flint_toml( Ok(true) } -/// Generates `.markdownlint.yml` in the project root when markdownlint-cli2 is -/// being set up alongside editorconfig-checker. -/// Returns `true` if the file was written (or an older variant was replaced). -/// -/// Target format is `.markdownlint.yml` for uniformity across consumer repos. -/// Older variants (`.markdownlint.{json,jsonc,yaml}`, -/// `.markdownlint-cli2.{jsonc,yaml,yml,cjs,mjs}`) are replaced. -pub(super) fn generate_markdownlint_config(project_root: &Path) -> Result { +/// Generates `.rumdl.toml` in the project root when rumdl is being set up. +/// Returns `true` if the file was written (or an older markdownlint variant was replaced). +pub(super) fn generate_rumdl_config(project_root: &Path) -> Result { const LEGACY_CONFIG_NAMES: &[&str] = &[ ".markdownlint.json", ".markdownlint.jsonc", ".markdownlint.yaml", + ".markdownlint.yml", ".markdownlint-cli2.jsonc", ".markdownlint-cli2.yaml", ".markdownlint-cli2.yml", ".markdownlint-cli2.cjs", ".markdownlint-cli2.mjs", ]; - let target = project_root.join(".markdownlint.yml"); + let target = project_root.join(".rumdl.toml"); if target.exists() { return Ok(false); } @@ -627,14 +623,126 @@ pub(super) fn generate_markdownlint_config(project_root: &Path) -> Result let legacy = project_root.join(name); if legacy.exists() { std::fs::remove_file(&legacy)?; - println!( - " removed {} (replaced by .markdownlint.yml)", - legacy.display() - ); + println!(" removed {} (replaced by .rumdl.toml)", legacy.display()); + } + } + let content = concat!( + "[MD013]\n", + "enabled = true\n", + "line-length = 120\n", + "code-blocks = false\n", + "tables = false\n", + ); + std::fs::write(&target, content)?; + println!(" wrote {}", target.display()); + Ok(true) +} + +/// Updates an existing editorconfig-checker config so Markdown is excluded when +/// rumdl owns Markdown formatting and line-length enforcement. +/// +/// Checks both the project root and `config_dir`, updating the first config +/// file that exists. Returns `true` when a config was changed. +pub(super) fn exclude_markdown_from_editorconfig_checker( + project_root: &Path, + config_dir: &Path, +) -> Result { + const MARKDOWN_EXCLUDE: &str = ".*\\.md$"; + let candidates = [ + config_dir.join(".editorconfig-checker.json"), + project_root.join(".editorconfig-checker.json"), + ]; + + for path in candidates { + if !path.exists() { + continue; + } + + let content = std::fs::read_to_string(&path)?; + let mut value: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display()))?; + let Some(obj) = value.as_object_mut() else { + anyhow::bail!("{} is not a JSON object", path.display()); + }; + + let key = if obj.contains_key("Exclude") { + "Exclude" + } else if obj.contains_key("exclude") { + "exclude" + } else { + "Exclude" + }; + + let entry = obj + .entry(key.to_string()) + .or_insert_with(|| serde_json::Value::Array(vec![])); + let Some(items) = entry.as_array_mut() else { + anyhow::bail!("{} field in {} is not an array", key, path.display()); + }; + + if items + .iter() + .any(|v| v.as_str().is_some_and(|s| s == MARKDOWN_EXCLUDE)) + { + return Ok(false); } + + items.push(serde_json::Value::String(MARKDOWN_EXCLUDE.to_string())); + let updated = serde_json::to_string_pretty(&value)? + "\n"; + std::fs::write(&path, updated)?; + return Ok(true); + } + + Ok(false) +} + +/// Generates `.yamllint.yml` in the project root when yaml-lint is being set up. +pub(super) fn generate_yamllint_config(project_root: &Path) -> Result { + let target = project_root.join(".yamllint.yml"); + if target.exists() { + return Ok(false); + } + let content = concat!( + "extends: relaxed\n", + "\n", + "rules:\n", + " document-start: disable\n", + " line-length:\n", + " max: 120\n", + " indentation:\n", + " spaces: 2\n", + ); + std::fs::write(&target, content)?; + println!(" wrote {}", target.display()); + Ok(true) +} + +/// Generates `biome.json` in the project root when biome is being set up and no +/// existing biome config is present. +/// +/// Flint writes explicit space indentation to avoid Biome's default tab +/// formatting surprising consumers during rollout. +pub(super) fn generate_biome_config(project_root: &Path) -> Result { + const EXISTING_CONFIG_NAMES: &[&str] = &["biome.json", "biome.jsonc"]; + if EXISTING_CONFIG_NAMES + .iter() + .map(|name| project_root.join(name)) + .any(|path| path.exists()) + { + return Ok(false); } - let content = - "# Line length is enforced by editorconfig-checker via .editorconfig\nMD013: false\n"; + + let target = project_root.join("biome.json"); + let content = [ + "{", + " \"formatter\": {", + " \"indentStyle\": \"space\",", + " \"indentWidth\": 2", + " }", + "}", + "", + ] + .join("\n"); std::fs::write(&target, content)?; println!(" wrote {}", target.display()); Ok(true) @@ -850,7 +958,7 @@ mod node_prereq_tests { #[test] fn needs_node_when_npm_key_without_node() { - let content = "[tools]\n\"npm:prettier\" = \"3.8.1\"\n"; + let content = "[tools]\n\"npm:renovate\" = \"43.129.0\"\n"; assert!(needs_node_for_npm(content)); } @@ -862,7 +970,7 @@ mod node_prereq_tests { #[test] fn no_node_needed_when_node_already_declared() { - let content = "[tools]\nnode = \"20\"\n\"npm:prettier\" = \"3.8.1\"\n"; + let content = "[tools]\nnode = \"20\"\n\"npm:renovate\" = \"43.129.0\"\n"; assert!(!needs_node_for_npm(content)); } @@ -876,7 +984,7 @@ mod node_prereq_tests { fn noop_when_node_already_present() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mise.toml"); - let original = "[tools]\nnode = \"20\"\n\"npm:prettier\" = \"3.8.1\"\n"; + let original = "[tools]\nnode = \"20\"\n\"npm:renovate\" = \"43.129.0\"\n"; std::fs::write(&path, original).unwrap(); let added = ensure_node_for_npm(dir.path()).unwrap(); assert!(!added); @@ -903,41 +1011,27 @@ mod replace_obsolete_tests { 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(); + std::fs::write(&path, "[tools]\n\"github:mvdan/sh\" = \"v3.13.1\"\n").unwrap(); + let replaced = replace_obsolete_keys(dir.path(), &[("github:mvdan/sh", "shfmt")]).unwrap(); assert_eq!( replaced, - vec![( - "npm:markdownlint-cli".to_string(), - "npm:markdownlint-cli2".to_string() - )] + vec![("github:mvdan/sh".to_string(), "shfmt".to_string())] ); let result = std::fs::read_to_string(&path).unwrap(); + assert!(result.contains("shfmt"), "new key written: {result}"); assert!( - result.contains("npm:markdownlint-cli2"), - "new key written: {result}" - ); - assert!( - !result.contains("\"npm:markdownlint-cli\""), + !result.contains("\"github:mvdan/sh\""), "old key removed: {result}" ); - assert!(result.contains("0.39.0"), "version preserved: {result}"); + assert!(result.contains("v3.13.1"), "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(); + std::fs::write(&path, "[tools]\nshfmt = \"v3.13.1\"\n").unwrap(); + let replaced = replace_obsolete_keys(dir.path(), &[("github:mvdan/sh", "shfmt")]).unwrap(); assert!(replaced.is_empty()); } } diff --git a/src/init/mod.rs b/src/init/mod.rs index 2f5a33e..a190511 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, ensure_flint_self_pin, - ensure_node_for_npm, flint_preset, generate_flint_toml, generate_lint_workflow, - generate_markdownlint_config, get_existing_config_dir, has_slow_selected, maybe_install_hook, + ensure_node_for_npm, exclude_markdown_from_editorconfig_checker, flint_preset, + generate_biome_config, generate_flint_toml, generate_lint_workflow, generate_rumdl_config, + generate_yamllint_config, get_existing_config_dir, has_slow_selected, maybe_install_hook, normalize_tools_section, patch_renovate_extends, prompt_config_dir, remove_v1_tasks, }; use ui::{interactive_select_linters, select_categories_arrow}; @@ -26,7 +27,7 @@ use ui::{interactive_select_linters, select_categories_arrow}; pub enum Profile { /// Primary language linters only (ruff, cargo-clippy, golangci-lint, …). Lang, - /// Lang + supplementary checks + fast general tools (shellcheck, prettier, codespell, …). + /// Lang + supplementary checks + fast general tools (shellcheck, rumdl, codespell, …). Default, /// Default + slow linters (renovate-deps). Comprehensive, @@ -140,13 +141,16 @@ Add and stage your source files before running init so the detection is accurate let registry = builtin(); let mut present_patterns = detect_present_patterns(project_root, ®istry)?; - // If init will generate `.github/workflows/lint.yml`, treat the workflow - // patterns as present so actionlint gets selected in the same run. - // Without this, init would be non-idempotent: the second run would see the - // newly-generated workflow and add actionlint then. + // If init will generate `.github/workflows/lint.yml`, treat both the workflow- + // specific patterns and generic YAML patterns as present so actionlint and + // yaml-lint get selected in the same run. Without this, init would be + // non-idempotent: the second run would see the newly-generated workflow and + // add extra linters then. if !project_root.join(".github/workflows/lint.yml").exists() { present_patterns.insert(".github/workflows/*.yml".to_string()); present_patterns.insert(".github/workflows/*.yaml".to_string()); + present_patterns.insert("*.yml".to_string()); + present_patterns.insert("*.yaml".to_string()); } // Step 1: determine which categories set the initial pre-selection. @@ -193,7 +197,7 @@ 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). + // Detect obsolete tool keys (e.g. github:mvdan/sh → shfmt). // 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 { @@ -237,17 +241,23 @@ 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| { + let has_rumdl = groups.iter().any(|g| { g.checks .iter() .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.name == "markdownlint-cli2") + .any(|(c, &sel)| sel && c.name == "rumdl") }); - let has_editorconfig_checker = groups.iter().any(|g| { + let has_yaml_lint = groups.iter().any(|g| { g.checks .iter() .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.name == "editorconfig-checker") + .any(|(c, &sel)| sel && c.name == "yaml-lint") + }); + let has_biome = groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && (c.name == "biome" || c.name == "biome-format")) }); // Prompt for the flint config dir (skipped if already set in mise.toml or --yes). @@ -297,8 +307,26 @@ Add and stage your source files before running init so the detection is accurate let has_rust = final_add.iter().any(|(k, _)| k == "rust") || (current_tool_keys.contains("rust") && !final_remove.iter().any(|k| k == "rust")); let workflow_generated = generate_lint_workflow(project_root, &base_branch, has_rust)?; - let markdownlint_generated = if has_markdownlint && has_editorconfig_checker { - generate_markdownlint_config(project_root)? + let rumdl_generated = if has_rumdl { + generate_rumdl_config(project_root)? + } else { + false + }; + let editorconfig_markdown_excluded = if has_rumdl { + exclude_markdown_from_editorconfig_checker(project_root, &config_dir_path)? + } else { + false + }; + if editorconfig_markdown_excluded { + println!(" patched editorconfig-checker config — Markdown is now owned by rumdl"); + } + let yamllint_generated = if has_yaml_lint { + generate_yamllint_config(project_root)? + } else { + false + }; + let biome_generated = if has_biome { + generate_biome_config(project_root)? } else { false }; @@ -324,7 +352,10 @@ Add and stage your source files before running init so the detection is accurate && !meta_changed && !toml_generated && !workflow_generated - && !markdownlint_generated + && !rumdl_generated + && !editorconfig_markdown_excluded + && !yamllint_generated + && !biome_generated && !renovate_patched { println!("No changes to apply."); @@ -413,19 +444,19 @@ mod tests { 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("github:mvdan/sh".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"); + assert_eq!(found[0].0, "github:mvdan/sh"); + assert_eq!(found[0].1, "shfmt"); } #[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("rumdl".to_string()); keys.insert("shellcheck".to_string()); let found = detect_obsolete_keys(&keys); assert!(found.is_empty()); @@ -474,9 +505,8 @@ mod tests { fn normalize_tools_section_sorts_and_inserts_linters_header() { let content = r#"[tools] lychee = "0.22.0" -node = "24.0.0" actionlint = "1.7.0" -"npm:prettier" = "3.8.0" +rumdl = "0.1.0" rust = { version = "1.95.0", components = "clippy,rustfmt" } "#; let tmp = tempfile::NamedTempFile::new().unwrap(); @@ -485,18 +515,17 @@ rust = { version = "1.95.0", components = "clippy,rustfmt" } assert!(changed); let result = std::fs::read_to_string(tmp.path()).unwrap(); let header_pos = result.find("# Linters").expect("header present"); - let node_pos = result.find("node =").expect("node present"); + let biome_pos = result.find("biome =").unwrap_or(usize::MAX); let rust_pos = result.find("rust =").expect("rust present"); let actionlint_pos = result.find("actionlint =").expect("actionlint present"); let lychee_pos = result.find("lychee =").expect("lychee present"); - let prettier_pos = result.find("\"npm:prettier\"").expect("prettier present"); - // rust is the only true toolchain here; node lives with linters because - // it's only pinned as a prereq for npm:* backend tools. + let rumdl_pos = result.find("rumdl =").expect("rumdl present"); assert!(rust_pos < header_pos, "toolchains above header"); - assert!(node_pos > header_pos, "node below header (linter prereq)"); assert!(actionlint_pos > header_pos, "linters below header"); assert!( - actionlint_pos < lychee_pos && lychee_pos < node_pos && node_pos < prettier_pos, + actionlint_pos < lychee_pos + && lychee_pos < rumdl_pos + && (biome_pos == usize::MAX || rumdl_pos < biome_pos), "linters sorted alphabetically" ); @@ -532,12 +561,12 @@ rust = { version = "1.95.0", components = "clippy,rustfmt" } let content = r#" [tools] shellcheck = "v0.11.0" -"npm:prettier" = "3.8.1" +rumdl = "0.1.0" rust = { version = "1.0", components = "clippy" } "#; let keys = parse_tool_keys(content); assert!(keys.contains("shellcheck")); - assert!(keys.contains("npm:prettier")); + assert!(keys.contains("rumdl")); assert!(keys.contains("rust")); assert!(!keys.contains("nonexistent")); } @@ -634,37 +663,117 @@ rust = { version = "1.0", components = "clippy" } } #[test] - fn generate_markdownlint_config_writes_file() { - use generation::generate_markdownlint_config; + fn generate_rumdl_config_writes_file() { + use generation::generate_rumdl_config; let tmp = tempfile::TempDir::new().unwrap(); - let written = generate_markdownlint_config(tmp.path()).unwrap(); + let written = generate_rumdl_config(tmp.path()).unwrap(); assert!(written); - let content = std::fs::read_to_string(tmp.path().join(".markdownlint.yml")).unwrap(); - assert!(content.contains("MD013: false")); - assert!(content.contains("editorconfig-checker")); + let content = std::fs::read_to_string(tmp.path().join(".rumdl.toml")).unwrap(); + assert!(content.contains("line-length = 120")); + assert!(content.contains("code-blocks = false")); + assert!(!content.contains("[global]")); } #[test] - fn generate_markdownlint_config_skips_when_target_exists() { - use generation::generate_markdownlint_config; + fn generate_rumdl_config_skips_when_target_exists() { + use generation::generate_rumdl_config; let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join(".markdownlint.yml"), "existing").unwrap(); - let written = generate_markdownlint_config(tmp.path()).unwrap(); + std::fs::write(tmp.path().join(".rumdl.toml"), "existing").unwrap(); + let written = generate_rumdl_config(tmp.path()).unwrap(); assert!(!written); - let content = std::fs::read_to_string(tmp.path().join(".markdownlint.yml")).unwrap(); + let content = std::fs::read_to_string(tmp.path().join(".rumdl.toml")).unwrap(); assert_eq!(content, "existing"); } #[test] - fn generate_markdownlint_config_replaces_legacy_json() { - use generation::generate_markdownlint_config; + fn generate_rumdl_config_replaces_legacy_json() { + use generation::generate_rumdl_config; let tmp = tempfile::TempDir::new().unwrap(); std::fs::write(tmp.path().join(".markdownlint.json"), r#"{"MD013":false}"#).unwrap(); - let written = generate_markdownlint_config(tmp.path()).unwrap(); + let written = generate_rumdl_config(tmp.path()).unwrap(); assert!(written); assert!(!tmp.path().join(".markdownlint.json").exists()); - let content = std::fs::read_to_string(tmp.path().join(".markdownlint.yml")).unwrap(); - assert!(content.contains("MD013: false")); + let content = std::fs::read_to_string(tmp.path().join(".rumdl.toml")).unwrap(); + assert!(content.contains("[MD013]")); + } + + #[test] + fn exclude_markdown_from_editorconfig_checker_updates_config_dir_file() { + use generation::exclude_markdown_from_editorconfig_checker; + let tmp = tempfile::TempDir::new().unwrap(); + let config_dir = tmp.path().join(".github/config"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join(".editorconfig-checker.json"), + "{\n \"Exclude\": [\".*\\\\.java$\"]\n}\n", + ) + .unwrap(); + + let changed = exclude_markdown_from_editorconfig_checker(tmp.path(), &config_dir).unwrap(); + assert!(changed); + let content = + std::fs::read_to_string(config_dir.join(".editorconfig-checker.json")).unwrap(); + assert!(content.contains(".*\\\\.java$")); + assert!(content.contains(".*\\\\.md$")); + } + + #[test] + fn exclude_markdown_from_editorconfig_checker_is_idempotent() { + use generation::exclude_markdown_from_editorconfig_checker; + let tmp = tempfile::TempDir::new().unwrap(); + let config_dir = tmp.path().join(".github/config"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join(".editorconfig-checker.json"), + "{\n \"Exclude\": [\".*\\\\.md$\"]\n}\n", + ) + .unwrap(); + + let changed = exclude_markdown_from_editorconfig_checker(tmp.path(), &config_dir).unwrap(); + assert!(!changed); + } + + #[test] + fn generate_yamllint_config_writes_file() { + use generation::generate_yamllint_config; + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_yamllint_config(tmp.path()).unwrap(); + assert!(written); + let content = std::fs::read_to_string(tmp.path().join(".yamllint.yml")).unwrap(); + assert!(content.contains("extends: relaxed")); + assert!(content.contains("document-start: disable")); + } + + #[test] + fn generate_biome_config_writes_file() { + use generation::generate_biome_config; + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_biome_config(tmp.path()).unwrap(); + assert!(written); + let content = std::fs::read_to_string(tmp.path().join("biome.json")).unwrap(); + assert!(content.contains("\"indentStyle\": \"space\"")); + assert!(content.contains("\"indentWidth\": 2")); + } + + #[test] + fn generate_biome_config_skips_existing_json() { + use generation::generate_biome_config; + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("biome.json"), "existing").unwrap(); + let written = generate_biome_config(tmp.path()).unwrap(); + assert!(!written); + let content = std::fs::read_to_string(tmp.path().join("biome.json")).unwrap(); + assert_eq!(content, "existing"); + } + + #[test] + fn generate_biome_config_skips_existing_jsonc() { + use generation::generate_biome_config; + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("biome.jsonc"), "existing").unwrap(); + let written = generate_biome_config(tmp.path()).unwrap(); + assert!(!written); + assert!(!tmp.path().join("biome.json").exists()); } #[test] diff --git a/src/init/ui.rs b/src/init/ui.rs index c0d0753..b53d3ce 100644 --- a/src/init/ui.rs +++ b/src/init/ui.rs @@ -8,8 +8,6 @@ use crossterm::{ terminal::{self, ClearType}, }; -use crate::registry::Category; - use super::{CategoryItem, LinterGroup}; fn run_arrow_selector( @@ -218,15 +216,15 @@ fn print_linter_table( "[ ]" }; let cursor_mark = if flat_idx == cursor { ">" } else { " " }; - let speed = if check.category == Category::Slow { - "slow" - } else { - "fast" + let speed = match check.run_policy { + crate::registry::RunPolicy::Fast => "fast", + crate::registry::RunPolicy::Slow => "slow", + crate::registry::RunPolicy::Adaptive => "adaptive", }; let patterns = check.patterns.join(" "); write!( stdout, - " {} {} {: Li } } +pub(crate) fn is_relevant(file_list: &FileList, project_root: &Path) -> bool { + if file_list.full { + return true; + } + + let changed: HashSet = file_list + .files + .iter() + .filter_map(|path| { + path.strip_prefix(project_root) + .ok() + .map(|rel| rel.to_string_lossy().into_owned()) + }) + .collect(); + + if changed.is_empty() { + return false; + } + + if changed + .iter() + .any(|path| RENOVATE_CONFIG_PATTERNS.contains(&path.as_str())) + { + return true; + } + + let committed_path = COMMITTED_PATHS + .iter() + .map(|path| project_root.join(path)) + .find(|path| path.exists()); + + let Some(committed_path) = committed_path else { + return false; + }; + + let committed_rel = display_path(project_root, &committed_path); + if changed.contains(&committed_rel) { + return true; + } + + let committed: DepMap = match std::fs::read_to_string(&committed_path) + .ok() + .and_then(|contents| serde_json::from_str(&contents).ok()) + { + Some(committed) => committed, + None => return true, + }; + + committed.keys().any(|path| changed.contains(path)) +} + async fn run_inner( cfg: &RenovateDepsConfig, fix: bool, @@ -464,4 +516,101 @@ mod tests { PathBuf::from(".github/renovate-tracked-deps.json") ); } + + fn file_list(paths: &[&str], full: bool) -> FileList { + FileList { + files: paths.iter().map(PathBuf::from).collect(), + merge_base: Some("base".to_string()), + full, + } + } + + #[test] + fn relevant_when_full_mode() { + let dir = tempfile::tempdir().unwrap(); + assert!(is_relevant(&file_list(&[], true), dir.path())); + } + + #[test] + fn relevant_when_renovate_config_changed() { + let dir = tempfile::tempdir().unwrap(); + assert!(is_relevant( + &file_list( + &[dir.path().join(".github/renovate.json5").to_str().unwrap()], + false + ), + dir.path() + )); + } + + #[test] + fn relevant_when_snapshot_changed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + std::fs::write( + dir.path().join(".github/renovate-tracked-deps.json"), + "{}\n", + ) + .unwrap(); + + assert!(is_relevant( + &file_list( + &[dir + .path() + .join(".github/renovate-tracked-deps.json") + .to_str() + .unwrap()], + false + ), + dir.path() + )); + } + + #[test] + fn relevant_when_tracked_manifest_changed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + write_snapshot( + &dir.path().join(".github/renovate-tracked-deps.json"), + &dep_map(&[("package.json", &[("npm", &["express"])])]), + ) + .unwrap(); + + assert!(is_relevant( + &file_list(&[dir.path().join("package.json").to_str().unwrap()], false), + dir.path() + )); + } + + #[test] + fn not_relevant_for_untracked_change() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + write_snapshot( + &dir.path().join(".github/renovate-tracked-deps.json"), + &dep_map(&[("package.json", &[("npm", &["express"])])]), + ) + .unwrap(); + + assert!(!is_relevant( + &file_list(&[dir.path().join("README.md").to_str().unwrap()], false), + dir.path() + )); + } + + #[test] + fn relevant_when_snapshot_is_unparseable() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + std::fs::write( + dir.path().join(".github/renovate-tracked-deps.json"), + "{not json}\n", + ) + .unwrap(); + + assert!(is_relevant( + &file_list(&[dir.path().join("README.md").to_str().unwrap()], false), + dir.path() + )); + } } diff --git a/src/main.rs b/src/main.rs index ef99883..e849b42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ mod runner; use anyhow::Result; use clap::{Args, Parser, Subcommand}; -use registry::{CheckKind, Scope}; +use registry::{CheckKind, FixBehavior, RunPolicy, Scope, SpecialKind}; use runner::{CheckResult, RunOptions}; use std::collections::HashMap; @@ -195,20 +195,48 @@ async fn run( registry.iter().collect() }; + let file_list = files::changed( + project_root, + &cfg, + args.full, + args.new_from_rev.as_deref(), + args.to_ref.as_deref(), + )?; + // Discover which checks are declared in the consuming repo's mise.toml, and apply - // --fast-only filter (skipped when linters are named explicitly). - // mise guarantees declared tools are on PATH, so no PATH check needed. + // --fast-only policy (skipped when linters are named explicitly, relevance-gated for + // adaptive checks). 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); } + if let Some((old, hint)) = registry::find_unsupported_key(&mise_tools) { + eprintln!("flint: unsupported legacy lint tool in mise.toml: {old:?}"); + eprintln!(" Migration required: {hint}."); + eprintln!(" Run `flint init` to upgrade the lint toolchain."); + std::process::exit(1); + } 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 { + let include = if explicit || !args.fast_only { + true + } else { + match c.run_policy { + RunPolicy::Fast => true, + RunPolicy::Slow => false, + RunPolicy::Adaptive => match &c.kind { + CheckKind::Special(SpecialKind::RenovateDeps) => { + linters::renovate_deps::is_relevant(&file_list, project_root) + } + _ => true, + }, + } + }; + if include { out.push(c); } } else if explicit { @@ -231,53 +259,27 @@ async fn run( } } - let file_list = files::changed( - project_root, - &cfg, - args.full, - args.new_from_rev.as_deref(), - args.to_ref.as_deref(), - )?; - 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. - let check_results = runner::run( - &active, - &file_list, - RunOptions { - fix: false, - verbose: false, - short: true, - time: false, - }, - project_root, - &cfg, - config_dir, - ) - .await?; - - let (fixable, reviewable): (Vec, Vec) = check_results - .into_iter() - .filter(|r| !r.ok) - .partition(|r| is_fixable(&r.name, &active)); + let (single_pass_fixable, legacy_checks): (Vec<®istry::Check>, Vec<®istry::Check>) = + active + .iter() + .copied() + .partition(|c| supports_single_pass_fix(c)); + let mut reviewable: Vec = vec![]; let mut fixed = vec![]; let mut fix_failed = vec![]; let mut post_fix_failed = vec![]; - 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)) - .copied() - .collect(); - let fix_results = runner::run( - &to_fix, + + if !legacy_checks.is_empty() { + let check_results = runner::run( + &legacy_checks, + &active, &file_list, RunOptions { - fix: true, + fix: false, verbose: false, short: true, time: false, @@ -287,29 +289,60 @@ async fn run( config_dir, ) .await?; + + let (fixable, legacy_reviewable): (Vec, Vec) = check_results + .into_iter() + .filter(|r| !r.ok) + .partition(|r| is_fixable(&r.name, &legacy_checks)); + reviewable.extend(legacy_reviewable); + let mut to_verify = vec![]; - for r in fix_results { - if r.ok { - if let Some(check) = active.iter().find(|c| c.name == r.name) { - if check.fix_behavior() == registry::FixBehavior::PartialNeedsVerify { - to_verify.push(r.name); - } else { - fixed.push(r.name); + if !fixable.is_empty() { + let fixable_names: Vec<&str> = fixable.iter().map(|r| r.name.as_str()).collect(); + let to_fix: Vec<®istry::Check> = legacy_checks + .iter() + .filter(|c| fixable_names.contains(&c.name)) + .copied() + .collect(); + let fix_results = runner::run( + &to_fix, + &active, + &file_list, + RunOptions { + fix: true, + verbose: false, + short: true, + time: false, + }, + project_root, + &cfg, + config_dir, + ) + .await?; + for r in fix_results { + if r.ok { + if let Some(check) = legacy_checks.iter().find(|c| c.name == r.name) { + if check.fix_behavior() == registry::FixBehavior::PartialNeedsVerify { + to_verify.push(r.name); + } else { + fixed.push(r.name); + } } + } else { + fix_failed.push(r.name); } - } else { - fix_failed.push(r.name); } } if !to_verify.is_empty() { let verify_names: Vec<&str> = to_verify.iter().map(String::as_str).collect(); - let to_verify_checks: Vec<®istry::Check> = active + let to_verify_checks: Vec<®istry::Check> = legacy_checks .iter() .filter(|c| verify_names.contains(&c.name)) .copied() .collect(); let verify_results = runner::run( &to_verify_checks, + &active, &file_list, RunOptions { fix: false, @@ -332,6 +365,33 @@ async fn run( } } + if !single_pass_fixable.is_empty() { + let fix_results = runner::run( + &single_pass_fixable, + &active, + &file_list, + RunOptions { + fix: true, + verbose: false, + short: true, + time: false, + }, + project_root, + &cfg, + config_dir, + ) + .await?; + for r in fix_results { + if r.ok && r.changed { + fixed.push(r.name); + } else if !r.ok { + post_fix_failed.push(r); + } else { + // Already clean: no summary line needed. + } + } + } + // 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.iter().chain(post_fix_failed.iter()) { @@ -371,6 +431,7 @@ async fn run( } let results = runner::run( + &active, &active, &file_list, RunOptions { @@ -446,16 +507,37 @@ 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.category == registry::Category::Slow, + "run_policy": run_policy_label(check.run_policy), + "slow": check.run_policy == RunPolicy::Slow, "scope": scope, "config_file": config_file, }) } +fn run_policy_label(run_policy: RunPolicy) -> &'static str { + match run_policy { + RunPolicy::Fast => "fast", + RunPolicy::Slow => "slow", + RunPolicy::Adaptive => "adaptive", + } +} + fn is_fixable(name: &str, active: &[®istry::Check]) -> bool { active.iter().any(|c| c.name == name && c.has_fix()) } +fn supports_single_pass_fix(check: ®istry::Check) -> bool { + check.has_fix() + && check.fix_behavior() == FixBehavior::Definitive + && matches!( + check.kind, + CheckKind::Template { + scope: Scope::File | Scope::Files, + .. + } + ) +} + fn print_linters( registry: &[registry::Check], mise_tools: &HashMap, @@ -482,7 +564,7 @@ fn print_linters( .max(11); println!( - "{: Check { .style() } -fn check_markdownlint_cli2() -> Check { - Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) - .fix("markdownlint-cli2 --fix {FILE}") - .linter_config(".markdownlint.jsonc", "--config") +fn check_rumdl() -> Check { + Check::file("rumdl", "rumdl check {FILE}", &["*.md"]) + .fix("rumdl check --fix {FILE}") + .linter_config(".rumdl.toml", "--config") + .formatter() .desc("Lint Markdown files for style and consistency") - .mise_tool("npm:markdownlint-cli2") + .mise_tool("rumdl") } -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") - .mise_tool("npm:prettier") +fn check_yaml_lint() -> Check { + Check::files("yaml-lint", "yaml-lint {FILES}", &["*.yml", "*.yaml"]) + .fix("yaml-lint --fix {FILES}") + .linter_config(".yamllint.yml", "-c") + .formatter() + .desc("Lint YAML files for style and consistency") + .mise_tool("cargo:yaml-lint") } fn check_actionlint() -> Check { @@ -141,7 +137,7 @@ fn check_biome() -> Check { ) .fix("biome check --fix {FILE}") .desc("Lint JS/TS/JSON files") - .mise_tool("npm:@biomejs/biome") + .mise_tool("biome") .lang() } @@ -155,7 +151,7 @@ fn check_biome_format() -> Check { .fix("biome format --write {FILE}") .formatter() .desc("Format JS/TS/JSON files") - .mise_tool("npm:@biomejs/biome") + .mise_tool("biome") .lang() } @@ -265,6 +261,7 @@ fn check_lychee() -> Check { fn check_renovate_deps() -> Check { Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) + .adaptive() .mise_tool("npm:renovate") .patterns(RENOVATE_CONFIG_PATTERNS) .desc("Verify Renovate dependency snapshot is up to date") @@ -298,8 +295,8 @@ pub fn builtin() -> Vec { vec![ check_shellcheck(), check_shfmt(), - check_markdownlint_cli2(), - check_prettier(), + check_rumdl(), + check_yaml_lint(), check_actionlint(), check_hadolint(), check_xmllint(), diff --git a/src/registry/mise.rs b/src/registry/mise.rs index 39122c3..c614c04 100644 --- a/src/registry/mise.rs +++ b/src/registry/mise.rs @@ -8,8 +8,7 @@ use super::types::Check; /// /// 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) +/// - `"cargo:yaml-lint"` → also registers `"yaml-lint"` /// - `"github:google/google-java-format"` → also registers `"google-java-format"` /// /// The original key is always preserved; aliases only fill in missing entries. @@ -38,7 +37,7 @@ pub fn read_mise_tools(project_root: &Path) -> HashMap { } } } - // Add normalized aliases: strip the backend prefix (e.g. "npm:", "pipx:", "ubi:") + // Add normalized aliases: strip the backend prefix (e.g. "cargo:", "pipx:", "github:") // and take the last path component (e.g. "@biomejs/biome" → "biome"). // Aliases never override an explicitly declared entry. let aliases: Vec<(String, String)> = tools @@ -62,8 +61,8 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool return true; } let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); - // 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. + // When mise_tool_name is set (e.g. "cargo:yaml-lint"), also accept + // the bare bin_name ("yaml-lint") 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))); diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 631dcd3..c1a7c1e 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -6,18 +6,16 @@ mod types; pub use checks::builtin; pub use mise::{check_active, read_mise_tools}; -pub use obsolete::{OBSOLETE_KEYS, find_obsolete_key}; +pub use obsolete::{OBSOLETE_KEYS, find_obsolete_key, find_unsupported_key}; pub use resolve::binary_on_path; -pub use types::{Category, Check, CheckKind, FixBehavior, Scope, SpecialKind}; +pub use types::{Category, Check, CheckKind, FixBehavior, RunPolicy, Scope, SpecialKind}; /// Returns the set of `mise.toml` tool keys that name language runtimes/SDKs /// (e.g. `rust`, `go`, `dotnet`). Derived from registry checks marked /// `.toolchain()`. /// /// `flint init` uses this set to keep runtime keys above the `# Linters` -/// header in `mise.toml`. `node` is deliberately excluded — it's pinned by -/// `ensure_node_for_npm` only as a prereq for `npm:` backend linters, so it -/// belongs in the linters group. +/// header in `mise.toml`. pub fn toolchain_keys() -> std::collections::HashSet<&'static str> { builtin() .into_iter() diff --git a/src/registry/obsolete.rs b/src/registry/obsolete.rs index 9176984..b554bdf 100644 --- a/src/registry/obsolete.rs +++ b/src/registry/obsolete.rs @@ -4,9 +4,6 @@ use std::collections::HashMap; /// 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"), // ubi: was deprecated in mise; the github: backend is the modern replacement. // Repos that adopted flint before this change may still have ubi: keys. ( @@ -17,6 +14,21 @@ pub const OBSOLETE_KEYS: &[(&str, &str)] = &[ // github:mvdan/sh is superseded by bare shfmt; mise resolves it via aqua:mvdan/sh, // and the aqua registry now ships Windows support for shfmt. ("github:mvdan/sh", "shfmt"), + // npm-installed biome is superseded by the standalone biome binary. + ("npm:@biomejs/biome", "biome"), +]; + +/// Mise tool keys that flint no longer supports and cannot auto-rewrite 1:1. +/// These require a docs/config migration rather than a backend swap. +pub const UNSUPPORTED_KEYS: &[(&str, &str)] = &[ + ( + "npm:markdownlint-cli2", + "replace with rumdl and remove markdownlint-era config", + ), + ( + "npm:prettier", + "replace with rumdl and yaml-lint, then remove prettier from the lint toolchain", + ), ]; /// Checks whether any obsolete tool keys are present in `mise_tools`. @@ -29,3 +41,14 @@ pub fn find_obsolete_key( .find(|(old, _)| mise_tools.contains_key(*old)) .copied() } + +/// Checks whether any unsupported legacy tool keys are present in `mise_tools`. +/// Returns the first violation found as `(unsupported_key, migration_hint)`. +pub fn find_unsupported_key( + mise_tools: &HashMap, +) -> Option<(&'static str, &'static str)> { + UNSUPPORTED_KEYS + .iter() + .find(|(old, _)| mise_tools.contains_key(*old)) + .copied() +} diff --git a/src/registry/tests.rs b/src/registry/tests.rs index 45b066d..c6b9b00 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -3,21 +3,10 @@ use std::path::Path; use super::*; -#[test] -fn find_obsolete_key_detects_superseded_keys() { - let mut tools = HashMap::new(); - tools.insert("npm:markdownlint-cli".to_string(), "0.39.0".to_string()); - let result = find_obsolete_key(&tools); - assert_eq!( - result, - Some(("npm:markdownlint-cli", "npm:markdownlint-cli2")) - ); -} - #[test] fn find_obsolete_key_returns_none_for_clean_tools() { let mut tools = HashMap::new(); - tools.insert("npm:markdownlint-cli2".to_string(), "0.17.2".to_string()); + tools.insert("shfmt".to_string(), "3.13.1".to_string()); assert_eq!(find_obsolete_key(&tools), None); } @@ -31,6 +20,42 @@ fn find_obsolete_key_detects_legacy_shfmt_backend() { ); } +#[test] +fn find_obsolete_key_detects_legacy_biome_backend() { + let mut tools = HashMap::new(); + tools.insert("npm:@biomejs/biome".to_string(), "2.4.12".to_string()); + assert_eq!( + find_obsolete_key(&tools), + Some(("npm:@biomejs/biome", "biome")) + ); +} + +#[test] +fn find_unsupported_key_detects_markdownlint_stack() { + let mut tools = HashMap::new(); + tools.insert("npm:markdownlint-cli2".to_string(), "0.18.1".to_string()); + assert_eq!( + find_unsupported_key(&tools), + Some(( + "npm:markdownlint-cli2", + "replace with rumdl and remove markdownlint-era config", + )) + ); +} + +#[test] +fn find_unsupported_key_detects_prettier_stack() { + let mut tools = HashMap::new(); + tools.insert("npm:prettier".to_string(), "3.6.2".to_string()); + assert_eq!( + find_unsupported_key(&tools), + Some(( + "npm:prettier", + "replace with rumdl and yaml-lint, then remove prettier from the lint toolchain", + )) + ); +} + /// 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. @@ -115,7 +140,7 @@ fn readme_linter_table_in_sync() { return; } - // Normalize both sides: strip blank lines that prettier adds around + // Normalize both sides: strip blank lines that markdown formatters add around // headings, tables, and code blocks. This keeps the comparison stable // even when docs contain multi-paragraph content with blank lines. let actual_summary = extract_section(&readme, README_TABLE_START, README_TABLE_END); @@ -305,8 +330,17 @@ fn detail_rows(check: &Check) -> Vec<(&'static str, String)> { } } - if check.category == Category::Slow { - rows.push(("Slow", "yes — skipped by `--fast-only`".to_string())); + match check.run_policy { + crate::registry::RunPolicy::Fast => {} + crate::registry::RunPolicy::Slow => { + rows.push(("Run policy", "slow — skipped by `--fast-only`".to_string())); + } + crate::registry::RunPolicy::Adaptive => { + rows.push(( + "Run policy", + "adaptive — runs in `--fast-only` only when relevant".to_string(), + )); + } } rows diff --git a/src/registry/types.rs b/src/registry/types.rs index 09f79bd..c9b83f8 100644 --- a/src/registry/types.rs +++ b/src/registry/types.rs @@ -9,7 +9,7 @@ pub enum Scope { Project, } -/// Which init profile (and `--fast-only` behaviour) a check belongs to. +/// Which init profile a check belongs to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum Category { /// Primary programming language linter/formatter (Rust, Python, Go, …) — all init profiles. @@ -19,10 +19,22 @@ pub enum Category { /// General fast tool (not language-specific) — `default` and `comprehensive` init profiles. #[default] Default, - /// Slow tool — `comprehensive` init profile only; skipped when `--fast-only` is passed. + /// Comprehensive-only tool (e.g. expensive or niche checks). Slow, } +/// How a check participates in `--fast-only`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RunPolicy { + /// Always runs, including in `--fast-only`. + #[default] + Fast, + /// Skipped in `--fast-only` unless explicitly named. + Slow, + /// Runs in `--fast-only` only when the changed files are relevant to the check. + Adaptive, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpecialKind { Links, @@ -73,6 +85,7 @@ pub struct Check { /// dedicated formatter already owns. pub excludes_if_active: &'static [&'static str], pub category: Category, + pub run_policy: RunPolicy, /// 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)>, @@ -187,6 +200,7 @@ impl Check { defers_to_formatters: false, activate_unconditionally: false, category: Category::Default, + run_policy: RunPolicy::Fast, toolchain: None, kind: CheckKind::Template { check_cmd, @@ -216,6 +230,7 @@ impl Check { defers_to_formatters: false, activate_unconditionally: false, category: Category::Default, + run_policy: RunPolicy::Fast, toolchain: None, windows_java_jar: false, fix_behavior: FixBehavior::Definitive, @@ -288,9 +303,17 @@ impl Check { self } - /// Mark as slow — skipped when `--fast-only` is passed; `comprehensive` init profile only. + /// Mark as comprehensive-only in `flint init`, and skipped by `--fast-only`. pub fn slow(mut self) -> Self { self.category = Category::Slow; + self.run_policy = RunPolicy::Slow; + self + } + + /// Mark as comprehensive-only in `flint init`, and relevance-gated in `--fast-only`. + pub fn adaptive(mut self) -> Self { + self.category = Category::Slow; + self.run_policy = RunPolicy::Adaptive; self } diff --git a/src/runner.rs b/src/runner.rs index b7adc25..f028cff 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -19,6 +20,7 @@ pub struct RunOptions { pub struct CheckResult { pub name: String, pub ok: bool, + pub changed: bool, pub stdout: Vec, pub stderr: Vec, pub duration: Duration, @@ -30,6 +32,7 @@ enum PreparedCheck { Invocations { name: String, argv_list: Vec>, + tracked_files: Vec, windows_java_jar: bool, }, Links { @@ -62,26 +65,43 @@ 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 { + let (out, changed): (LinterOutput, bool) = match self { Self::Invocations { argv_list, + tracked_files, windows_java_jar, .. - } => run_invocations(&name, &argv_list, windows_java_jar, project_root).await, + } => { + let before = if fix && !tracked_files.is_empty() { + Some(fingerprint_files(&tracked_files)) + } else { + None + }; + let out = run_invocations(&name, &argv_list, windows_java_jar, project_root).await; + let changed = + before.is_some_and(|before| before != fingerprint_files(&tracked_files)); + (out, changed) + } 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, + } => ( + lychee::run(&cfg, &file_list, project_root, &config_dir).await, + false, + ), + Self::RenovateDeps { cfg, .. } => { + (renovate_deps::run(&cfg, fix, project_root).await, false) + } Self::LicenseHeader { cfg, files, .. } => { - license_header::run(&cfg, project_root, &files).await + (license_header::run(&cfg, project_root, &files).await, false) } }; CheckResult { name, ok: out.ok, + changed, stdout: out.stdout, stderr: out.stderr, duration: start.elapsed(), @@ -91,6 +111,7 @@ impl PreparedCheck { pub async fn run( checks: &[&Check], + active_checks: &[&Check], file_list: &FileList, opts: RunOptions, project_root: &Path, @@ -105,7 +126,17 @@ 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, + active_checks, + cfg, + config_dir, + ) + }) .collect(); if fix { @@ -162,6 +193,7 @@ fn prepare( let name = check.name.to_string(); match &check.kind { CheckKind::Template { .. } => { + let tracked_files = tracked_files(check, file_list, project_root, active_checks); let argv_list = build_invocations( check, file_list, @@ -176,6 +208,7 @@ fn prepare( Some(PreparedCheck::Invocations { name, argv_list, + tracked_files, windows_java_jar: check.windows_java_jar, }) } @@ -216,6 +249,36 @@ fn prepare( } } +fn tracked_files( + check: &Check, + file_list: &FileList, + project_root: &Path, + active_checks: &[&Check], +) -> Vec { + let CheckKind::Template { scope, .. } = &check.kind else { + return vec![]; + }; + if !matches!(scope, Scope::File | Scope::Files) { + return vec![]; + } + + 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(); + if check.defers_to_formatters { + for active in active_checks.iter().filter(|c| c.is_formatter) { + excludes.extend(active.patterns.iter().copied()); + } + } + + match_files(&file_list.files, check.patterns, &excludes, project_root) + .into_iter() + .cloned() + .collect() +} + /// Returns the list of argv vectors to execute for a check. fn build_invocations( check: &Check, @@ -429,6 +492,17 @@ Install it with: `rustup component add {component}`\n" stderr.extend_from_slice(note.as_bytes()); } +fn fingerprint_files(files: &[PathBuf]) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for path in files { + path.hash(&mut hasher); + if let Ok(bytes) = std::fs::read(path) { + bytes.hash(&mut hasher); + } + } + hasher.finish() +} + fn missing_rust_component(name: &str, stderr: &[u8]) -> Option<&'static str> { let stderr = String::from_utf8_lossy(stderr); match name { @@ -583,7 +657,7 @@ fn shell_words(cmd: String) -> Vec { mod tests { use super::*; use crate::files::FileList; - use crate::registry::{Category, Check, CheckKind, Scope}; + use crate::registry::{Category, Check, CheckKind, RunPolicy, Scope}; use std::path::PathBuf; #[test] @@ -652,6 +726,7 @@ mod tests { defers_to_formatters: false, activate_unconditionally: false, category: Category::Default, + run_policy: RunPolicy::Fast, toolchain: None, windows_java_jar: false, fix_behavior: crate::registry::FixBehavior::Definitive, diff --git a/tests/cases/general/init-idempotent/files/.markdownlint.yml b/tests/cases/general/init-idempotent/files/.markdownlint.yml deleted file mode 100644 index 0ba7306..0000000 --- a/tests/cases/general/init-idempotent/files/.markdownlint.yml +++ /dev/null @@ -1,2 +0,0 @@ -# Line length is enforced by editorconfig-checker via .editorconfig -MD013: false diff --git a/tests/cases/general/init-idempotent/files/.rumdl.toml b/tests/cases/general/init-idempotent/files/.rumdl.toml new file mode 100644 index 0000000..066c5b4 --- /dev/null +++ b/tests/cases/general/init-idempotent/files/.rumdl.toml @@ -0,0 +1,5 @@ +[MD013] +enabled = true +line-length = 120 +code-blocks = false +tables = false diff --git a/tests/cases/general/init-idempotent/files/.yamllint.yml b/tests/cases/general/init-idempotent/files/.yamllint.yml new file mode 100644 index 0000000..bf0a002 --- /dev/null +++ b/tests/cases/general/init-idempotent/files/.yamllint.yml @@ -0,0 +1,8 @@ +extends: relaxed + +rules: + document-start: disable + line-length: + max: 120 + indentation: + spaces: 2 diff --git a/tests/cases/general/init-idempotent/files/biome.json b/tests/cases/general/init-idempotent/files/biome.json new file mode 100644 index 0000000..4e68cbc --- /dev/null +++ b/tests/cases/general/init-idempotent/files/biome.json @@ -0,0 +1,6 @@ +{ + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + } +} diff --git a/tests/cases/general/init-idempotent/files/mise.toml b/tests/cases/general/init-idempotent/files/mise.toml index e6ac1af..2340148 100644 --- a/tests/cases/general/init-idempotent/files/mise.toml +++ b/tests/cases/general/init-idempotent/files/mise.toml @@ -3,13 +3,13 @@ rust = { version = "1.0.0", components = "clippy,rustfmt" } # Linters actionlint = "1.0.0" +biome = "1.0.0" +"cargo:yaml-lint" = "1.0.0" editorconfig-checker = "1.0.0" "github:grafana/flint" = "1.0.0" lychee = "1.0.0" -node = "1.0.0" -"npm:markdownlint-cli2" = "1.0.0" -"npm:prettier" = "1.0.0" "pipx:codespell" = "1.0.0" +rumdl = "1.0.0" [env] FLINT_CONFIG_DIR = ".github/config" diff --git a/tests/cases/general/init-rust/test.toml b/tests/cases/general/init-rust/test.toml index 536eb5f..6a3f32f 100644 --- a/tests/cases/general/init-rust/test.toml +++ b/tests/cases/general/init-rust/test.toml @@ -4,13 +4,14 @@ exit = 0 stdout = ''' 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. - added node (LTS) — required by npm: backend tools pinned flint itself — reproducible lint runs across contributors normalized [tools] in /mise.toml wrote /.github/config/flint.toml wrote /.github/workflows/lint.yml - removed /.markdownlint.json (replaced by .markdownlint.yml) - wrote /.markdownlint.yml + removed /.markdownlint.json (replaced by .rumdl.toml) + wrote /.rumdl.toml + wrote /.yamllint.yml + wrote /biome.json installed pre-commit hook (.git/hooks/pre-commit) Done. Run `mise install` to install the new tools. ''' @@ -25,9 +26,30 @@ Done. Run `mise install` to install the new tools. # exclude = "CHANGELOG\\.md" # exclude_paths = [] ''' -".markdownlint.yml" = ''' -# Line length is enforced by editorconfig-checker via .editorconfig -MD013: false +".rumdl.toml" = ''' +[MD013] +enabled = true +line-length = 120 +code-blocks = false +tables = false +''' +".yamllint.yml" = ''' +extends: relaxed + +rules: + document-start: disable + line-length: + max: 120 + indentation: + spaces: 2 +''' +"biome.json" = ''' +{ + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + } +} ''' "mise.toml" = ''' [tools] @@ -35,14 +57,13 @@ rust = { version = "1.0.0", components = "clippy,rustfmt" } # Linters actionlint = "1.0.0" +biome = "1.0.0" +"cargo:yaml-lint" = "1.0.0" editorconfig-checker = "1.0.0" "github:grafana/flint" = "1.0.0" lychee = "1.0.0" -node = "1.0.0" -"npm:@biomejs/biome" = "1.0.0" -"npm:markdownlint-cli2" = "1.0.0" -"npm:prettier" = "1.0.0" "pipx:codespell" = "1.0.0" +rumdl = "1.0.0" [env] FLINT_CONFIG_DIR = ".github/config" diff --git a/tests/cases/general/list/files/mise.toml b/tests/cases/general/list/files/mise.toml index 841f867..3ad889b 100644 --- a/tests/cases/general/list/files/mise.toml +++ b/tests/cases/general/list/files/mise.toml @@ -1,13 +1,13 @@ [tools] +biome = "2.3.14" +"cargo:yaml-lint" = "0.1.0" lychee = "0.22.0" "npm:renovate" = "43.92.1" shellcheck = "v0.11.0" shfmt = "v3.13.1" 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" +rumdl = "0.1.78" rust = { version = "1.94.1", components = "clippy,rustfmt" } diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml index e149c88..df2da9b 100644 --- a/tests/cases/general/list/test.toml +++ b/tests/cases/general/list/test.toml @@ -2,32 +2,33 @@ args = "linters" exit = 0 stdout = ''' -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 -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 -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 +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 +rumdl rumdl active fast yes Lint Markdown files for style and consistency *.md +yaml-lint yaml-lint active fast yes Lint YAML files for style and consistency *.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 +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 adaptive 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 = ''' #!/bin/sh @@ -47,10 +48,10 @@ ec = ''' lychee = ''' #!/bin/sh ''' -markdownlint-cli2 = ''' +rumdl = ''' #!/bin/sh ''' -prettier = ''' +yaml-lint = ''' #!/bin/sh ''' renovate = ''' diff --git a/tests/cases/general/update-adds-node/files/mise.toml b/tests/cases/general/update-adds-node/files/mise.toml index 8180e70..860ef8e 100644 --- a/tests/cases/general/update-adds-node/files/mise.toml +++ b/tests/cases/general/update-adds-node/files/mise.toml @@ -1,2 +1,2 @@ [tools] -"npm:prettier" = "3.8.1" +"npm:renovate" = "43.129.0" diff --git a/tests/cases/general/update-no-op/files/mise.toml b/tests/cases/general/update-no-op/files/mise.toml index 6a10e0e..2249768 100644 --- a/tests/cases/general/update-no-op/files/mise.toml +++ b/tests/cases/general/update-no-op/files/mise.toml @@ -1,4 +1,3 @@ [tools] -node = "24.14.1" -"npm:markdownlint-cli2" = "0.17.2" +rumdl = "0.1.78" shellcheck = "v0.11.0" diff --git a/tests/cases/general/update-obsolete-key/files/mise.toml b/tests/cases/general/update-obsolete-key/files/mise.toml index eb50dea..c32b3d7 100644 --- a/tests/cases/general/update-obsolete-key/files/mise.toml +++ b/tests/cases/general/update-obsolete-key/files/mise.toml @@ -1,4 +1,3 @@ [tools] -node = "24.14.1" -"npm:markdownlint-cli" = "0.39.0" +"github:mvdan/sh" = "v3.13.1" shellcheck = "v0.11.0" diff --git a/tests/cases/general/update-obsolete-key/test.toml b/tests/cases/general/update-obsolete-key/test.toml index 7a21664..d2fcf43 100644 --- a/tests/cases/general/update-obsolete-key/test.toml +++ b/tests/cases/general/update-obsolete-key/test.toml @@ -1,12 +1,11 @@ [expected] args = "update" exit = 0 -stdout = " replaced \"npm:markdownlint-cli\" → \"npm:markdownlint-cli2\"\n" +stdout = " replaced \"github:mvdan/sh\" → \"shfmt\"\n" [expected.files] "mise.toml" = ''' [tools] -node = "24.14.1" shellcheck = "v0.11.0" -"npm:markdownlint-cli2" = "0.39.0" +shfmt = "v3.13.1" ''' diff --git a/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml b/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml deleted file mode 100644 index f8b2754..0000000 --- a/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/auto-fix/test.toml b/tests/cases/markdownlint-cli2/auto-fix/test.toml deleted file mode 100644 index 9438367..0000000 --- a/tests/cases/markdownlint-cli2/auto-fix/test.toml +++ /dev/null @@ -1,15 +0,0 @@ -[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/mise.toml b/tests/cases/markdownlint-cli2/clean/files/mise.toml deleted file mode 100644 index f8b2754..0000000 --- a/tests/cases/markdownlint-cli2/clean/files/mise.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/clean/test.toml b/tests/cases/markdownlint-cli2/clean/test.toml deleted file mode 100644 index 2bc16c2..0000000 --- a/tests/cases/markdownlint-cli2/clean/test.toml +++ /dev/null @@ -1,3 +0,0 @@ -[expected] -args = "run --full markdownlint-cli2" -exit = 0 diff --git a/tests/cases/markdownlint-cli2/failure/files/mise.toml b/tests/cases/markdownlint-cli2/failure/files/mise.toml deleted file mode 100644 index f8b2754..0000000 --- a/tests/cases/markdownlint-cli2/failure/files/mise.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/failure/test.toml b/tests/cases/markdownlint-cli2/failure/test.toml deleted file mode 100644 index 3701802..0000000 --- a/tests/cases/markdownlint-cli2/failure/test.toml +++ /dev/null @@ -1,14 +0,0 @@ -[expected] -args = "run --full markdownlint-cli2" -exit = 1 -stderr = ''' -[markdownlint-cli2] -markdownlint-cli2 -Finding: /README.md -Linting: 1 file(s) -Summary: 1 error(s) -README.md:3:26 error 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/mise.toml b/tests/cases/prettier/auto-fix/files/mise.toml deleted file mode 100644 index 789d69a..0000000 --- a/tests/cases/prettier/auto-fix/files/mise.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -prettier = "latest" diff --git a/tests/cases/prettier/auto-fix/test.toml b/tests/cases/prettier/auto-fix/test.toml deleted file mode 100644 index 3690de9..0000000 --- a/tests/cases/prettier/auto-fix/test.toml +++ /dev/null @@ -1,13 +0,0 @@ -[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/mise.toml b/tests/cases/prettier/clean/files/mise.toml deleted file mode 100644 index 789d69a..0000000 --- a/tests/cases/prettier/clean/files/mise.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -prettier = "latest" diff --git a/tests/cases/prettier/clean/test.toml b/tests/cases/prettier/clean/test.toml deleted file mode 100644 index ea50f65..0000000 --- a/tests/cases/prettier/clean/test.toml +++ /dev/null @@ -1,3 +0,0 @@ -[expected] -args = "run --full prettier" -exit = 0 diff --git a/tests/cases/prettier/failure/files/mise.toml b/tests/cases/prettier/failure/files/mise.toml deleted file mode 100644 index 789d69a..0000000 --- a/tests/cases/prettier/failure/files/mise.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tools] -prettier = "latest" diff --git a/tests/cases/prettier/failure/test.toml b/tests/cases/prettier/failure/test.toml deleted file mode 100644 index 4caf9ed..0000000 --- a/tests/cases/prettier/failure/test.toml +++ /dev/null @@ -1,12 +0,0 @@ -[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/renovate-deps/fast-only-irrelevant/changes/README.md b/tests/cases/renovate-deps/fast-only-irrelevant/changes/README.md new file mode 100644 index 0000000..ef2fc0f --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-irrelevant/changes/README.md @@ -0,0 +1 @@ +# Updated diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..b46b339 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json @@ -0,0 +1,8 @@ +{ + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate.json5 b/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate.json5 new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/README.md b/tests/cases/renovate-deps/fast-only-irrelevant/files/README.md new file mode 100644 index 0000000..8ae0569 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-irrelevant/files/README.md @@ -0,0 +1 @@ +# Test diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/mise.toml b/tests/cases/renovate-deps/fast-only-irrelevant/files/mise.toml new file mode 100644 index 0000000..09e8396 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-irrelevant/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "latest" diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/package.json b/tests/cases/renovate-deps/fast-only-irrelevant/files/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-irrelevant/files/package.json @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/test.toml b/tests/cases/renovate-deps/fast-only-irrelevant/test.toml new file mode 100644 index 0000000..88dbae9 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-irrelevant/test.toml @@ -0,0 +1,10 @@ +[expected] +args = "run --fast-only" +exit = 0 + +[fake_bins] +renovate = ''' +#!/bin/sh +echo "renovate should not run for unrelated fast-only changes" >&2 +exit 1 +''' diff --git a/tests/cases/renovate-deps/fast-only-relevant/changes/package.json b/tests/cases/renovate-deps/fast-only-relevant/changes/package.json new file mode 100644 index 0000000..a4ba212 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-relevant/changes/package.json @@ -0,0 +1,3 @@ +{ + "name": "changed" +} diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..b46b339 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json @@ -0,0 +1,8 @@ +{ + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate.json5 b/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate.json5 new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/mise.toml b/tests/cases/renovate-deps/fast-only-relevant/files/mise.toml new file mode 100644 index 0000000..09e8396 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-relevant/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "latest" diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/package.json b/tests/cases/renovate-deps/fast-only-relevant/files/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-relevant/files/package.json @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/fast-only-relevant/test.toml b/tests/cases/renovate-deps/fast-only-relevant/test.toml new file mode 100644 index 0000000..e461e4f --- /dev/null +++ b/tests/cases/renovate-deps/fast-only-relevant/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --fast-only" +exit = 0 + +[expected.files] +".renovate-ran" = """ +""" + +[fake_bins] +renovate = ''' +#!/bin/sh +touch .renovate-ran +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' diff --git a/tests/cases/markdownlint-cli2/auto-fix/files/README.md b/tests/cases/rumdl/auto-fix/files/README.md similarity index 100% rename from tests/cases/markdownlint-cli2/auto-fix/files/README.md rename to tests/cases/rumdl/auto-fix/files/README.md diff --git a/tests/cases/rumdl/auto-fix/files/mise.toml b/tests/cases/rumdl/auto-fix/files/mise.toml new file mode 100644 index 0000000..738b0f3 --- /dev/null +++ b/tests/cases/rumdl/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rumdl = "latest" diff --git a/tests/cases/rumdl/auto-fix/test.toml b/tests/cases/rumdl/auto-fix/test.toml new file mode 100644 index 0000000..3d49c1e --- /dev/null +++ b/tests/cases/rumdl/auto-fix/test.toml @@ -0,0 +1,55 @@ +[expected] +args = "run --full --fix rumdl" +exit = 1 +stderr = ''' +flint: fixed: rumdl — commit before pushing +''' + +[expected.files] +"README.md" = """ +# Title + +Text with trailing spaces + +## Section +""" + +[fake_bins] +rumdl = ''' +#!/bin/sh +set -eu +file="" +fix=0 +count_file=".rumdl-invocations" +for arg in "$@"; do + case "$arg" in + --fix) fix=1 ;; + -*) ;; + *) file="$arg" ;; + esac +done +[ -n "$file" ] || exit 1 +count=0 +[ -f "$count_file" ] && count=$(cat "$count_file") +count=$((count + 1)) +printf '%s' "$count" > "$count_file" +if [ "$count" -gt 1 ]; then + printf '%s\n' 'rumdl invoked more than once in --fix flow' >&2 + exit 99 +fi +if [ "$fix" -ne 1 ]; then + printf '%s\n' 'unexpected rumdl pre-check invocation' >&2 + exit 98 +fi +if grep -q 'spaces $' "$file"; then + cat >"$file" <<'EOF' +# Title + +Text with trailing spaces + +## Section +EOF + exit 0 +fi +exit 0 +''' diff --git a/tests/cases/markdownlint-cli2/clean/files/README.md b/tests/cases/rumdl/clean/files/README.md similarity index 100% rename from tests/cases/markdownlint-cli2/clean/files/README.md rename to tests/cases/rumdl/clean/files/README.md diff --git a/tests/cases/rumdl/clean/files/mise.toml b/tests/cases/rumdl/clean/files/mise.toml new file mode 100644 index 0000000..738b0f3 --- /dev/null +++ b/tests/cases/rumdl/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rumdl = "latest" diff --git a/tests/cases/rumdl/clean/test.toml b/tests/cases/rumdl/clean/test.toml new file mode 100644 index 0000000..3f2e454 --- /dev/null +++ b/tests/cases/rumdl/clean/test.toml @@ -0,0 +1,9 @@ +[expected] +args = "run --full rumdl" +exit = 0 + +[fake_bins] +rumdl = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/cases/markdownlint-cli2/failure/files/README.md b/tests/cases/rumdl/failure/files/README.md similarity index 100% rename from tests/cases/markdownlint-cli2/failure/files/README.md rename to tests/cases/rumdl/failure/files/README.md diff --git a/tests/cases/rumdl/failure/files/mise.toml b/tests/cases/rumdl/failure/files/mise.toml new file mode 100644 index 0000000..738b0f3 --- /dev/null +++ b/tests/cases/rumdl/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rumdl = "latest" diff --git a/tests/cases/rumdl/failure/test.toml b/tests/cases/rumdl/failure/test.toml new file mode 100644 index 0000000..3864357 --- /dev/null +++ b/tests/cases/rumdl/failure/test.toml @@ -0,0 +1,17 @@ +[expected] +args = "run --full rumdl" +exit = 1 +stderr = ''' +[rumdl] +README.md:3:26: trailing spaces + +flint: 1 check failed (rumdl) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' + +[fake_bins] +rumdl = ''' +#!/bin/sh +printf '%s\n' 'README.md:3:26: trailing spaces' +exit 1 +''' diff --git a/tests/cases/prettier/auto-fix/files/config.yml b/tests/cases/yaml-lint/auto-fix/files/config.yml similarity index 100% rename from tests/cases/prettier/auto-fix/files/config.yml rename to tests/cases/yaml-lint/auto-fix/files/config.yml diff --git a/tests/cases/yaml-lint/auto-fix/files/mise.toml b/tests/cases/yaml-lint/auto-fix/files/mise.toml new file mode 100644 index 0000000..5fb0f08 --- /dev/null +++ b/tests/cases/yaml-lint/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"cargo:yaml-lint" = "latest" diff --git a/tests/cases/yaml-lint/auto-fix/test.toml b/tests/cases/yaml-lint/auto-fix/test.toml new file mode 100644 index 0000000..f0861b5 --- /dev/null +++ b/tests/cases/yaml-lint/auto-fix/test.toml @@ -0,0 +1,42 @@ +[expected] +args = "run --full --fix yaml-lint" +exit = 1 +stderr = ''' +flint: fixed: yaml-lint — commit before pushing +''' + +[expected.files] +"config.yml" = """ +name: "test" +value: 42 +items: ["a", "b"] +""" + +[fake_bins] +yaml-lint = ''' +#!/bin/sh +set -eu +file="" +fix=0 +for arg in "$@"; do + case "$arg" in + --fix) fix=1 ;; + -*) ;; + *) file="$arg" ;; + esac +done +[ -n "$file" ] || exit 1 +if grep -q 'value: 42' "$file"; then + if [ "$fix" -eq 1 ]; then + cat >"$file" <<'EOF' +name: "test" +value: 42 +items: ["a", "b"] +EOF + exit 0 + fi + printf '%s\n' 'config.yml:2: bad spacing' + exit 1 +fi +exit 0 +''' diff --git a/tests/cases/prettier/clean/files/config.yml b/tests/cases/yaml-lint/clean/files/config.yml similarity index 100% rename from tests/cases/prettier/clean/files/config.yml rename to tests/cases/yaml-lint/clean/files/config.yml diff --git a/tests/cases/yaml-lint/clean/files/mise.toml b/tests/cases/yaml-lint/clean/files/mise.toml new file mode 100644 index 0000000..5fb0f08 --- /dev/null +++ b/tests/cases/yaml-lint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"cargo:yaml-lint" = "latest" diff --git a/tests/cases/yaml-lint/clean/test.toml b/tests/cases/yaml-lint/clean/test.toml new file mode 100644 index 0000000..8cb9f27 --- /dev/null +++ b/tests/cases/yaml-lint/clean/test.toml @@ -0,0 +1,9 @@ +[expected] +args = "run --full yaml-lint" +exit = 0 + +[fake_bins] +yaml-lint = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/cases/prettier/failure/files/config.yml b/tests/cases/yaml-lint/failure/files/config.yml similarity index 100% rename from tests/cases/prettier/failure/files/config.yml rename to tests/cases/yaml-lint/failure/files/config.yml diff --git a/tests/cases/yaml-lint/failure/files/mise.toml b/tests/cases/yaml-lint/failure/files/mise.toml new file mode 100644 index 0000000..5fb0f08 --- /dev/null +++ b/tests/cases/yaml-lint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"cargo:yaml-lint" = "latest" diff --git a/tests/cases/yaml-lint/failure/test.toml b/tests/cases/yaml-lint/failure/test.toml new file mode 100644 index 0000000..969ab19 --- /dev/null +++ b/tests/cases/yaml-lint/failure/test.toml @@ -0,0 +1,17 @@ +[expected] +args = "run --full yaml-lint" +exit = 1 +stderr = ''' +[yaml-lint] +config.yml:2: bad spacing + +flint: 1 check failed (yaml-lint) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' + +[fake_bins] +yaml-lint = ''' +#!/bin/sh +printf '%s\n' 'config.yml:2: bad spacing' +exit 1 +''' diff --git a/tests/e2e.rs b/tests/e2e.rs index 52da958..7f38a1c 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -150,6 +150,172 @@ fn cases() { } } +#[cfg(unix)] +#[test] +fn markdown_tool_ignores_biome_owned_jsonc() { + let repo = git_repo(); + + std::fs::write( + repo.path().join("mise.toml"), + r#"[tools] +rumdl = "0.1.78" +biome = "2.4.12" +"#, + ) + .unwrap(); + std::fs::write(repo.path().join("README.md"), "# Test\n").unwrap(); + std::fs::write( + repo.path().join("biome.jsonc"), + r#"{ + // Keep JSON formatting aligned with the repo's two-space style. + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + }, +} +"#, + ) + .unwrap(); + + let out = Command::new("git") + .args(["add", "-A"]) + .current_dir(repo.path()) + .output() + .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("failed to spawn git commit"); + assert!( + out.status.success(), + "git commit failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let fake_bin_dir = tempfile::tempdir().expect("fake_bin tempdir"); + let rumdl = fake_bin_dir.path().join("rumdl"); + std::fs::write( + &rumdl, + r#"#!/bin/sh +set -eu + +cmd="$1" +shift + +if [ "$cmd" != "check" ]; then + echo "unsupported rumdl invocation: $cmd $*" >&2 + exit 1 +fi + +for target in "$@"; do + case "$target" in + -*) + continue + ;; + esac + + base="$(basename "$target")" + if [ "$base" = "README.md" ]; then + continue + fi + + echo "rumdl unexpectedly targeted: $target" >&2 + exit 1 +done + +exit 0 +"#, + ) + .unwrap(); + + let biome = fake_bin_dir.path().join("biome"); + std::fs::write( + &biome, + r#"#!/bin/sh +set -eu + +cmd="$1" +shift + +if [ "$cmd" = "format" ] && [ "${1:-}" = "--write" ]; then + file="$2" + cat >"$file" <<'EOF' +{ + // Keep JSON formatting aligned with the repo's two-space style. + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + } +} +EOF + exit 0 +fi + +if [ "$cmd" = "format" ]; then + file="$1" + if grep -q '"indentWidth": 2$' "$file" && grep -q '^ }$' "$file"; then + exit 0 + fi + echo "formatting differs" >&2 + exit 1 +fi + +echo "unsupported biome invocation: $cmd $*" >&2 +exit 1 +"#, + ) + .unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&rumdl, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&biome, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let fake_path = format!( + "{}:{}", + fake_bin_dir.path().display(), + std::env::var("PATH").unwrap_or_default() + ); + let fix_out = flint_with_env( + &["run", "--full", "--fix", "rumdl", "biome-format"], + repo.path(), + &[("PATH", &fake_path)], + ); + assert_eq!(fix_out.status.code(), Some(1)); + let fix_stderr = String::from_utf8_lossy(&fix_out.stderr); + assert!( + fix_stderr.contains("flint: fixed: biome-format — commit before pushing"), + "unexpected fix stderr:\n{fix_stderr}" + ); + + let biome_jsonc = std::fs::read_to_string(repo.path().join("biome.jsonc")).unwrap(); + assert!( + biome_jsonc.contains("\"indentWidth\": 2\n") + && !biome_jsonc.contains("\"indentWidth\": 2,\n"), + "expected biome-owned formatting after fix:\n{biome_jsonc}" + ); + + let check_out = flint_with_env( + &["run", "--full", "rumdl"], + repo.path(), + &[("PATH", &fake_path)], + ); + assert_eq!( + check_out.status.code(), + Some(0), + "rumdl should ignore biome-owned JSONC in full mode:\n{}", + String::from_utf8_lossy(&check_out.stderr) + ); +} + /// Recursively finds all directories containing a `test.toml` file. fn collect_cases(dir: &Path) -> Vec { let mut cases = Vec::new(); @@ -436,12 +602,12 @@ fn normalize_tool_versions(s: &str) -> String { // flint X.Y.Z (version command output) let re = Regex::new(r"flint \d+\.\d+\.\d+").unwrap(); let s = re.replace_all(s, "flint ").into_owned(); - // markdownlint-cli2 vX.Y.Z (markdownlint vA.B.C) - let re = + let old_markdownlint_banner = Regex::new(r"markdownlint-cli2 v\d+\.\d+\.\d+ \(markdownlint v\d+\.\d+\.\d+\)").unwrap(); - let s = re - .replace_all(&s, "markdownlint-cli2 ") - .into_owned(); + assert!( + !old_markdownlint_banner.is_match(&s), + "found stale markdownlint-era snapshot output; update the fixture instead of normalizing it" + ); let re = Regex::new(r"https://rust-lang\.github\.io/rust-clippy/rust-\d+\.\d+\.\d+/").unwrap(); re.replace_all( &s,