diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 47c374b6..c2224fdb 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -4,19 +4,19 @@ "mise" ] }, - ".github/workflows/release.yml": { + ".github/workflows/release-assets.yml": { "regex": [ "mise" ] }, - ".github/workflows/test.yml": { + ".github/workflows/release-plz.yml": { "regex": [ "mise" ] }, - "README.md": { + ".github/workflows/test.yml": { "regex": [ - "grafana/flint" + "mise" ] }, "mise.toml": { @@ -37,6 +37,7 @@ "node", "npm:renovate", "pipx:codespell", + "release-plz", "ruff", "rumdl", "rust", diff --git a/.github/renovate.json5 b/.github/renovate.json5 index eb5c5b55..31eb68f2 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -23,16 +23,6 @@ matchStrings: ["https://raw\\.githubusercontent\\.com/(?[^/]+/[^/]+)/(?[a-f0-9]{40})/.*#\\s*(?v\\S+)"], datasourceTemplate: "github-tags", }, - { - customType: "regex", - description: "Update flint version in README.md install snippets", - managerFilePatterns: ["/^README\\.md$/"], - matchStrings: ['"github:grafana/flint"\\s*=\\s*"(?[^"]+)"'], - datasourceTemplate: "github-releases", - depNameTemplate: "grafana/flint", - packageNameTemplate: "grafana/flint", - extractVersionTemplate: "^v?(?.+)$", - }, { customType: "regex", description: "Update mise version in GitHub Actions workflows", diff --git a/.github/workflows/release.yml b/.github/workflows/release-assets.yml similarity index 99% rename from .github/workflows/release.yml rename to .github/workflows/release-assets.yml index cf3e0ea2..cc056865 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release-assets.yml @@ -1,5 +1,5 @@ --- -name: Release +name: Release Assets on: push: diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index aecb3366..2d6f2874 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -1,5 +1,5 @@ --- -name: Release-plz +name: Release Management on: push: @@ -12,8 +12,8 @@ jobs: release: # Runs on every push to main. If the release PR was just merged (i.e. Cargo.toml # version was bumped), creates a git tag and a draft GitHub release, then triggers - # release.yml to build platform binaries and publish the release. - name: Release + # release-assets.yml to build platform binaries and publish the release. + name: Create Release runs-on: ubuntu-24.04 if: ${{ github.repository == 'grafana/flint' }} permissions: @@ -24,26 +24,22 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5 - id: release-plz + - name: Setup mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 with: - command: release + version: v2026.4.18 + sha256: 6ae2d5f0f23a2f2149bc5d9bf264fe0922a1da843f1903e453516c462b23cc1f + - name: Create release metadata env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Trigger binary release - if: ${{ steps.release-plz.outputs.releases_created == 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: > - gh workflow run release.yml - -f tag=${{ fromJSON(steps.release-plz.outputs.releases)[0].tag }} + run: mise run release:create release-pr: # Runs on every push to main. Keeps an open PR that bumps Cargo.toml version # and updates CHANGELOG.md based on conventional commits since the last release. # Merging this PR triggers the release job above. # Depends on release so the new git tag exists before we compute the next version. - name: Release PR + name: Update Release PR needs: [release] runs-on: ubuntu-24.04 if: ${{ github.repository == 'grafana/flint' }} @@ -58,8 +54,12 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5 + - name: Setup mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 with: - command: release-pr + version: v2026.4.18 + sha256: 6ae2d5f0f23a2f2149bc5d9bf264fe0922a1da843f1903e453516c462b23cc1f + - name: Update release PR + run: mise run release:pr env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.mise/tasks/release/create b/.mise/tasks/release/create new file mode 100755 index 00000000..81509a8d --- /dev/null +++ b/.mise/tasks/release/create @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +#MISE description="Create git tags and GitHub releases when a release PR was merged" +#USAGE arg "[args]" var=#true help="Additional arguments passed to release-plz release" + +set -euo pipefail + +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "GITHUB_TOKEN environment variable is not set. Exiting." + exit 1 +fi + +tmp_json="$(mktemp)" +trap 'rm -f "${tmp_json}"' EXIT + +release_args=() +if [ -n "${usage_args:-}" ]; then + # Mise provides variadic args as a shell-escaped string. + eval "release_args=(${usage_args})" +fi + +release-plz release -o json "${release_args[@]}" >"${tmp_json}" + +jq -e '.releases and (.releases | type == "array")' "${tmp_json}" >/dev/null + +if ! jq -e '.releases | length > 0' "${tmp_json}" >/dev/null; then + echo "No releases created." + exit 0 +fi + +if [ -z "${GH_TOKEN:-}" ]; then + export GH_TOKEN="${GITHUB_TOKEN}" +fi + +tag="$(jq -r '.releases[0].tag' "${tmp_json}")" + +gh workflow run release-assets.yml -f "tag=${tag}" diff --git a/.mise/tasks/release/docs-sync b/.mise/tasks/release/docs-sync new file mode 100755 index 00000000..9d2bb6f1 --- /dev/null +++ b/.mise/tasks/release/docs-sync @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#MISE description="Refresh release-managed docs snippets" + +set -euo pipefail + +cargo run --bin sync-readme-snippets +cargo run -q --bin flint -- run --fix --allow-fixed diff --git a/.mise/tasks/release/update b/.mise/tasks/release/update new file mode 100755 index 00000000..5a07c19e --- /dev/null +++ b/.mise/tasks/release/update @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +#MISE description="Update release-plz state locally" +#USAGE arg "[args]" var=#true help="Additional arguments passed to release-plz update" + +set -euo pipefail + +update_args=() +if [ -n "${usage_args:-}" ]; then + # Mise provides variadic args as a shell-escaped string. + eval "update_args=(${usage_args})" +fi + +release-plz update "${update_args[@]}" +mise run release:docs-sync diff --git a/README.md b/README.md index e70f4b96..595c91e9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Add `flint` to your repo's `mise.toml`: ```toml [tools] -"github:grafana/flint" = "0.20.4" +"github:grafana/flint" = "0.21.0" ``` Bootstrap a repo with `flint init` (scaffolds config). Install a @@ -50,29 +50,29 @@ enable during the prompt, or trim the generated tool list afterward if you run Flint reads your `[tools]` section to discover which linters to run — declaring a tool is the opt-in. No separate configuration needed to activate a check: if -ShellCheck's Flint-managed tool key is in `[tools]`, flint runs shellcheck; if -it isn't, that check is skipped. `mise install` puts all declared tools on PATH; -flint picks up whatever is there. +ShellCheck's Flint-managed tool key is present in `[tools]`, flint runs +shellcheck; otherwise that check is skipped. `mise install` puts all declared +tools on PATH; flint picks up whatever is there. Add the linting tools your project needs alongside the `flint` binary itself: ```toml [tools] -"github:grafana/flint" = "0.20.4" +"github:grafana/flint" = "0.21.0" # Add whichever linters apply to your repo: "github:koalaman/shellcheck" = "0.11.0" -shfmt = "3.13.1" +shfmt = "v3.13.1" actionlint = "1.7.10" rumdl = "0.1.78" -ruff = "0.15.11" +ruff = "0.15.12" "aqua:owenlamont/ryl" = "0.6.0" taplo = "0.10.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 -"npm:renovate" = "39.0.0" # activates renovate-deps check +rust = "1.95.0" # activates cargo-fmt + cargo-clippy +go = "1.26.2" # activates gofmt +lychee = "0.22.0" # activates links check +"npm:renovate" = "43.141.5" # activates renovate-deps check ``` Then wire up lint tasks: diff --git a/docs/linters.md b/docs/linters.md index a7f1dbda..264f0e4c 100644 --- a/docs/linters.md +++ b/docs/linters.md @@ -18,7 +18,7 @@ Every supported check, its config file (when applicable), and its scope. The | Description | Lint GitHub Actions workflow files | | Fix | no | | Binary | `actionlint` | -| Scope | [file](#scopes) | +| Scope | [file](#scope-file) | | Patterns | `.github/workflows/*.yml .github/workflows/*.yaml` | | Config | `actionlint.yml` | @@ -29,7 +29,7 @@ Every supported check, its config file (when applicable), and its scope. The | Description | Lint JS/TS/JSON files | | Fix | yes | | Binary | `biome` | -| Scope | [file](#scopes) | +| Scope | [file](#scope-file) | | Patterns | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | ## `biome-format` @@ -39,7 +39,7 @@ Every supported check, its config file (when applicable), and its scope. The | Description | Format JS/TS/JSON files | | Fix | yes | | Binary | `biome` | -| Scope | [file](#scopes) | +| Scope | [file](#scope-file) | | Patterns | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | ## `cargo-clippy` @@ -49,7 +49,7 @@ Every supported check, its config file (when applicable), and its scope. The | Description | Lint Rust code; runs on all .rs files, not just changed | | Fix | yes | | Binary | `cargo-clippy` | -| Scope | [project](#scopes) | +| Scope | [project](#scope-project) | | Patterns | `*.rs` | ## `cargo-fmt` @@ -59,7 +59,7 @@ Every supported check, its config file (when applicable), and its scope. The | Description | Format Rust code; runs on all .rs files, not just changed | | Fix | yes | | Binary | `rustfmt` | -| Scope | [project](#scopes) | +| Scope | [project](#scope-project) | | Patterns | `*.rs` | | Config | `rustfmt.toml` | @@ -70,19 +70,19 @@ Every supported check, its config file (when applicable), and its scope. The | Description | Check for common spelling mistakes | | Fix | yes | | Binary | `codespell` | -| Scope | [files](#scopes) | +| Scope | [files](#scope-files) | | Patterns | `*` | | Config | `.codespellrc` | ## `dotnet-format` -| | | -| ----------- | ---------------- | -| Description | Format C# code | -| Fix | yes | -| Binary | `dotnet` | -| Scope | [files](#scopes) | -| Patterns | `*.cs` | +| | | +| ----------- | --------------------- | +| Description | Format C# code | +| Fix | yes | +| Binary | `dotnet` | +| Scope | [files](#scope-files) | +| Patterns | `*.cs` | ## `editorconfig-checker` @@ -91,7 +91,7 @@ Every supported check, its config file (when applicable), and its scope. The | Description | Check files comply with EditorConfig settings | | Fix | no | | Binary | `ec` | -| Scope | [files](#scopes) | +| Scope | [files](#scope-files) | | Patterns | `*` | | Config | `.editorconfig-checker.json` | @@ -102,7 +102,7 @@ Every supported check, its config file (when applicable), and its scope. The | Description | Keep Flint setup current and mise.toml lint tooling canonical | | Fix | yes | | Binary | (built-in) | -| Scope | [special](#scopes) | +| Scope | [special](#scope-special) | | Patterns | `mise.toml` | Checks the repo's Flint-managed setup state and `mise.toml` layout. @@ -121,13 +121,13 @@ With `--fix`, rewrites Flint-managed config in place and advances ## `gofmt` -| | | -| ----------- | --------------- | -| Description | Format Go code | -| Fix | yes | -| Binary | `gofmt` | -| Scope | [file](#scopes) | -| Patterns | `*.go` | +| | | +| ----------- | ------------------- | +| Description | Format Go code | +| Fix | yes | +| Binary | `gofmt` | +| Scope | [file](#scope-file) | +| Patterns | `*.go` | ## `golangci-lint` @@ -136,19 +136,19 @@ With `--fix`, rewrites Flint-managed config in place and advances | Description | Lint Go code; uses --new-from-rev to scope analysis to changed code | | Fix | no | | Binary | `golangci-lint` | -| Scope | [project](#scopes) | +| Scope | [project](#scope-project) | | Patterns | `*.go` | | Config | `.golangci.yml` | ## `google-java-format` -| | | -| ----------- | -------------------- | -| Description | Format Java code | -| Fix | yes | -| Binary | `google-java-format` | -| Scope | [files](#scopes) | -| Patterns | `*.java` | +| | | +| ----------- | --------------------- | +| Description | Format Java code | +| Fix | yes | +| Binary | `google-java-format` | +| Scope | [files](#scope-files) | +| Patterns | `*.java` | ## `hadolint` @@ -157,7 +157,7 @@ With `--fix`, rewrites Flint-managed config in place and advances | Description | Lint Dockerfiles | | Fix | no | | Binary | `hadolint` | -| Scope | [file](#scopes) | +| Scope | [file](#scope-file) | | Patterns | `Dockerfile Dockerfile.* *.dockerfile` | | Config | `.hadolint.yaml` | @@ -168,7 +168,7 @@ With `--fix`, rewrites Flint-managed config in place and advances | Description | Lint and format Kotlin code | | Fix | yes | | Binary | `ktlint` | -| Scope | [files](#scopes) | +| Scope | [files](#scope-files) | | Patterns | `*.kt *.kts` | ## `license-header` @@ -178,7 +178,7 @@ With `--fix`, rewrites Flint-managed config in place and advances | Description | Check source files have the required license header | | Fix | no | | Binary | (built-in) | -| Scope | [special](#scopes) | +| Scope | [special](#scope-special) | ## `lychee` @@ -187,7 +187,7 @@ With `--fix`, rewrites Flint-managed config in place and advances | Description | Check for broken links | | Fix | no | | Binary | `lychee` | -| Scope | [special](#scopes) | +| Scope | [special](#scope-special) | | Config | via `[checks.links]` in flint.toml | Orchestrates [lychee](https://lychee.cli.rs/) for link checking. Requires `lychee` in `[tools]`. @@ -212,7 +212,7 @@ check_all_local = true | Description | Verify Renovate dependency snapshot is up to date | | Fix | yes | | Binary | `renovate` | -| Scope | [special](#scopes) | +| Scope | [special](#scope-special) | | 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 | @@ -231,25 +231,25 @@ exclude_managers = ["github-actions", "github-runners"] ## `ruff` -| | | -| ----------- | ---------------- | -| Description | Lint Python code | -| Fix | yes | -| Binary | `ruff` | -| Scope | [file](#scopes) | -| Patterns | `*.py` | -| Config | `ruff.toml` | +| | | +| ----------- | ------------------- | +| Description | Lint Python code | +| Fix | yes | +| Binary | `ruff` | +| Scope | [file](#scope-file) | +| Patterns | `*.py` | +| Config | `ruff.toml` | ## `ruff-format` -| | | -| ----------- | ------------------ | -| Description | Format Python code | -| Fix | yes | -| Binary | `ruff` | -| Scope | [file](#scopes) | -| Patterns | `*.py` | -| Config | `ruff.toml` | +| | | +| ----------- | ------------------- | +| Description | Format Python code | +| Fix | yes | +| Binary | `ruff` | +| Scope | [file](#scope-file) | +| Patterns | `*.py` | +| Config | `ruff.toml` | ## `rumdl` @@ -258,7 +258,7 @@ exclude_managers = ["github-actions", "github-runners"] | Description | Lint Markdown files for style and consistency | | Fix | yes | | Binary | `rumdl` | -| Scope | [file](#scopes) | +| Scope | [file](#scope-file) | | Patterns | `*.md` | | Config | `.rumdl.toml` | @@ -269,7 +269,7 @@ exclude_managers = ["github-actions", "github-runners"] | Description | Lint YAML files for style and consistency | | Fix | yes | | Binary | `ryl` | -| Scope | [files](#scopes) | +| Scope | [files](#scope-files) | | Patterns | `*.yml *.yaml` | | Config | `.yamllint.yml` | @@ -280,7 +280,7 @@ exclude_managers = ["github-actions", "github-runners"] | Description | Lint shell scripts for common mistakes | | Fix | no | | Binary | `shellcheck` | -| Scope | [file](#scopes) | +| Scope | [file](#scope-file) | | Patterns | `*.sh *.bash *.bats` | | Config | `.shellcheckrc` | @@ -291,19 +291,19 @@ exclude_managers = ["github-actions", "github-runners"] | Description | Format shell scripts | | Fix | yes | | Binary | `shfmt` | -| Scope | [file](#scopes) | +| Scope | [file](#scope-file) | | Patterns | `*.sh *.bash` | ## `taplo` -| | | -| ----------- | ----------------- | -| Description | Format TOML files | -| Fix | yes | -| Binary | `taplo` | -| Scope | [file](#scopes) | -| Patterns | `*.toml` | -| Config | `.taplo.toml` | +| | | +| ----------- | ------------------- | +| Description | Format TOML files | +| Fix | yes | +| Binary | `taplo` | +| Scope | [file](#scope-file) | +| Patterns | `*.toml` | +| Config | `.taplo.toml` | Formats TOML files with [Taplo](https://taplo.tamasfe.dev/). @@ -321,22 +321,34 @@ support, so treat this check as TOML 1.0-oriented for now. | Description | Validate XML files are well-formed | | Fix | no | | Binary | `xmllint` | -| Scope | [files](#scopes) | +| Scope | [files](#scope-files) | | Patterns | `*.xml` | ## Scopes -- `file` — invoked once per matched file -- `files` — invoked once with all matched files as args; only changed files are +### Scope: `file` + +Invoked once per matched file. + +### Scope: `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 + +### Scope: `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 `--new-from-rev` to scope analysis to changed code even within the project run. +### Scope: `special` + +Implemented in-process rather than via a command template. These checks may run +without file arguments or use custom orchestration logic. + Checks use one of three run policies: - `fast` — always runs, including in `--fast-only` diff --git a/mise.toml b/mise.toml index a1aef89f..4f33b950 100644 --- a/mise.toml +++ b/mise.toml @@ -5,6 +5,7 @@ FLINT_CONFIG_DIR = ".github/config" dotnet = "10.0.201" go = "1.26.2" node = "24.15.0" +release-plz = "0.3.157" rust = { version = "1.95.0", components = "clippy,rustfmt" } # Linters @@ -32,11 +33,16 @@ file = "tasks/setup/update-super-linter-versions.sh" [tasks.lint] description = "Run all lints" -run = "cargo run -q -- run" +run = "cargo run -q --bin flint -- run" [tasks."lint:fix"] description = "Auto-fix lint issues" -run = "cargo run -q -- run --fix" +run = "cargo run -q --bin flint -- run --fix" + +[tasks."release:pr"] +description = "Open or update the release PR" +depends = ["release:update"] +run = "release-plz release-pr --allow-dirty" [tasks.build] description = "Build the project" diff --git a/release-plz.toml b/release-plz.toml index 42d26421..8f9aecda 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -1,9 +1,3 @@ [workspace] git_release_draft = true publish = false -pr_body = """ -{% for release in releases %}{{ release.changelog }}{% endfor %} - -> [!IMPORTANT] -> Close and reopen this PR to trigger CI checks. -""" diff --git a/src/bin/sync-readme-snippets.rs b/src/bin/sync-readme-snippets.rs new file mode 100644 index 00000000..1e98936b --- /dev/null +++ b/src/bin/sync-readme-snippets.rs @@ -0,0 +1,127 @@ +#[path = "../readme_snippets.rs"] +mod readme_snippets; + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use toml::Value; + +fn main() -> Result<()> { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let readme_path = repo_root.join("README.md"); + let mise_path = repo_root.join("mise.toml"); + + let mut readme = fs::read_to_string(&readme_path).context("read README.md")?; + let mise = fs::read_to_string(&mise_path).context("read mise.toml")?; + let mise: Value = toml::from_str(&mise).context("parse mise.toml")?; + let tools = mise["tools"] + .as_table() + .context("mise.toml must contain [tools]")?; + + let install_block = format!( + "[tools]\n\"github:grafana/flint\" = \"{}\"", + env!("CARGO_PKG_VERSION") + ); + replace_fenced_block( + &mut readme, + readme_snippets::INSTALL_MARKER, + "toml", + &install_block, + )?; + + let quickstart_block = render_quickstart_tools(tools)?; + replace_fenced_block( + &mut readme, + readme_snippets::QUICKSTART_MARKER, + "toml", + &quickstart_block, + )?; + + fs::write(&readme_path, readme).context("write README.md")?; + Ok(()) +} + +fn tool_versions(table: &toml::Table, keys: &[&str]) -> Result> { + keys.iter() + .map(|key| { + let value = table + .get(*key) + .with_context(|| format!("missing tool key {key:?} in mise.toml"))?; + let version = value + .as_str() + .map(ToOwned::to_owned) + .or_else(|| { + value + .as_table() + .and_then(|t| t.get("version")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .with_context(|| format!("tool key {key:?} must have a string version"))?; + Ok(((*key).to_string(), version)) + }) + .collect() +} + +fn render_quickstart_tools(table: &toml::Table) -> Result { + let versions = tool_versions(table, readme_snippets::QUICKSTART_KEYS)?; + Ok(format!( + "[tools]\n\ +\"github:grafana/flint\" = \"{flint}\"\n\ +\n\ +# Add whichever linters apply to your repo:\n\ +\"github:koalaman/shellcheck\" = \"{shellcheck}\"\n\ +shfmt = \"{shfmt}\"\n\ +actionlint = \"{actionlint}\"\n\ +rumdl = \"{rumdl}\"\n\ +ruff = \"{ruff}\"\n\ +\"aqua:owenlamont/ryl\" = \"{ryl}\"\n\ +taplo = \"{taplo}\"\n\ +biome = \"{biome}\"\n\ +rust = \"{rust}\" # activates cargo-fmt + cargo-clippy\n\ +go = \"{go}\" # activates gofmt\n\ +lychee = \"{lychee}\" # activates links check\n\ +\"npm:renovate\" = \"{renovate}\" # activates renovate-deps check", + flint = env!("CARGO_PKG_VERSION"), + shellcheck = versions["github:koalaman/shellcheck"], + shfmt = versions["shfmt"], + actionlint = versions["actionlint"], + rumdl = versions["rumdl"], + ruff = versions["ruff"], + ryl = versions["aqua:owenlamont/ryl"], + taplo = versions["taplo"], + biome = versions["biome"], + rust = versions["rust"], + go = versions["go"], + lychee = versions["lychee"], + renovate = versions["npm:renovate"], + )) +} + +fn replace_fenced_block( + haystack: &mut String, + marker: &str, + lang: &str, + replacement: &str, +) -> Result<()> { + let marker_pos = haystack + .find(marker) + .with_context(|| format!("missing marker {marker:?}"))?; + let after_marker = marker_pos + marker.len(); + let fence = format!("```{lang}\n"); + let rel_start = haystack[after_marker..] + .find(&fence) + .with_context(|| format!("missing {lang} fenced block after {marker:?}"))?; + let block_start = after_marker + rel_start + fence.len(); + let rel_end = haystack[block_start..] + .find("\n```") + .with_context(|| format!("missing closing fence after {marker:?}"))?; + let block_end = block_start + rel_end; + if replacement.contains("```") { + bail!("replacement block cannot contain code fences"); + } + haystack.replace_range(block_start..block_end, replacement); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 4540eb5c..eed7e45f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; #[derive(Parser, Debug)] -#[command(name = "flint", about = "flint — fast lint")] +#[command(name = "flint", bin_name = "flint", about = "flint — fast lint")] #[command(subcommand_required = true, arg_required_else_help = true)] struct Cli { #[command(subcommand)] @@ -78,6 +78,11 @@ struct RunArgs { #[arg(long, env = "FLINT_FIX")] fix: bool, + /// In --fix mode, exit 0 when all reported issues were fixed successfully. + /// Still exits non-zero when any check is partial or needs review. + #[arg(long, requires = "fix")] + allow_fixed: bool, + /// Lint all files instead of only changed files. #[arg(long, env = "FLINT_FULL")] full: bool, @@ -276,7 +281,10 @@ async fn run( .next() .expect("flint-setup preflight produced a result"); if args.fix { - finish_fix_outcomes(vec![classify_single_pass_fix(setup_result)]); + finish_fix_outcomes( + vec![classify_single_pass_fix(setup_result)], + args.allow_fixed, + ); } else if !setup_result.ok { let failed = [setup_result.name.as_str()]; if args.short { @@ -463,7 +471,7 @@ async fn run( } } - finish_fix_outcomes(outcomes); + finish_fix_outcomes(outcomes, args.allow_fixed); return Ok(()); } @@ -545,7 +553,7 @@ impl FixOutcome { } } -fn finish_fix_outcomes(outcomes: Vec) { +fn finish_fix_outcomes(outcomes: Vec, allow_fixed: bool) { // Emit linter output for checks that need manual review so the caller has // the failure details without a second flint invocation. for r in outcomes.iter().filter_map(FixOutcome::result) { @@ -589,7 +597,9 @@ fn finish_fix_outcomes(outcomes: Vec) { } if !segments.is_empty() { eprintln!("flint: {}", segments.join(" | ")); - std::process::exit(1); + if !allow_fixed || !partial.is_empty() || !review.is_empty() { + std::process::exit(1); + } } } diff --git a/src/readme_snippets.rs b/src/readme_snippets.rs new file mode 100644 index 00000000..175ca4f9 --- /dev/null +++ b/src/readme_snippets.rs @@ -0,0 +1,17 @@ +pub const INSTALL_MARKER: &str = "Add `flint` to your repo's `mise.toml`:"; +pub const QUICKSTART_MARKER: &str = + "Add the linting tools your project needs alongside the `flint` binary itself:"; +pub const QUICKSTART_KEYS: &[&str] = &[ + "github:koalaman/shellcheck", + "shfmt", + "actionlint", + "rumdl", + "ruff", + "aqua:owenlamont/ryl", + "taplo", + "biome", + "rust", + "go", + "lychee", + "npm:renovate", +]; diff --git a/src/registry/tests.rs b/src/registry/tests.rs index 937cced4..fb3d5a0b 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -3,6 +3,9 @@ use std::path::Path; use super::*; +#[path = "../readme_snippets.rs"] +mod readme_snippets; + #[test] fn find_obsolete_key_returns_none_for_clean_tools() { let mut tools = HashMap::new(); @@ -533,6 +536,118 @@ fn sorted_package_names(rule: &serde_json::Value) -> Vec<&str> { names } +fn extract_fenced_block_after<'a>(haystack: &'a str, marker: &str, lang: &str) -> &'a str { + let start = haystack + .find(marker) + .unwrap_or_else(|| panic!("missing marker {marker:?}")); + let after_marker = &haystack[start + marker.len()..]; + let fence = format!("```{lang}\n"); + let block_start = after_marker + .find(&fence) + .unwrap_or_else(|| panic!("missing {lang} fenced block after {marker:?}")) + + fence.len(); + let rest = &after_marker[block_start..]; + let block_end = rest + .find("\n```") + .unwrap_or_else(|| panic!("missing closing fence after {marker:?}")); + &rest[..block_end] +} + +fn toml_tool_versions_from_table( + table: &toml::Table, + keys: &[&str], +) -> std::collections::BTreeMap { + keys.iter() + .map(|key| { + let value = table + .get(*key) + .unwrap_or_else(|| panic!("missing tool key {key:?}")); + let version = value + .as_str() + .map(ToOwned::to_owned) + .or_else(|| { + value + .as_table() + .and_then(|t| t.get("version")) + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| panic!("tool key {key:?} must have a string version")); + ((*key).to_string(), version) + }) + .collect() +} + +#[test] +fn readme_quickstart_tools_snippets_stay_current() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let readme_path = manifest_dir.join("README.md"); + let mise_path = manifest_dir.join("mise.toml"); + + let readme = std::fs::read_to_string(&readme_path).expect("README.md must be readable"); + let mise = std::fs::read_to_string(&mise_path).expect("mise.toml must be readable"); + + let install_block = + extract_fenced_block_after(&readme, readme_snippets::INSTALL_MARKER, "toml"); + let install_toml: toml::Value = + toml::from_str(install_block).expect("README install block must be valid TOML"); + let install_tools = install_toml["tools"] + .as_table() + .expect("README install block must contain [tools]"); + assert_eq!( + install_tools + .get("github:grafana/flint") + .and_then(toml::Value::as_str), + Some(env!("CARGO_PKG_VERSION")), + "README install snippet must pin the current flint release" + ); + + let quickstart_block = + extract_fenced_block_after(&readme, readme_snippets::QUICKSTART_MARKER, "toml"); + let quickstart_toml: toml::Value = + toml::from_str(quickstart_block).expect("README quickstart block must be valid TOML"); + let quickstart_tools = quickstart_toml["tools"] + .as_table() + .expect("README quickstart block must contain [tools]"); + + let repo_mise: toml::Value = toml::from_str(&mise).expect("mise.toml must be valid TOML"); + let repo_tools = repo_mise["tools"] + .as_table() + .expect("repo mise.toml must contain [tools]"); + + let expected = toml_tool_versions_from_table(repo_tools, readme_snippets::QUICKSTART_KEYS) + .into_iter() + .chain(std::iter::once(( + "github:grafana/flint".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ))) + .collect::>(); + + let actual = toml_tool_versions_from_table( + quickstart_tools, + &[ + "github:grafana/flint", + "github:koalaman/shellcheck", + "shfmt", + "actionlint", + "rumdl", + "ruff", + "aqua:owenlamont/ryl", + "taplo", + "biome", + "rust", + "go", + "lychee", + "npm:renovate", + ], + ); + + assert_eq!( + actual, expected, + "README quickstart [tools] snippet drifted from current repo tool versions" + ); +} + /// Verifies README summary table and docs/linters.md detail sections stay /// in sync with the registry. The summary table lives in README.md between /// `registry-table-*` markers; the per-linter detail sections live in @@ -733,7 +848,7 @@ fn detail_rows(check: &Check) -> Vec<(&'static str, String)> { rows.push(("Binary", binary)); let scope = check.kind.scope_name(); - rows.push(("Scope", format!("[{scope}](#scopes)"))); + rows.push(("Scope", format!("[{scope}](#scope-{scope})"))); if !check.patterns.is_empty() { rows.push(("Patterns", format!("`{}`", check.patterns.join(" ")))); diff --git a/tests/cases/general/allow-fixed-requires-fix/files/.gitkeep b/tests/cases/general/allow-fixed-requires-fix/files/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/cases/general/allow-fixed-requires-fix/files/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/cases/general/allow-fixed-requires-fix/test.toml b/tests/cases/general/allow-fixed-requires-fix/test.toml new file mode 100644 index 00000000..be54c84c --- /dev/null +++ b/tests/cases/general/allow-fixed-requires-fix/test.toml @@ -0,0 +1,11 @@ +[expected] +args = "run --allow-fixed" +exit = 2 +stderr = ''' +error: the following required arguments were not provided: + --fix + +Usage: flint run --fix --allow-fixed [LINTERS]... + +For more information, try '--help'. +''' diff --git a/tests/cases/shfmt/auto-fix-allow-fixed/files/mise.toml b/tests/cases/shfmt/auto-fix-allow-fixed/files/mise.toml new file mode 100644 index 00000000..54b34144 --- /dev/null +++ b/tests/cases/shfmt/auto-fix-allow-fixed/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shfmt = "v3.12.0" diff --git a/tests/cases/shfmt/auto-fix-allow-fixed/files/script.sh b/tests/cases/shfmt/auto-fix-allow-fixed/files/script.sh new file mode 100644 index 00000000..e66e97f2 --- /dev/null +++ b/tests/cases/shfmt/auto-fix-allow-fixed/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/shfmt/auto-fix-allow-fixed/test.toml b/tests/cases/shfmt/auto-fix-allow-fixed/test.toml new file mode 100644 index 00000000..0edf150d --- /dev/null +++ b/tests/cases/shfmt/auto-fix-allow-fixed/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full --fix --allow-fixed shfmt" +exit = 0 +stderr = ''' +flint: fixed: shfmt — commit before pushing +''' + +[expected.files] +"script.sh" = """ +#!/bin/sh +if true; then + echo "hello" +fi +"""